Changelog¶
All notable changes to boring are documented here. Format follows Keep a Changelog; versions follow Semantic Versioning.
[Unreleased]¶
Added¶
tasks:— long-running processes launched after setup (ARD-0023). A fifth profile primitive alongsideservices:/volumes:/setup:/restore:: each entry (name+run+ optionaldepends_on, topological launch order, cycles rejected at schema time) becomes a window of aboring-taskstmux session inside the dev container, started aftersetup:andrestore:complete — soboring openon a non-trivial codebase boots with the app running, editor-independently (VSCode-tasks-style automation without VSCode). Window output is tee'd to/var/log/boring/tasks/<name>.logvia a host bind-mount. No auto-restart in v1 (deliberate — a crashed task's window stays open showing the exit status). New commands:boring attach(land in the tmux session),boring logs <name> [-f](tail a task's log host-side, nodocker exec),boring close(tear down a running sandbox from any shell: tmux kill + compose down + audit-collector stop), plusboring open --no-tasks. tmux added to all five preset images;boring doctorverifies it in a running container when the profile declarestasks:; the ARD-0017 agent snippet tells in-container agents the session exists and is not theirs to kill.boring runignorestasks:by design. The immich example now declares its API + web dev servers as tasks. (#18)boring proxy installactually installs (ARD-0021 §5/§6/§8, #20). The install now runs the provisioning it used to only print:mkcert -installfor the local CA, theboring.localcert issue, the one-time sudo'd127.0.0.1 boring.localline in/etc/hosts, and the autostart unit is both written and activated (launchctl loadon macOS,systemctl --user enable --nowon Linux). On Linux the binary getsCAP_NET_BIND_SERVICEviasetcapso the proxy binds:443(:8443fallback when setcap is unavailable).--print-onlyrestores the review-first dry-run.uninstallnow really unloads/disables and removes the unit.boring proxy statusanswers the running question for real: autostart load state via launchctl/systemd plus a probe of the new unauthenticated/healthzendpoint onboring.local:443/:8443and dev:8090.- "Add a project" wizard (ARD-0021 §7, #20). The picker's
+card opens a real dialog: point it at a folder, it detects.boring/profile.yaml, derives the lowercase-kebab slug from the profilename:, refuses duplicate slugs with a suggested suffix, and registers the project inregistry.json(asstopped, with an honest "start it withboring open --ui <path>" hint — the proxy registers, the CLI launches).POST /api/projectsis the backing endpoint. - Idle-container auto-shutdown (ARD-0021 §8, #20). The proxy stamps per-project browser activity (an open chat SSE stream counts as continuously active) and stops projects idle past the timeout via
boring ui stop <slug>. Default 2h — the long-lunch tolerance; tune or disable withserve --idle-shutdown <dur|0>. The stopped-project page now says what happened and how to resume. - Single-user lock enforced (ARD-0022 §2, #20).
lock_statusfinally has a producer: the backend owns a per-project lock (claim / heartbeat / release / take-over via/api/lock*), gatesPOST /api/messages,/api/save, and/api/undoon holding it (423 otherwise), auto-releases after 30 minutes idle, and broadcasts transitions. The chat UI claims on load, heartbeats, releases on tab close, and shows the ARD's lock screen — who's there, last activity, wait or take over. The picker's project cards show presence ("● Alice is here") relayed from the lock over the project socket. - Per-action undo is a real
git revert(ARD-0022 §3/§4, #20). Every file-modifying turn now lands as a hidden commit on aboring/wip/<user>/<timestamp>branch (created lazily on the first change; disable with--wip-commits=false), surfaced as a "Checkpoint" card with an Undo button. Undo reverts that commit — sticky, so the eventual PR shows both the change and the change of mind — refuses off-WIP branches, dirty trees, and unrelated SHAs, and reports failure honestly (undo_failed) instead of pretending. boring sweep(ARD-0022 §3 cleanup, #20). Prunesboring/wip/*branches whose newest commit is older than the profile'swip_branch_ttl(default 7d — the field existed since v0.11 but nothing consumed it).--dry-runlists the candidates; the checked-out branch is never touched. Meant for a daily cron; safe by hand.- Tool cards show diffs, not stdout snippets (ARD-0022 §4, ARD-0029 §6 gap #4, #20). Edit/Write tool results synthesize a unified diff from claude's
structuredPatch(file creations render as all-added hunks, capped at 400 lines) and the chat card renders it under the summary. - The chat surface is an installable PWA (ARD-0019 §6, #20).
manifest.json+ service worker + icons on the per-project chat page (the picker already had them), scoped to the project path behind the proxy. boring open --uiprefers the bookmark (ARD-0019, #20). When the installed always-running proxy answers onhttps://boring.local/, the dev-mode:8090proxy isn't started and URLs point atboring.local— the marketer's bookmark and the engineer's terminal now land in the same place. Unauthenticated browser navigations redirect to/authinstead of a bare 401.
[0.17.0] — 2026-08-05¶
Added¶
- The container comes up ready (ARD-0046).
boring openforwards the engineer's existing host Claude Code login into the container, so the agent pane is signed in on the subscription plan with no setup step. Replacesclaude setup-token, which never authenticated an interactive pane (Claude Code reads~/.claude/.credentials.json;CLAUDE_CODE_OAUTH_TOKENalone leaves it at the login picker) and, when it did apply, billed asClaude APIrather than the subscription — the regression ARD-0020 calls non-negotiable. Only theclaudeAiOauthobject is forwarded; the same Keychain record holdsmcpOAuth.*for every MCP server the user has authorised, and that stays on the host. Onboarding state and/workspacetrust are carried across too — valid credentials alone still leave a fresh container asking for a theme, then a login method, then folder trust, in front of a token it hasn't consulted. Disable withclaude.auth: falseorBORING_NO_CLAUDE_AUTH=1. claude.mcpis actually rendered. The profile's MCP servers are written to/etc/boring/mcp.jsonand passed to the agent pane. Previously the field was parsed, validated, and then dropped — the pane hard-coded an empty config, so a profile could declare servers and get none. An empty declaration still emits{"mcpServers":{}}, so the ARD-0029 §3 isolation default is unchanged.
Fixed¶
- Secrets now reach every process that needs them (ARD-0045).
--remote-envondevcontainer uponly covers lifecycle hooks, sodev.command(viadevcontainer exec) and the boring-ui pane (viadocker exec) both started with secrets unset. In practiceshopify theme devsilently fell back to interactive OAuth device-code and the agent pane started logged out — which reads as "boring is broken" rather than "a credential is missing". Resolved values now ride the compose serviceenvironment:block, which everyexecinherits. - A failed
dev.commandno longer tears down the web UI. With no TTY on stdin the fallback shell EOF'd immediately and fired the EXIT trap, taking down the UI the user had just been told was still usable.
Security¶
- Resolved secrets are off host argv (#41, ARD-0045).
devcontainer up --remote-env KEY=VALUEplaced every secret in host process argv, readable by any local process for the duration of bring-up; ARD-0044 raised the stakes by sending a push-capableGH_TOKENdown that channel by default. Values are now exported into boring's own process env and emitted asKEY: "${KEY}"placeholders that Docker Compose interpolates atuptime — off argv, and only the placeholder is written to disk. Mirrors the existingBORING_EGRESS_MODEprecedent. Coverscmd_open,cmd_run, andlib/gitauth.sh. Trade-off recorded in ARD-0045: values are now visible viadocker inspectand in boring's process env — a different exposure shape than argv, not strictly smaller. - A live subscription credential now enters the sandbox (ARD-0046), where a prompt-injected agent could exfiltrate it within the egress allowlist —
api.anthropic.comis necessarily open. Same shape of exposure ARD-0044 accepted forGH_TOKEN, bounded the same way:claude.auth: falseper-repo,BORING_NO_CLAUDE_AUTH=1globally.
[0.16.0] — 2026-06-23¶
Added¶
- Frictionless in-container GitHub auth (ARD-0044).
boring open/boring runauto-inject the hostgh auth tokeninto github.com sandboxes sogit push/ghwork inside the container — an agent no longer has to hand the push + PR back to a host shell. Git is configured viaGIT_CONFIG_*env (no on-disk token): the SSH remote is rewritten to HTTPS plus a token-from-env credential helper, and the host git identity is forwarded — so it works with nosshbinary, dbus, or keyring in the container.github.com/api.github.comare opened in the egress allowlist when enforcing. Token precedence:BORING_GIT_TOKEN→ keychainboring-github/github.com→gh auth token. Silent no-op without a token; off for the marketer UI surface (--uior profileui.enabled); disable per-repo withgit_auth: falseor globally withBORING_NO_GIT_AUTH=1. Newboring git-auth {status|login|logout}and aboring doctorsource check. (#40)
Security¶
- The in-container token is a deliberate, bounded exception to the credential-starvation default (ARD-0005) — narrow it to a single repo with a fine-grained PAT via
boring git-auth login.git_authis an overlay-protected field (a gitignored/machine overlay can't re-enable a committedgit_auth: false), and origin detection is host-anchored (no*github.com*substring, so look-alike hosts don't activate). The host-argv exposure of the--remote-envsecret channel (pre-existing, affects every secret) is tracked for a channel-wide fix in #41.
[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