Changelog¶
All notable changes to boring are documented here. Format follows Keep a Changelog; versions follow Semantic Versioning.
[Unreleased]¶
[0.15.0] — 2026-06-19¶
Added¶
- boring-ui "mission control" cockpit (ARD-0041). The browser surface grows from one-project-at-a-time into a multi-project cockpit: a live dashboard of project cards (status resolved from socket reachability, last-activity, running/total counts) (#34), and a tab bar that opens several projects at once in same-origin iframes — switch/close, a "+" to add a registered project on the fly, and open tabs persist across refresh via localStorage (#36).
- Egress internal-network blocks —
cross_sandbox+ RFC1918 (ARD-0036). Inenforce/learnmodes the container's own docker subnet and the private ranges (10/8,172.16/12,192.168/16) are dropped, so a compromised agent can't reach sibling sandboxes or the host LAN. The DNS resolver and the profile's declared sidecars (services[].name, resolved at boot) are carved out first sodev → postgres/rediskeeps working; fails open (skips the blocks) if a sidecar won't resolve rather than severing it. Container-verified with a real postgres sidecar. (#33)
Docs / Decisions¶
- README refreshed to the current surface + a de-rotting curated ARD index (#35).
- ARD-0043 — "multiple chat threads +
/resume" resolved (via/grill-me) to parallel worktree workspaces, the deep end of the ARD-0041 cockpit (workspace = worktree + branch + full sandbox, registered as a Project, reusing the dashboard/tabs). Designed and deferred pending validation of the single-workspace cockpit; the single continuous thread stays the non-engineer default (#37).
[0.14.0] — 2026-06-18¶
Added¶
boring open --unsafe-network+ an always-on egress floor (ARD-0011, ARD-0036).install-egressnow drops cloud-metadata (169.254.169.254, ECS169.254.170.2, EC2 IMDSv6) and link-local (169.254.0.0/16,fe80::/10) unconditionally in every mode — the #1 SSRF/credential-theft target — with the DNS resolver carved out so name resolution survives.--unsafe-networkrelaxes the allowlist to default-ACCEPT while keeping that floor; mutually exclusive with--learn-mode. Runtime-verified in a NET_ADMIN container. (#14)extensions:/extension_settings:profile fields (ARD-0018). Declare VS Code extensions (publisher.id[@version]) and workspace settings; codegen emits them intodevcontainer.json'scustomizations.vscode(bare ids +autoUpdate:false; settings merged). Invalid ids rejected at parse. (#17)boring doctorrepo-side safety-net checks (ARD-0016). Warns (never fails) on missing GitHub branch protection — PR-required, ≥1 review, force-push blocked — and a missing.github/PULL_REQUEST_TEMPLATE.md; skips cleanly for non-GitHub/offline. Ships a PR template per preset. (#15)boring audit --agent <name>filter +agent:attribution on every audit event (ARD-0027). The bashaudit-emitshim now stampsagent:(defaultclaude); the Go backend already did. (#21)- Turn-outcome classification (ARD-0038). boring-ui's
turn_completecarries averdict(ok/agent_no_output/agent_error/nonzero_exit), andboring runmaps verdicts to distinct exit codes (0/3/ claude's own) with a stderr diagnostic — so CI can tell "the agent produced nothing" from "claude errored." (#16)
Fixed¶
- Per-profile
CLAUDE.mdnow reaches the in-container Claude (ARD-0017). It was generated but never bind-mounted or@-included, so the per-profile workflow rules silently reached OpenCode (viaAGENTS.md) but not Claude. Now RO-mounted and@-imported, mirroring theAGENTS.mdpath. (#13) smoke-ard-0015no longer flakes CI when the runner denies the NFLOG netfilter-scheduler capability — the live-capture steps skip (not fail); rule-emission checks still run. (#23)
Decisions (ARDs)¶
- ARD-0041 — the flashy multi-agent "mission control" cockpit will be built on the web substrate (not a native/Ghostty terminal); native deferred behind an explicit trigger.
- ARD-0042 — remote/hosted boring access model: trusted-share now → team-hosted target → public SaaS parked, with the egress internal-network blocks as a hard prerequisite.
[0.13.0] — 2026-06-18¶
Added¶
- Machine-level profile overlay at
${XDG_CONFIG_HOME:-~/.config}/boring/overlays/<name>.yaml, merged after the repo overlay (machine wins), for per-machine operational facts (e.g. a DB port already taken on one box) that survive per-worktree overlay regeneration. Headlessboring runignores it. (ARD-0040, #8) - boring-ui preview: multiple tabs + editable address bar.
preview_urls:renders one tab per declared upstream, each with its own dedicated-origin reverse proxy; the address bar navigates within the proxied origin. (ARD-0035, #7) AgentProvidercontract for the boring-ui backend. The agent harness is now a typed interface that threads boring's guardrails + audit through each turn: profile-resolved tool allowlist,policy_blockedevents routed to the ARD-0010 audit FIFO with anagent:field, and claude session continuity via--resume. Makes the future claude→opencode swap an interface implementation. (ARD-0037, #11)- SNI-aware egress proxy prototype + 3-module Go CI. (ARD-0034, #4)
- emdash Cloudflare Workers example. (#5)
Changed¶
- Profile overlays are now filtered to operational fields only. Both the repo-local and machine-level overlays drop any security- or identity-relevant key (
egress,guardrails,allowed_paths,data_sensitivity,save,restore,claude,name,preset,profile_version) and anyenvsecret://URI, with a per-key warning — enforcing in code the previously documented-but-unenforced "overlays can't expand the surface" guarantee. (ARD-0040, #8) data_sensitivityis documented and treated as operator-asserted, not boring-verified.boring open/boring runnow warn whendata_sensitivity: sanitizedis declared but norestore:entry carries a boring-runtransform:— i.e. sanitization happens outside boring's view and cannot be verified. (ARD-0039, #9)
Fixed¶
boring doctorno longer false-negativesdbx restore --transform/--into. dbx 0.x exposes no per-subcommand--help, so the old grep probe always failed; doctor now version-gates on dbx ≥ 0.11.0 (the release that shipped both flags). (#10)- Hardened secret resolution + escaping of literal
envvalues in generated compose. (#3)
[0.12.1] — 2026-06-04¶
Fixed¶
compose.sh: newline between a userservices:sidecar and the egress-logger block.sidecars_blockcomes from$(...), which strips its trailing newline, so the egress-logger block was glued onto the last sidecar line (e.g.retries: 10 egress-logger:) — producing invaliddocker-compose.ymlwhenever a profile declared both aservices:sidecar andegress:, failingdocker compose config/devcontainer upat generation. (#2)
[0.12.0] — 2026-05-26¶
Changed¶
- Preview iframe now loads a dedicated-origin reverse proxy, not a same-origin sub-path (ARD-0033, supersedes ARD-0031 §1).
boring-ui-backendstarts a second HTTP listener on a deterministic per-slug host port (--preview-port, range8700..9199viaweb_ui_preview_port) that reverse-proxies at root to--preview-url, strippingX-Frame-Options+ CSPframe-ancestorson every response. The right-pane iframesrcis now the absolutehttp://127.0.0.1:<preview-port>/. - Why: end-to-end testing against a real Shopify theme dev server (
shopify theme devon:9292) showed the storefront references every asset with root-absolute URLs (/cdn/...,/checkouts/...,/web-pixels@.../). Under the old/<slug>/preview/sub-path those escaped the prefix, hit the host proxy root, and 404'd astext/plainwithframe-ancestors 'none'— producing a wall of MIME ("Refused to apply style/execute script") and framing errors and a blank/broken preview. A<base href>can't fix root-absolute URLs; serving the preview at its own origin root makes them resolve back into the proxy. (:9292setsX-Frame-Options: DENY, so stripping via a proxy is still required — we can't iframe it directly.) - The header strip's URL display + open-in-new-tab link continue to show/open the upstream URL; only the iframe target moved to the dedicated origin.
- The backend
/preview/*sub-path route is removed. WebSocket upgrade + query strings are preserved (HMR / Shopify theme hot-reload keep working). A preview-port bind collision logs a warning and disables the preview without taking down the chat UI. - Known trade-off: the preview iframe is now cross-origin to the chat UI, so upstream
SameSite=Lax/Strictcookies aren't sent on in-iframe subrequests (cart/session may not persist across navigations). Acceptable for a dev preview; unavoidable given root-absolute upstream URLs. See ARD-0033.
Added¶
- Resizable + collapsible boring-ui panes. A draggable divider between the left and right panes (pointer-capture so the drag survives crossing the iframes; Arrow-Left/Right nudge when focused), plus header toggles to hide the left pane (
◧) or the preview (◨) — at most one collapsed at a time. Layout (split ratio + which pane is hidden) persists per project inlocalStorage. Works identically whether the left pane is the chat thread or the--terminal-urlterminal iframe. (assets/index.html,assets/chat.css,assets/chat.js.) - Preview address bar tracks in-frame navigation. As you click around the previewed app, the header URL + open-in-new-tab link update to the current page. Because the preview is now a separate origin (ARD-0033), the chat UI can't read the iframe's location directly, so the preview proxy injects a tiny same-origin script (
/__boring_nav.js) into proxied HTML thatpostMessages the current path up to the chat UI, which maps it onto the upstream URL for display. Only the top preview frame reports (window.parent === window.top), so Shopify's nested web-pixel/analytics sandbox iframes don't pollute the bar. To inject reliably the proxy stripsAccept-Encodingoutbound (Go's transport then transparently decompresses) and, defensively, allows'self'in any upstreamscript-src. Catches full page loads + history (pushState/popstate/hashchange) navigations.
[0.11.0] — 2026-05-26¶
Added¶
boring secret {set|get|rm} <service>/<account>(ARD-0032). Provisions a secret into the host OS keyring (macOS Keychain viasecurity; Linux libsecret viasecret-tool) — the same backend thesecret://keychain:resolver already reads. Lets an engineer/IT drop a credential (e.g. a Shopify Theme Access token) onto a machine once at onboarding;boring openthen resolves it and injects it via the existing--remote-envpath with zero per-use auth. A non-engineer runsboring open(or clicks the project in the boring-ui picker, samecmd_open) and the container is pre-authenticated — no OAuth prompt, no vault sign-in, no.env.setreads the value from stdin so it never enters argv or shell history. boring still owns no secret store (ARD-0002 intent preserved): it writes the OS's existing keyring only.listis intentionally omitted — enumerating generic-password items is awkward and inconsistent acrosssecurity/secret-tool;get/rmcover the need.
[0.10.1] — 2026-05-26¶
Fixed¶
chat.jsno longer throwsTypeError: Cannot read properties of null (reading 'addEventListener')in terminal-pane mode. When--terminal-urlis set,renderIndexsubstitutes{{LEFT_PANE}}with an iframe only — there's no#thread,#composer, or#inputin the DOM. v0.10.0 (and earlier--terminal-url-using versions back to v0.8.0) unconditionally calledcomposer.addEventListener(...)at script init, throwing immediately and blocking subsequent JS initialization (including the preview-refresh handler attached later in the same file). Fix: detect chat-pane presence once at script init (hasChatPane), guard chat-only bindings + render branches, keep SSE attachment + save dialog handlers in BOTH modes so save events still drive toast feedback when the user clicks Save in the terminal-mode UI. Save card rendering (renderSaveCard) is gated since it writes to#thread; toast still fires for save_succeeded / save_failed.
[0.10.0] — 2026-05-26¶
Added¶
/preview/*reverse-proxy route on boring-ui-backend (ARD-0031). The chat UI's right-pane iframe now loads via/preview/on the same origin as the chat page instead of the absolute upstream URL. The backend forwards requests to the configured--preview-url, surgically strips iframe-blocking response headers, and preserves WebSocket upgrade so HMR keeps working.X-Frame-Optionsheader: deleted entirely on every proxied response.Content-Security-Policyheader: theframe-ancestorsdirective is scrubbed (case-insensitive, whole-directive-name match) while every other directive (script-src,style-src,default-src, etc.) is preserved. Ifframe-ancestorswas the only directive, the whole CSP header is deleted.- WebSocket upgrade: passes through
Upgrade: websocket+Connection: upgradehandshake bidirectionally — Vite, Next, Rails (Hotwire), Shopify theme-kit HMR all keep working. - Cross-origin headers (
Cross-Origin-Resource-Policy,Cross-Origin-Opener-Policy,Cross-Origin-Embedder-Policy) are NOT stripped — these govern different cross-origin contexts and don't usually block iframes. Revisit if field evidence shows otherwise. - Same-origin iframe additionally dodges
SameSite=Strictcookie scoping and 2026's credentialed-fetch tightening — chat + preview share one origin, one cookie jar. - Closes a v0.9.x ship-blocker: iframing Shopify (
X-Frame-Options: DENY) and other production-shaped upstreams was structurally broken — iframe rendered blank regardless of cross-origin / cookie config. Same-origin proxy + header strip makes it work.
Changed¶
- Iframe
srcis now relative/preview/instead of the absolute preview URL. The header strip's URL display and "open in new tab" link still surface the absolute URL so the user knows what's being proxied; only the iframe itself uses the same-origin path. boring-ui-backend --preview-urlflag doc updated to note the new/preview/reverse-proxy behavior per ARD-0031.
Files touched¶
tools/boring-ui-backend/preview.go(new) —handlePreview+stripFrameBlockingHeaders+removeFrameAncestorsDirective. Stdlib-only (net/http,net/http/httputil,net/url,strings). Heavily commented with a local-dev-only safety boundary warning at the file top.tools/boring-ui-backend/preview_test.go(new) — 17 tests across the unit helpers, route registration, end-to-end proxy behavior, path-prefix stripping, host-header rewriting, 404/502 error paths, and stdlib-only WebSocket Upgrade handshake. Usesnet/http/httptestto mock every upstream — no live Shopify / claude / docker invocation.tools/boring-ui-backend/server.go— mount/preview+/preview/routes before the/catch-all; iframesrcinrenderIndexswitched from absolute URL to relative/preview/.tools/boring-ui-backend/server_test.go—TestIndexPreviewIframeWhenURLSetupdated to assert the new relative-src behavior + guard against the old absolute-src regressing.tools/boring-ui-backend/main.go—--preview-urlflag doc updated.boring— VERSION → 0.10.0.CHANGELOG.md— this entry.
Known limitations (transparency)¶
- Hardcoded absolute URLs in upstream response bodies aren't rewritten. If an upstream's HTML/JS/CSS contains absolute
http://127.0.0.1:9292/assets/foo.jsreferences (rather than relative/assets/foo.js), those fetches bypass the proxy. Most modern frameworks emit relative paths, so this affects only a minority of upstreams; documented in ARD-0031 §5. Overridepreview_url:to a path the upstream cooperates with if it bites you. - Stripping security headers is contextually safe only because the user is iframing their own local dev server. Comment at the top of
preview.goflags this loudly — anyone copyingstripFrameBlockingHeadersinto a general-purpose proxy needs to re-read ARD-0031 §Rationale first. - No per-project knob to disable header stripping. ARD-0031 §Rationale notes "a future config option could let a profile DISABLE header stripping (trust the upstream's framing rules)" — deferred until real demand emerges.
- Backend uptime is now on the critical path for the preview. Backend dies → preview dies. Today: backend dies → chat thread also dies, so the marginal cost is small.
[0.9.1] — 2026-05-26¶
Fixed¶
- Preset preview-URL defaults switched from
localhostto127.0.0.1inweb_ui_preset_preview_default(lib/web_ui.sh). Surfaced while testing shop-theme: the v0.8.1 defaulthttp://localhost:9292/produced a blank iframe even withpnpm devserving correctly inside the container. Root cause is the same IPv6/IPv4 mismatch family as v0.7.x'sIMMICH_HOSTfix — docker-compose port forwards bind IPv4 only by default; macOS resolveslocalhostto::1(IPv6) first via getaddrinfo; the iframe request hits IPv6 loopback :9292 (nothing listening), gives up or noticeably delays before retry. Explicit127.0.0.1matches what docker-compose actually binds. Updated for all four affected presets: shopify (9292), django-node (5173), node (3000), node-postgres (3000).
Profile override unchanged: if you want localhost (e.g. IPv6 testing) or a custom host, preview_url: (top-level) or ui.preview_url: still wins over the preset default per the ARD-0022 §6 resolution chain — no regression for anyone who's set their own URL.
[0.9.0] — 2026-05-26¶
Added¶
- Profile
dev:block (lib/profile.sh). New optional top-level map; closes the "boring readies the box but no app server, no auth prompt" gap that surfaced when shop-theme was opened in the web UI tonight. Schema: dev.command(string OR list-of-strings; required when block present; list entries are joined with spaces — users with quoting nuances should use the string form)dev.workdir(container-side absolute path; default/workspace)dev.port(integer 1..65535; informational only —forward_ports:is the real port-forward config) Validated + surfaced in the normalized JSON output ofprofile_load.boring openforeground dev-command UX (ARD-0030). After the container is up + setup is complete + (when--ui) the boring-ui stack is started, boring runs the profile'sdev.commandin the FOREGROUND viadevcontainer exec ... -- bash -c "cd <dev.workdir> && exec <dev.command>". The user's terminal is now the dev server's terminal — they see output, auth prompts, and errors directly.- On clean exit (code 0) or Ctrl-C (code 130): teardown via the EXIT trap.
- On nonzero exit: print an actionable hint (suggests
boring open --no-dev <path>for in-place debug) then drop into an interactive bash shell so the user can fix the issue without losing the container. - When
dev:is not set or--no-devwas passed: drop into the existing interactive bash shell (back-compat with pre-v0.9.0boring open). --no-devflag onboring open. Skipdev.commandeven if the profile sets it; drop into bash shell instead. Documented inboring help+ the top-of-file usage block. Use when debugging the container or the dev process itself.
Changed¶
- Trap chain hardened (
boring). Teardown logic forcmd_open(audit collector stop + UI stack stop) is now centralized in a single EXIT trap (_cmd_open_teardown_all). INT/TERM traps justexit 130; EXIT does the work —devcontainer execfrequently eats SIGINT, so the EXIT path is the only reliable safety net. UI teardown is gated on a newBORING_OPEN_UI_STARTEDflag set by_cmd_open_maybe_start_uion success, so the EXIT trap is a no-op for runs that never enabled--ui. Idempotent inner calls mean a redundant INT-then-EXIT cascade is harmless.
Known limitations (transparency)¶
- Foreground design is engineer-in-terminal-shaped. It does NOT compose with the future ARD-0021 §9 marketer-via-launchd flow (the proxy autostart wants
dev:running in the background, separately managed) — revisit for v1.x. Marketers should keep usingboring open --uifor now withoutdev:declared, or wrap the UI launch separately. - Single dev command only. Multi-process projects (concurrent backend + frontend + watcher) should compose them with a wrapper like
concurrentlyornpm-run-allin thedev.commandstring. A futuredev: { services: [...] }multi-process shape can come later if users ask. - First-run OAuth is still manual copy-paste. When the dev command needs an OAuth token on first run (Shopify, etc.), the user copies/pastes the URL from the foreground output. Same UX as running the command outside boring.
- Readiness polling deferred to v0.9.1. There's no automatic "wait for dev server to bind port X" — the user knows it's up when log lines start flowing. A future minor will add
dev.ready:(port poll or HTTP probe) so--uican wait before opening the browser.
Files touched¶
boring—--no-devparse, new_cmd_open_teardown_all+_cmd_open_maybe_run_dev_or_shellhelpers, trap chain centralization,BORING_OPEN_UI_STARTEDflag, help/usage updates, VERSION → 0.9.0.lib/profile.sh—dev:schema validation (dev.commandrequired + string-or-list shape;dev.workdirabsolute-path;dev.portint range) + normalization (.dev.commandis always a string downstream;.dev.workdirdefaults to/workspace;.devis null when block absent).tests/fixtures/profile-with-dev-block.yaml(new) — exercises every field.tests/smoke-dev-foreground.sh(new) — 27 assertions across 10 test groups: schema (string/list/defaults/all 4 rejection paths/back-compat),--no-devflag surfacing, runner argv via PATH-shimmeddevcontainerstub,--no-devshort-circuit, failure-hint + bash-drop fallback. No livedevcontainer/docker/claudeinvocation.CHANGELOG.md— this entry.
[0.8.1] — 2026-05-26¶
Fixed¶
- In-container
claudefailed to start under--uiwithInvalid MCP configuration: mcpServers: Invalid input: expected record, received undefined. v0.8.0'sweb_ui_ensure_container_claudewroteprintf "{}" > /etc/boring/empty-mcp.json— but claude's MCP validator rejects bare{}(already verified empirically when boring-ui-backend'semptyMCPConfigFile()was written; the only accepted shape is the literal{"mcpServers":{}}). Fix: write the exact accepted shape; also remove theif [ ! -f ]guard so v0.8.0-installed bad files get corrected on next--uirun; also use temp+rename so chmod 0444 from a previous run doesn't block the rewrite (same pattern as v0.7.2 egress fix).
Immediate unblock for users on v0.8.0 mid-session: docker exec -u root <profile>-dev-1 bash -c 'echo "{\"mcpServers\":{}}" > /etc/boring/empty-mcp.json' then hit Enter in the ttyd pane to reconnect.
- Preview iframe showed "No preview configured" for shopify / django-node / node / node-postgres profiles without an explicit
preview_url:. v0.8.0's preview-URL resolution stopped at.ui.preview_url // .preview_url // ""— never consulted the ARD-0022 §6.2 per-preset defaults table. Fix: newweb_ui_preset_preview_default()inlib/web_ui.shcodifies the table (shopify→9292, django-node→5173, node→3000, node-postgres→3000, python→empty);cmd_open --uifalls back to it when both profile fields are empty. python preset still requires explicitpreview_url:since there's no canonical dev-server port.
[0.8.0] — 2026-05-26¶
Added¶
boring open --ui+--no-uiflags. Single-command path to bring the dev container up AND wire the boring-ui web stack (singleton host proxy on:8090, per-project ttyd servingdocker exec -it <c> claudewith the ARD-0029 guardrail flags, per-project boring-ui-backend on a Unix socket, registry upsert) in one shot. After the container is up +setup-completeis marked, boring builds the Go binaries (one-time, ~10s each), spawns the proxy if it's not running, brings up ttyd + backend for the slug, registers the project, prints[OK] Web UI: http://127.0.0.1:8090/<slug>/, and opens the browser. Falls through to the existing shell-drop / Ctrl-C-tears-down loop unchanged — engineer gets BOTH the shell AND the browser.--no-uiforce-disables even if the profile opted in (useful for CI / SSH; SSH sessions also auto-skip the browser open).- Profile
ui:block (lib/profile.sh). New optional top-level map:ui.enabled(bool, default false; the opt-in trigger when neither--uinor--no-uiis passed) andui.preview_url(string; absolute URL the right-pane iframe loads, wins over the top-levelpreview_urlfor UI consumers). Validated + surfaced in the normalized JSON output ofprofile_load. lib/web_ui.sh— new module (~390 LOC, bash 3.2-compat). Public functions:web_ui_required_binaries_present,web_ui_build_binaries,web_ui_socket_path,web_ui_ttyd_port,web_ui_proxy_pid_file,web_ui_proxy_port,web_ui_proxy_running,web_ui_proxy_start,web_ui_registry_upsert,web_ui_registry_remove,web_ui_ttyd_start,web_ui_backend_start,web_ui_stop,web_ui_url,web_ui_open_browser,web_ui_ensure_container_claude,web_ui_status. Deterministic per-slug ttyd port viaprintf '%s' "$slug" | cksum(7681..8679 range; same slug always lands on the same port so reruns reconnect cleanly). Socket path under$XDG_RUNTIME_DIR/boring/(Linux) or$TMPDIR/boring/(macOS); matches boring-proxysocketAllowedPrefixes.boring ui {status|stop|open} [<slug>]subcommand.statusprints proxy state + per-slug ttyd/backend liveness;stop <slug>SIGTERMs the per-project ttyd + backend (proxy stays — other slugs may use it);open <slug>(re-)opens the browser to the slug's URL.- Auto-build of
tools/boring-{proxy,ui-backend}/on first--uiuse viamake buildin each tool dir. Requiresgoon PATH (withttyd,docker— pre-flighted byweb_ui_required_binaries_presentwith actionable install hints). tests/smoke-web-ui.sh— 27 assertions across 7 sections: missing-binary detection (PATH=stub trick), socket-path determinism, ttyd-port determinism + range, URL shape, registry upsert preserves other entries + is idempotent + updates fields on re-upsert, registry remove preserves siblings + no-ops on missing slug,web_ui_ttyd_startproduces the exact ARD-0029 §3 argv (verified via shell-function stub that records argv). No liveclaude/ proxy / backend / ttyd spawn — every binary is mocked. Full smoke suite: 8/8 pass (was 7).
Known limitations (transparency)¶
- No automatic cleanup when the container stops. Stopping the container (Ctrl-C
boring open) leaves the per-slug ttyd + backend running because they're host-side processes. Useboring ui stop <slug>to tear them down explicitly. A future minor will wire this into the existing INT/TERM trap chain. - In-container
claudeOAuth is still a one-time manual step. First time you use--uiagainst a fresh container, you'll need to click through the OAuth flow in the ttyd terminal pane. Subsequent sessions use the cached credential. - Proxy runs in
--insecure --no-authdev mode.boring open --uistarts the proxy without TLS or token auth so the marketer flow works without theboring proxy installceremony. TLS + per-user token + autostart (ARD-0021 §5-§8) still requireboring proxy installexplicitly; the dev-mode proxy is bound to127.0.0.1:8090only, so it's not exposed beyond loopback. - Custom Dockerfile presets must install
claudethemselves. For boring's bundled presets (shopify, django-node, python, node, node-postgres) claude is image-baked. For customstack.dockerfile:profiles (e.g. immich),web_ui_ensure_container_claudewill fail with an actionable hint:docker exec -u root <container> npm install -g @anthropic-ai/claude-code.
[0.7.4] — 2026-05-26¶
Added¶
boring upgradesubcommand. Pulls the latest boring fromorigin/mainat the install root (SCRIPT_DIRper the existingreadlink -fresolution). Refuses with a clear error if uncommitted local changes are present (unless--force). Supports--tag <version>to pin to a specific tag (e.g.boring upgrade --tag v0.7.3to roll back). Print before/after VERSION + SHA + links to changelog and releases page.
Closes the obvious gap that surfaced during v0.7.0–0.7.3's bugfix cascade: the only upgrade path was cd ~/.local/opt/boring && git pull or re-running the curl installer.
Refuses to run if $SCRIPT_DIR/.git isn't a directory — i.e. if boring wasn't installed via the curl installer's git-clone path. Prints the install-script command in that case.
[0.7.3] — 2026-05-26¶
Fixed¶
corepack enableno longer fails inpostCreateCommandforshopify+django-nodepresets. Both presets install Node 20 via NodeSource, which does NOT enable corepack by default (unlike the officialnodeDocker image). Profiles that includedcorepack enableinsetup:then hitEACCES: permission denied, symlink ... -> /usr/bin/pnpmbecause thedevuser can't write to/usr/bin/. Fix: addcorepack enableat image-build time (as root) right after thenpm install -gline in both Dockerfiles. Profiles can keepcorepack enableinsetup:for idempotence or drop it;pnpm installworks either way. Thepythonpreset is intentionally unchanged — it ships without runtime npm by design (usenode/node-postgres/django-nodefor Node-needing projects).
For users on v0.7.0-0.7.2 with a built shopify/django-node container: the new Dockerfile only takes effect on container rebuild. Either docker exec -u root <profile>-dev-1 corepack enable to patch the running container in place (lasts until next recreate), OR cd <repo>/.devcontainer && docker compose down && docker image rm <profile>-dev then boring open . to rebuild from the new Dockerfile.
[0.7.2] — 2026-05-26¶
Fixed¶
egress_write_allowlist_filere-runs now succeed. v0.7.1 and earlier wrote.devcontainer/boring-runtime/egress.allowthenchmod 0444'd it (so an in-container agent can't overwrite via the bind mount). Subsequentboring openinvocations on the same repo failed withEACCESatlib/egress.sh:31because the redirect>couldn't open a 0444 file for writing. Fix: write to.tmp,chmod 0444 .tmp, thenmv -f(atomic rename works against the parent dir's perms, bypasses the destination's read-only mode). Matches theatomicWriteFilepattern intools/boring-proxy/atomic.go.
Immediate workaround for users on v0.7.0/v0.7.1: chmod +w <repo>/.devcontainer/boring-runtime/egress.allow once; v0.7.2+ doesn't need it.
[0.7.1] — 2026-05-26¶
Fixed¶
install.shno longer collides withBORING_DATA_DIR. The v0.7.0 installer defaulted to cloning into$HOME/.local/share/boring, which is also boring's own runtime state directory (registry.json,audit/,proxy/, etc. per ARD-0001). Users who'd ever runboring proxy installor seen any boring runtime file got a hard error:~/.local/share/boring exists but is not a git checkout. Resolved by moving the default install root to$HOME/.local/opt/boring, with explicit back-compat detection for users who already installed at the legacy path before this fix. Resolution order:$BORING_INSTALL_ROOTenv (always honored) → legacy~/.local/share/boring/.gitif it's asteig/boringcheckout → new default~/.local/opt/boring.
[0.7.0] — 2026-05-26¶
This release bundles all the previously-unreleased work from v0.3 → v0.6 plus the v0.7 slice (harness-agnostic prereqs + save/wip CLI + first major real-stack example).
Added — v0.7 harness-agnostic prereqs + save/wip CLI + immich example (2026-05-25/26)¶
boring save <profile|.>— promote a WIP branch to a draft PR per the profile'ssave:configuration (ARD-0022 §7). Readssave.target_branch,save.reviewers_from/save.reviewers,save.draft_by_default,save.branch_prefix,save.pr_template. Branches the current WIP head into<branch_prefix><AI-slug>-<date>-<sha>, pushes, opens a PR viagh pr create. Leaves WIP intact on any failure with an actionable error.boring wip {start|commit|discard} <profile|.>— WIP-branch lifecycle for marketer sessions (ARD-0022 §3).startcreatesboring/wip/<marketer>/<ts>;commit --prompt <text>stages all + commits with an AI-summarized message viaclaude --print;discarddeletes (refuses unsaved commits unless--force).lib/saver.sh— the underlying module (~330 LOC, bash 3.2-compat). Public functions:saver_wip_branch_name,saver_create_wip_branch,saver_commit_turn,saver_summarize_turn,saver_summarize_pr,saver_save,saver_discard_wip.lib/guardrails.sh— new module (~160 LOC) for harness-agnostic codegen per ARD-0026 + ARD-0028. Per-harness translation tables (_guardrails_claude_tool/_guardrails_opencode_tool) map canonical tool names (edit,run,read,web_fetch,web_search) to per-harness native names. New codegen artifacts emitted to.boring/codegen/:CLAUDE.md,AGENTS.md(sibling per ARD-0028),opencode-permissions.json(per ARD-0026 §4).guardrails_resolve_pathscomputes(preset default + profile.allowed_paths) − profile.disallowed_paths.cmd_opennow callsguardrails_emit_codegen_dirafter the existing ARD-0009 runtime emit.- Profile schema additions (
lib/profile.sh) — all optional, sensible defaults: allowed_paths:/disallowed_paths:— glob lists; resolved at codegen timesave:block:target_branch,reviewers_from/reviewers,draft_by_default,branch_prefix,pr_templatepreview_url:(string) /preview_urls:(list of{name, url})wip_branch_ttl:/wip_branch_grace:— duration strings (e.g.7d,24h)allowed_claude_tools:→allowed_tools:rename (back-compat alias) — both keys parse;allowed_claude_tools:warns + rewrites toallowed_tools:in-memory; hard error if both keys set in the same profile (security-relevant disagreement).templates/_shared/agent/workflow.md— universal CLAUDE.md/AGENTS.md template with substitution tokens ({{TOOL_EDIT}},{{TOOL_RUN}},{{TOOL_READ}},{{HARNESS_FILENAME}},{{PROFILE_SNIPPET}}).- Per-preset path-allowlist defaults at
templates/{shopify,django-node,python,node,node-postgres}/allowed-paths.yamlper ARD-0022 §5.2 verbatim. examples/immich/— first real-world stack example, separate from the curated presets. Customstack.dockerfile:FROMghcr.io/immich-app/base-server-dev; three sidecars (custom postgres with VectorChord+pgvecto.rs, Valkey, immich-machine-learning); forward_ports[2283, 3000, 9230, 9231]; ten env vars matching upstream immich's docker/example.env shape. Bring-up confirmed end-to-end through boring: 4 services up, immich API responding on:2283(v3.0.0), web frontend on:3000.- AGENTS.md mount entry in
lib/compose.sh— binds<repo>/.boring/codegen/AGENTS.mdto/home/dev/.config/opencode/AGENTS.md:roper ARD-0028 §3. - Test fixtures + smoke tests:
tests/fixtures/profile-with-boring-ui-fields.yamlexercises every new fieldtests/fixtures/profile-with-deprecated-allowed-claude-tools.yamlexercises the back-compat pathtests/smoke-boring-ui-schema.sh— 27-assertion schema smoketests/smoke-saver.sh— 24-assertion save-flow smoke- Full suite: 7 smoke tests pass under macOS
/bin/bash 3.2.
Added — v0.7 ARDs¶
- ARD-0016 — repo-side safety nets (branch protection + per-preset PR templates) as a boring prerequisite; extends ARD-0005 past the container boundary
- ARD-0017 — agent workflow rules: preset-baked CLAUDE.md + per-profile snippet derived from
guardrails:at codegen - ARD-0018 — VS Code extensions are profile-declared trust-anchor content
- ARD-0019 — boring-ui umbrella (browser surface for non-engineers, post-v1.0)
- ARD-0020 — OpenCode as the agent harness; subscription verification is the precondition gate
- ARD-0021 — host-side reverse proxy + project picker at
https://boring.local/ - ARD-0022 — session + trust model (single chat per project, auto-branch, save flow)
- ARD-0026 — harness-agnostic guardrails + path allowlist (amends ARD-0009)
- ARD-0027 — OpenCode emit path into the same audit FIFO (amends ARD-0010)
- ARD-0028 — AGENTS.md codegen alongside CLAUDE.md (amends ARD-0017)
- ARD-0029 — v0 deviation:
claude --printshell-out as boring-ui backend because user's opencode lacked configured Claude Code subscription provider; time-bound, swap back to ARD-0020 path when subscription support matures
Fixed — examples/immich¶
setup:mkdir uses portable POSIX for-loop instead of bash brace expansion (postCreateCommand runs via/bin/sh, which created a literal directory named{encoded-video,thumbs,...})IMMICH_HOST: "0.0.0.0"env — without it Node 22+ binds the API only to IPv6::1, and Vite's IPv4-only HTTP proxy gets ECONNREFUSED → web UI shows 502IMMICH_SERVER_URL: "http://localhost:2283/"env — vite.config.ts default proxy target ishttp://immich-server:2283/(a compose service name that exists in upstream's split-container layout but not here)- upload subdir markers (
encoded-video,thumbs,backups,library,profile,upload) created with.immichfiles per immich's StorageService system-integrity check
v1.x preview (not installed by this release)¶
Substantial boring-ui v0 prototype code lives in tools/boring-{proxy,ui-backend}/ after this release. It is not packaged or installed by install.sh and should not be considered part of the supported v0.7 surface. See ARD-0019 for the v1.x plan and ARD-0029 for the v0 deviation that's currently in tree.
tools/boring-proxy/(~2800 LOC Go) — host-side reverse proxy + project picker per ARD-0021tools/boring-ui-backend/(~3700 LOC Go + HTML/CSS/JS) — in-container chat backend per ARD-0022 with mock + real claude providers, embedded terminal pane (ttyd), path-allowlist enforcement (reactive git revert), per-turn cost trackingscripts/verify-opencode-subscription.sh+docs/verify-opencode-subscription.md— ARD-0020 §3 verification protocol Tom can run when ready
Added — v0.6 headless boring run (2026-05-24, ARD-0013)¶
boring run "<prompt>" --profile <name>— one-shot headless Claude invocation in a profile-scoped sandbox. Fresh container per invocation (compose project name = random suffix, torn down withdocker compose down -von exit). Claude prompt is the only input shape — for shell commands usedevcontainer execdirectly. Same secret-resolution code path asboring open; CI environment is responsible for non-interactive auth (e.g.op signin --service-account-token).- SIGINT trap catches Ctrl-C mid-run and tears down cleanly (one teardown only — trap resets on first fire).
tests/smoke_run.sh— 18 assertions covering happy path, secret pre-flight failure, SIGINT teardown,--profilevalidation, no-secrets profile,--help.
Added — v0.5 dbx restore integration, boring side (2026-05-24, ARD-0012)¶
restore:profile schema — structured list of{source, target, transform?, when?}entries.targetis cross-referenced againstservices:(fails validation if it names a non-existent sidecar).transformis REQUIRED whendata_sensitivity: sanitizedper ARD-0012's safety interlock (the field that's been parsed-but-no-op since v0.2 now becomes load-bearing).whenis one offirst_up | every_up | manual; defaults tofirst_up.- Profile-level:
restore:is rejected whendata_sensitivity: internal(the meaning of "internal" is "no real data ever in this container"). _cmd_open_run_restoresfires betweendevcontainer upandsetup:so migrations/seeds run against prod-shaped data, not against an empty schema. Walks the restore list, invokesdbx restore <source> [--transform=<path>] --into <container>(container name resolved as<profile>-<target>-1via the now-pinned compose project name).boring restore [<path>] [--refresh]subcommand — manual surface over the same pipeline. Idempotent by default (re-runs only entries missing their marker);--refreshclears markers and promotesmanual:entries toevery_upso they fire on demand.- Compose project name pinned to the profile name (
compose_generate ... --project-name "$name"incmd_open) so sidecar containers get predictable names rather than the unpredictabledevcontainer-<service>-1default. boring doctorpre-flightsdbx restore --helpfor--transformand--into; warns explicitly when missing rather than failing mid-boring open. Requires dbx ≥ commitd1f585d(PR #42 on dbx).- Marker files at
~/.local/share/boring/restore-state/<profile>/<idx>-<target>.complete.
Added — v0.4 egress enforcement + cross-platform --learn-mode (2026-05-23/24, ARD-0011 + ARD-0015)¶
- iptables-in-container egress enforcement with
CAP_NET_ADMIN(not--privileged).install-egressruns as root at container boot, installs OUTPUT rules from the bind-mounted allowlist file, then drops to thedevuser viagosubefore execing user code.enforcemode default;BORING_EGRESS_MODE=learnswapsREJECTforNFLOG. boring open --learn-moderecords every outbound connection attempt and prints a proposedegress.allow:diff on Ctrl-C — the authoring path that makes the allowlist tractable.ulogd2sidecar (ARD-0015) replaces the original dmesg-based learn-mode reader. Newtemplates/_common/egress-logger/ships a Debian-slim sidecar with ulogd2 + JSON output plugin; shares the dev container's netns vianetwork_mode: "service:dev"; reads NFLOG packets and writes JSON to a host-bind-mounted shared volume that boring'segress_propose_allowlist_diffparses. Works on Mac+Orbstack, which the dmesg path could not — the dogfood team's daily platform.lib/egress.shcompleted (was a stub since v0.1). New host-side functions:egress_enabled,egress_write_allowlist_file,egress_propose_allowlist_diff.- Egress allowlist file lives at
<repo>/.devcontainer/boring-runtime/egress.allow; host writes, container reads RO. - All five presets ship with
iptables,iproute2,gosu,dnsutilsand theinstall-egressentrypoint chain (tini -- install-egress).
Added — v0.3 trust + observability layer (2026-05-23/24, ARD-0009 + ARD-0010)¶
- Guardrails codegen (ARD-0009). Three artifacts generated host-side at
boring opentime and bind-mounted RO into the container at/workspace/.devcontainer/boring-runtime/: pre-pushhook fromguardrails.forbid_branches:— refuses pushes whose target ref matches a forbidden branch. Repointedcore.hooksPathfrom/etc/boring/git-hooks/to the bind-mount so the runtime version wins.bin/<cmd>wrappers fromguardrails.forbid_commands:— earlier on PATH than the real binary; prefix-matches argv against the forbidden patterns; passes through to the real binary on no-match.claude/settings.jsonfromguardrails.allowed_claude_tools:—jqdeep-merge of the image-baked baseline (ARD-0006 deny rules + ARD-0010 audit hooks) with the per-profilepermissions.allowlist. In-container~/.claude/settings.jsonsymlinks to the merged file.- Audit log + prompt tracing (ARD-0010). FIFO + host-side collector for tamper-resistant emit:
- Per-profile FIFO at
~/.local/share/boring/audit/<profile>/events.fifo, bind-mounted into the container at/var/log/boring/events.fifo. - Collector spawned by
cmd_open; reads events and routes to per-tier JSONL files. Lifecycle traps (INT/TERM/EXIT) ensure no orphaned collectors. - Tiered visibility (ARD-0010 §C22). Security events (
guardrail_violation,egress_block,restore,command_wrapper_fired) →_shared/<profile>/security.jsonl(profile-wide). Prompt events (prompt_issued,tool_used,prompt_completed) → per-user<USER>/<profile>/prompts.jsonlby default; opt-inaudit.prompts: sharedroutes to the shared file. - Claude Code native hooks (
UserPromptSubmit,PostToolUse,Stop) wired in the image-bakedsettings.jsonto invokeaudit-emit-<kind>shims; the shims write JSON envelopes through the FIFO. boring audit security <profile>/boring audit prompts <profile>subcommands.- Trust-anchor extended (ARD-0006 + derived requirements): Claude
denyrules now cover/workspace/.devcontainer/boring-runtime/**and/home/dev/.claude/settings.jsonso an in-container agent can't disable its own observability. audit-emitshim moved from image-baked → host-emitted RO bind-mount (fix indcce24f). The original v0.3-dev shipped the script at/usr/local/boring/bin/audit-emitin the container's writable layer, wheresudo rmcould disable it. Moved to/workspace/.devcontainer/boring-runtime/bin/audit-emit{,-<kind>}so the docker daemon (not file perms) enforces immutability — same trust-anchor pattern as ARD-0006/0009.- Five v1.0 presets aligned with full v0.3 wiring (
f4045a1):python,node,node-postgreswere authored before ARD-0009/0010/0011 and got none of the trust+audit+egress hooks; backported. All five now share the same: iptables/gosu/dnsutils apt installs,/var/log/boringFIFO mount target,core.hooksPathrepoint, PATH prepend forboring-runtime/bin, settings.json symlink swap,install-egressentrypoint chain, removedUSER dev(install-egress drops via gosu).
Added — infrastructure (2026-05-23/24)¶
scripts/deploy-site.sh— pushdocs/index.htmlto MinIO ats3.steig.io/public/boring/. Idempotent; verifies live response after upload.scripts/test.sh— unified smoke test runner. Discoverssmoke*.sh/test*.shunderscripts/(excluding non-test scripts likedeploy-site.sh) and any*.shundertests/. Per-test PASS/FAIL/SKIP (exit 77 = skip per autoconf convention).-vfor inline output; positional arg filters by path substring. 5/5 smoke tests pass locally..github/workflows/test.yml— CI runs on push + PR to main.syntaxjob runsbash -non every shell file + advisoryshellcheck.smokejob installsjq+ mikefarah/yq +@devcontainers/cli, runsboring doctor(warns on missing optional deps likedbx), thenscripts/test.sh -v.
Added — ARDs landed in this session¶
- ARD-0007 — django-node preset, multi-service compose, schema versioning (covered in v0.2 entry below)
- ARD-0008 — v0.3→v1.0 release plan + thesis evolution (code as thinking medium for mixed teams)
- ARD-0009 — guardrails codegen architecture
- ARD-0010 — audit log + prompt tracing (FIFO + host collector, Claude native hooks, tiered visibility)
- ARD-0011 — iptables egress +
--learn-mode - ARD-0012 — dbx restore via the
restore:profile field - ARD-0013 — headless
boring run - ARD-0014 — preset versioning + canonical v1.0 preset list
- ARD-0015 — ulogd2 sidecar (amends ARD-0011's dmesg log source)
Changed¶
- Schema versioning + soft deprecations (ARD-0007 mechanism).
theme:→preset:rename ships as a soft deprecation: both keys parse,theme:warns and rewrites in-memory, v2 will hard-remove. Same mechanism handles every future rename. - All five presets versioned via build ARGs (ARD-0014). Profile
preset_version: { python: "3.12", node: "22" }translates to--build-arg PYTHON_VERSION=3.12 ...; defaults baked into each Dockerfile. - Compose project name pinned to the profile name in
cmd_open(was previously the unpredictabledevcontainerdefault from the.devcontainer/directory). Sidecar containers now get predictable names like<profile>-<service>-1. - Marketing site rewritten for the thesis pivot (
docs/index.html, commit8bb7ce7). Was "AI safely working on prod-shape data"; now "code as a thinking medium for mixed teams (engineers + marketers + managers)." Phased capabilities grid (today / v0.3 / v0.4 / v0.5 / v0.6 / v1.0) replaces the binary today/roadmap split.
Fixed¶
audit-emitscript location (dcce24f). Originally shipped in the container's writable layer wheresudo rmcould disable audit; moved to the same host-writes-container-reads-RO bind-mount the rest of the trust anchor uses.- bash 3.2 compat in
boring(fb0a2c4+ earlier audit fixes). Removed namerefs (used a documented global instead), used the${arr[@]+"${arr[@]}"}empty-array splat idiom, replaced((c++))withc=$((c + 1))(the++form returns nonzero onc=0whichset -etreats as failure). local badshadowing in_profile_validate_json—badwas used withoutlocalbefore being redeclared later. Fixed.cd frontend && npm installin setup chains — the cwd persisted across the joined shell expression, breaking later commands. Subshelled(cd frontend && npm install)in the dogfood profile; documented as asetup:ergonomics note.- Stale dmesg-based egress smoke removed (
4f09100); replaced byscripts/smoke-ard-0015.shexercising the ulogd2 path.
[0.2.0-dev] — 2026-05-23¶
Added (django-node + multi-service compose — 2026-05-23, v0.2 slice)¶
- ARD-0007 —
preset: django-node, multi-service compose, schema versioning, lifecycle hooks, secret resolution at container start. Amends ARD-0004's implementation order step #8. - Profile schema versioning. New top-level
profile_version: "1"field. Missing → warns; unknown → hard error with upgrade hint. Major-only versioning (no semver). Deprecation table lives inlib/profile.sh(_BORING_PROFILE_DEPRECATIONS_V1). theme:→preset:rename (soft deprecation).lib/profile.shaccepts both for v1 schema; warns ontheme:and rewrites in-memory topreset:. v2 will removetheme:. shop-theme's existingtheme: shopifyprofile continues to work with a warning until migrated.services:structured schema. Sidecars declared as{name, image, env, volumes, healthcheck, depends_on}objects. Top-levelvolumes:list for named-volume declarations.lib/compose.shemits multi-service compose with auto-wireddepends_onon thedevservice (condition: service_healthywhen sidecar declares a healthcheck, elseservice_started).setup:lifecycle hook. List of shell commands.lib/compose.shemits them aspostCreateCommandindevcontainer.json(devcontainer-native, fires once on container creation, works with VS Code "Reopen in Container").cmd_openalso writes a/var/lib/boring/setup-completemarker as the last setup step and re-verifies post-up, re-running setup if the marker is missing (belt-and-suspenders against partial-failure modes likebootstrap_dataracing Postgres readiness).- Secret URI resolution at container start.
cmd_openwalks normalized env entries, callssecret_resolvefromlib/secrets.shfor eachsecret://...URI, and passes the resolved pairs todevcontainer up --remote-env KEY=VALUE. Resolved values never touch disk (not in compose, not in devcontainer.json). Failure to resolve any required secret aborts the open with a clear error naming the URI. Was deferred per ARD-0002's impl order; content-infrastructure forced it (cannot shipOPENROUTER_API_KEYas a literal in a checked-in profile). templates/django-node/—preset: django-nodeDockerfile + supporting files. Basepython:3.14-slim-bookworm; installs uv (pinned ARG), Node 20 (NodeSource), libpq5, postgresql-client (psql + pg_isready), git, gh, sudo, tini, Claude Code. Non-rootdevuser (uid 1000) with NOPASSWD sudo./workspace,/home/dev/.config,/var/lib/boringpre-created withdev:devownership. xdg-open shim verbatim from shopify preset; ARD-0006 trust-anchor enforcement verbatim. Claude defaults via the sharedcommonbuild context (templates/_common/claude/).preset: django-nodedefaults seeding. When a profile declarespreset: django-nodewithout authoring sidecars/volumes/forward_ports/DATABASE_URL, the normalizer seeds: postgres:17 sidecar (POSTGRES_DB=content_infra,POSTGRES_PASSWORD=postgres, named volumepostgres-data,pg_isreadyhealthcheck), top-levelvolumes: [postgres-data],forward_ports: [8000, 5173],DATABASE_URLpointing at the sidecar. User-authored values win on conflict (per-key merge forenv, whole-array replacement forservices/volumes/forward_ports).- Second dogfood profile:
~/code/work/content-infrastructure/.boring/profile.yaml. Django + Django Ninja + React/Vite + Postgres 17. Demonstratespreset: django-node,setup:hook (uv sync + migrate + npm install + bootstrap_data),op://secret URIs for OPENROUTER_API_KEY / WINDMILL_TOKEN / WINDMILL_CALLBACK_API_KEY / DJANGO_SECRET_KEY, andguardrails.forbid_branches: [main].
Added (Shopify-first v1 slice — 2026-05-23)¶
- ARD-0004 locks Shopify-first as the v1 dogfood path; defers dbx integration + sidecars to v1.x. Adds
mounts:,forward_ports:,theme:profile schema fields. - ARD-0005 records the security-model inversion (v1 contains the non-engineer + AI from prod systems; egress allowlist deferred to v1.x). Adds
guardrails:profile schema field. lib/profile.sh— full implementation (replaces the STUB). yq + jq powered. Parses.boring/profile.yaml, merges.boring/profile.overlay.yamlif present (overlay wins), validates schema (name, theme, stack, services, mounts, forward_ports, env, egress, data_sensitivity, guardrails, claude), and emits a normalized JSON blob downstream modules consume. Tilde-expandsmountshost paths; classifies env values as{kind: literal}vs.{kind: secret, uri: ...}(using thesecret://...convention per the v1 yq-tag pragma).lib/compose.sh— full implementation (replaces the STUB). Emits.devcontainer/docker-compose.yml(singledevservice for the v1 minimal case) and.devcontainer/devcontainer.json(dockerComposeFile + service: dev) from the normalized profile JSON. Honors theme presets, source bind-mount, profile mounts, port-forwards, literal env vars. Secret URI resolution deferred tocmd_open.boring open <path>— functional. Loads profile, generates.devcontainer/, callsdevcontainer up. URL cloning, secret resolution, egress enforcement, guardrails codegen all deferred.templates/shopify/—theme: shopifypreset Dockerfile + supporting files. Baseruby:3.3-slim-bookworm(matches a typical Shopify theme dev shell — same toolchainflake.nix-using projects pin); installs Node 20, Shopify CLI, gh, git, tini, Claude Code. Non-rootdevuser (uid 1000),/workspaceworking dir, port 9292 exposed. Builds in ~34s to 1.45GB.
Fixed (Shopify-first v1 dogfood smoke test surfaced these)¶
- Compose source bind-mount was rooted at
.devcontainer/, not the repo root. Generator was emitting.:/workspace:cached; relative paths in compose resolve to the compose file's directory, so the container only saw the generateddevcontainer.jsonanddocker-compose.yml. Fixed by emitting..:/workspace:cached. (880c9b8) /home/dev/.configwas created as root when boring's bind-mount for~/.config/shopifytriggered Docker to materialize the parent. That blocked sibling CLIs likeshopify-cli-kit-nodejsfrom writing their own config;shopify auth loginfailed withEACCES. Fixed by pre-creating/home/dev/.configwithdev:devownership in the Dockerfile. (7edcdb9)- CLIs that auto-open browsers crashed with
spawn xdg-open ENOENTin the headless container, abandoning their polling loops (so even manual browser auth couldn't complete). Fixed by dropping a tinyxdg-openshim into/usr/local/binthat prints the URL to stderr and exits 0. (165ccd9) - Profile-side env-var naming collided with project npm scripts. Set
SHOPIFY_FLAG_STORE(Shopify CLI's native any-flag env convention), but the project'snpm run devscript read$SHOPIFY_STORE(matching its.env.exampleconvention). Fixed in the project profile by setting both names; the lesson —theme:presets should set both the CLI-native env var and the project-convention env var documented in the project's.env.example— applies broadly.
Validated end-to-end on macOS against a production Shopify theme¶
- Container builds in ~34s (1.45GB image), pulls Ruby 3.3.11, Node 20.20.2, Shopify CLI 3.94.3, gh, Claude Code 2.1.150.
/workspacecorrectly mounts the repo root; git operations inside the container match host state.- Port 9292 forwards host↔container (
shopify theme devhot-reload). - Shopify auth via device-code flow completes successfully and persists across container rebuilds via the RW bind-mount of
~/.config/shopify/. npm run devserves the dev store with hot-reload visible athttp://localhost:9292.- VS Code's Dev Containers extension attaches cleanly to the boring-generated
devcontainer.json.
Added (later in the same day — agent guardrails + bundled Claude defaults)¶
- ARD-0006 — the profile is the trust anchor. In-container AI agents must NOT modify
.boring/*. Universal rule, not per-profile opt-in. Enforced by Claude Code permissiondeny+ system-wide gitpre-commithook installed viacore.hooksPathin/etc/boring/git-hooks/(image-baked, never pollutes the host repo's.git/hooks/). - Bundled Claude defaults in
templates/shopify/claude/, COPYd into/home/dev/.claude/at image build: CLAUDE.md— Karpathy behavioral guidelines (Think Before Coding, Simplicity First, Surgical Changes, Goal-Driven Execution) + a boring-local footer naming the trust-anchor rule and pointing at any host-repoCLAUDE.local.mdfor project-specific rules.settings.json— the trust-anchordenyrules (moved out of inlineprintfin the Dockerfile into a real JSON file for readability).skills/grill-me/SKILL.md—/grill-meavailable to the user inside the container.
Added (v0.6 headless boring run slice — 2026-05-24)¶
- ARD-0013 — headless
boring runfor one-shot Claude invocations in a profile-scoped sandbox. Fresh container per invocation, identical secret code path toboring open, same trust-anchor and guardrails posture. boring run "<prompt>" --profile <name> [--repo <path>]— replaces the v0.1 stub. Pre-flights allsecret://URIs in memory (no disk write) and fails fast on resolution errors before any container starts. Generates a unique compose project name (boring-run-<profile>-<8-hex-suffix>) so a one-shot run can't collide with an interactiveboring openof the same profile. Brings up viadevcontainer up --remove-existing-containerwith resolved secrets injected as--remote-env KEY=VAL(devcontainer-CLI surface; never written to docker-compose.yml). Invokesclaude -p "<prompt>"inside the container; streams stdout to the host; exits with Claude's exit code. SIGINT / SIGTERM / normal-exit teardown all converge ondocker compose --project-name … down -v --remove-orphans(the-vremoves the run's named volumes, which is the reproducibility property).lib/compose.sh—compose_generatenow accepts an optional--project-name <name>flag that writes a top-levelname:field into the generateddocker-compose.yml. Used byboring runonly;boring opencontinues to omit it.tests/smoke_run.sh— orchestration smoke forcmd_run. Uses on-PATH mocks forop,claude,devcontainer, anddocker(each logs invocation to a JSON-Lines file the assertions check) so the smoke runs without docker / @devcontainers/cli installed and without paying the cost of an actual Claude invocation. Covers: happy path (secret resolution → up → claude exec → teardown), secret pre-flight failure (no container starts), SIGINT mid-run (teardown still fires),--profilemismatch rejection, non-slug--profilerejection, no-secrets profile (empty--remote-envarg list), and--help.
Known UX gaps (filed for next slices)¶
boring opendoes not auto-recreate the container when the compose file changes. Workaround:docker compose --project-name <name> downbefore re-running.- The
theme: shopifypreset's container image is built locally on first run. Publishing to a registry (e.g.ghcr.io/steig/boring-shopify:v1) is on the roadmap to cut first-run from ~60s to ~5s. install.shis documented as the eventualcurl | bashinstall path, but requires the boring repo to go public (or a token-gated install) to work for users beyond the maintainer.
[0.1.0-dev] - 2026-05-23¶
Initial scaffold. Design locked, implementation in progress.
Added¶
- Architectural Decision Records under
docs/ards/: - ARD-0001 — full v1 architecture (12 design forks resolved via
/grill-me+ DevOps re-evaluation). - ARD-0002 — amends ARD-0001:
dbxis a runtime CLI dependency (not a library extraction), and boring owns zero secret storage (pure URI resolver). - ARD-0003 — amends ARD-0001: boring shells out to
@devcontainers/clifor container lifecycle. boringCLI scaffold: subcommand dispatcher (open,run,doctor,version,help).openandrunprint "not yet implemented" placeholders describing intent.lib/core.sh— paths (DATA_DIR,CONFIG_DIR,AUDIT_LOG,REGISTRY_FILE), TTY-aware ANSI colors, logging (log_info|success|warn|error|step),die,require_cmd.lib/secrets.sh—!secretURI resolver. Supportsop://,keychain:,dbx-vault:,vault://,aws-sm:,env:,file:. Fails loudly with install hints when the underlying CLI is missing.lib/dbx.sh— thin wrappers around thedbxCLI (dbx_restore,dbx_vault_get).lib/devcontainer.sh— thin wrappers around@devcontainers/cli(devcontainer_up,devcontainer_exec,devcontainer_down).lib/doctor.sh—boring doctorenvironment diagnostics: docker, devcontainer, dbx, optional secret-resolver tools (op,vault,aws,security,secret-tool).install.sh— checks for required dependencies and prints install hints; downloads boring + lib files to~/.local/bin/boringand~/.local/lib/boring/. Does not auto-install runtimes (ARD-0001 Q9: surprise installers tank trust).docs/index.html— marketing/intro page, also published tos3.steig.io/public/boring/.- README, AGENTS.md, LICENSE (MIT), and this CHANGELOG.
Stubbed (with TODO(impl, ARD-0002 impl-order #X) markers)¶
boring open <git-url|.>— clone, profile-read, compose+devcontainer.json generation, dbx restore, devcontainer up, editor attach.boring run <profile> --task <t>— headless agent run.lib/profile.sh—.boring/profile.yamlparser, overlay merge, schema validation.lib/compose.sh— docker-compose.yml + devcontainer.json generation from a parsed profile.lib/egress.sh— per-profile egress allowlist enforcement (iptables vs. proxy sidecar to be prototyped).
Verified working on macOS¶
boring help,boring version, unknown-subcommand pathboring doctorcorrectly reports docker present, dbx present, devcontainer missing