Skip to content

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 alongside services:/volumes:/setup:/restore:: each entry (name + run + optional depends_on, topological launch order, cycles rejected at schema time) becomes a window of a boring-tasks tmux session inside the dev container, started after setup: and restore: complete — so boring open on 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>.log via 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, no docker exec), boring close (tear down a running sandbox from any shell: tmux kill + compose down + audit-collector stop), plus boring open --no-tasks. tmux added to all five preset images; boring doctor verifies it in a running container when the profile declares tasks:; the ARD-0017 agent snippet tells in-container agents the session exists and is not theirs to kill. boring run ignores tasks: by design. The immich example now declares its API + web dev servers as tasks. (#18)
  • boring proxy install actually installs (ARD-0021 §5/§6/§8, #20). The install now runs the provisioning it used to only print: mkcert -install for the local CA, the boring.local cert issue, the one-time sudo'd 127.0.0.1 boring.local line in /etc/hosts, and the autostart unit is both written and activated (launchctl load on macOS, systemctl --user enable --now on Linux). On Linux the binary gets CAP_NET_BIND_SERVICE via setcap so the proxy binds :443 (:8443 fallback when setcap is unavailable). --print-only restores the review-first dry-run. uninstall now really unloads/disables and removes the unit. boring proxy status answers the running question for real: autostart load state via launchctl/systemd plus a probe of the new unauthenticated /healthz endpoint on boring.local:443/:8443 and 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 profile name:, refuses duplicate slugs with a suggested suffix, and registers the project in registry.json (as stopped, with an honest "start it with boring open --ui <path>" hint — the proxy registers, the CLI launches). POST /api/projects is 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 with serve --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_status finally has a producer: the backend owns a per-project lock (claim / heartbeat / release / take-over via /api/lock*), gates POST /api/messages, /api/save, and /api/undo on 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 a boring/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). Prunes boring/wip/* branches whose newest commit is older than the profile's wip_branch_ttl (default 7d — the field existed since v0.11 but nothing consumed it). --dry-run lists 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 --ui prefers the bookmark (ARD-0019, #20). When the installed always-running proxy answers on https://boring.local/, the dev-mode :8090 proxy isn't started and URLs point at boring.local — the marketer's bookmark and the engineer's terminal now land in the same place. Unauthenticated browser navigations redirect to /auth instead of a bare 401.

[0.17.0] — 2026-08-05

Added

  • The container comes up ready (ARD-0046). boring open forwards 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. Replaces claude setup-token, which never authenticated an interactive pane (Claude Code reads ~/.claude/.credentials.json; CLAUDE_CODE_OAUTH_TOKEN alone leaves it at the login picker) and, when it did apply, billed as Claude API rather than the subscription — the regression ARD-0020 calls non-negotiable. Only the claudeAiOauth object is forwarded; the same Keychain record holds mcpOAuth.* for every MCP server the user has authorised, and that stays on the host. Onboarding state and /workspace trust 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 with claude.auth: false or BORING_NO_CLAUDE_AUTH=1.
  • claude.mcp is actually rendered. The profile's MCP servers are written to /etc/boring/mcp.json and 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-env on devcontainer up only covers lifecycle hooks, so dev.command (via devcontainer exec) and the boring-ui pane (via docker exec) both started with secrets unset. In practice shopify theme dev silently 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 service environment: block, which every exec inherits.
  • A failed dev.command no 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=VALUE placed 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-capable GH_TOKEN down that channel by default. Values are now exported into boring's own process env and emitted as KEY: "${KEY}" placeholders that Docker Compose interpolates at up time — off argv, and only the placeholder is written to disk. Mirrors the existing BORING_EGRESS_MODE precedent. Covers cmd_open, cmd_run, and lib/gitauth.sh. Trade-off recorded in ARD-0045: values are now visible via docker inspect and 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.com is necessarily open. Same shape of exposure ARD-0044 accepted for GH_TOKEN, bounded the same way: claude.auth: false per-repo, BORING_NO_CLAUDE_AUTH=1 globally.

[0.16.0] — 2026-06-23

Added

  • Frictionless in-container GitHub auth (ARD-0044). boring open / boring run auto-inject the host gh auth token into github.com sandboxes so git push / gh work inside the container — an agent no longer has to hand the push + PR back to a host shell. Git is configured via GIT_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 no ssh binary, dbus, or keyring in the container. github.com / api.github.com are opened in the egress allowlist when enforcing. Token precedence: BORING_GIT_TOKEN → keychain boring-github/github.comgh auth token. Silent no-op without a token; off for the marketer UI surface (--ui or profile ui.enabled); disable per-repo with git_auth: false or globally with BORING_NO_GIT_AUTH=1. New boring git-auth {status|login|logout} and a boring doctor source 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_auth is an overlay-protected field (a gitignored/machine overlay can't re-enable a committed git_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-env secret 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). In enforce/learn modes 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 so dev → postgres/redis keeps 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-egress now drops cloud-metadata (169.254.169.254, ECS 169.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-network relaxes 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 into devcontainer.json's customizations.vscode (bare ids + autoUpdate:false; settings merged). Invalid ids rejected at parse. (#17)
  • boring doctor repo-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 bash audit-emit shim now stamps agent: (default claude); the Go backend already did. (#21)
  • Turn-outcome classification (ARD-0038). boring-ui's turn_complete carries a verdict (ok / agent_no_output / agent_error / nonzero_exit), and boring run maps 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.md now 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 (via AGENTS.md) but not Claude. Now RO-mounted and @-imported, mirroring the AGENTS.md path. (#13)
  • smoke-ard-0015 no 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. Headless boring run ignores 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)
  • AgentProvider contract 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_blocked events routed to the ARD-0010 audit FIFO with an agent: 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 any env secret:// 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_sensitivity is documented and treated as operator-asserted, not boring-verified. boring open / boring run now warn when data_sensitivity: sanitized is declared but no restore: entry carries a boring-run transform: — i.e. sanitization happens outside boring's view and cannot be verified. (ARD-0039, #9)

Fixed

  • boring doctor no longer false-negatives dbx 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 env values in generated compose. (#3)

[0.12.1] — 2026-06-04

Fixed

  • compose.sh: newline between a user services: sidecar and the egress-logger block. sidecars_block comes 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 invalid docker-compose.yml whenever a profile declared both a services: sidecar and egress:, failing docker compose config / devcontainer up at 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-backend starts a second HTTP listener on a deterministic per-slug host port (--preview-port, range 8700..9199 via web_ui_preview_port) that reverse-proxies at root to --preview-url, stripping X-Frame-Options + CSP frame-ancestors on every response. The right-pane iframe src is now the absolute http://127.0.0.1:<preview-port>/.
  • Why: end-to-end testing against a real Shopify theme dev server (shopify theme dev on :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 as text/plain with frame-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. (:9292 sets X-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/Strict cookies 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 in localStorage. Works identically whether the left pane is the chat thread or the --terminal-url terminal 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 that postMessages 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 strips Accept-Encoding outbound (Go's transport then transparently decompresses) and, defensively, allows 'self' in any upstream script-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 via security; Linux libsecret via secret-tool) — the same backend the secret://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 open then resolves it and injects it via the existing --remote-env path with zero per-use auth. A non-engineer runs boring open (or clicks the project in the boring-ui picker, same cmd_open) and the container is pre-authenticated — no OAuth prompt, no vault sign-in, no .env. set reads 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. list is intentionally omitted — enumerating generic-password items is awkward and inconsistent across security/secret-tool; get/rm cover the need.

[0.10.1] — 2026-05-26

Fixed

  • chat.js no longer throws TypeError: Cannot read properties of null (reading 'addEventListener') in terminal-pane mode. When --terminal-url is set, renderIndex substitutes {{LEFT_PANE}} with an iframe only — there's no #thread, #composer, or #input in the DOM. v0.10.0 (and earlier --terminal-url-using versions back to v0.8.0) unconditionally called composer.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-Options header: deleted entirely on every proxied response.
  • Content-Security-Policy header: the frame-ancestors directive is scrubbed (case-insensitive, whole-directive-name match) while every other directive (script-src, style-src, default-src, etc.) is preserved. If frame-ancestors was the only directive, the whole CSP header is deleted.
  • WebSocket upgrade: passes through Upgrade: websocket + Connection: upgrade handshake 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=Strict cookie 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 src is 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-url flag 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. Uses net/http/httptest to mock every upstream — no live Shopify / claude / docker invocation.
  • tools/boring-ui-backend/server.go — mount /preview + /preview/ routes before the / catch-all; iframe src in renderIndex switched from absolute URL to relative /preview/.
  • tools/boring-ui-backend/server_test.goTestIndexPreviewIframeWhenURLSet updated to assert the new relative-src behavior + guard against the old absolute-src regressing.
  • tools/boring-ui-backend/main.go--preview-url flag 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.js references (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. Override preview_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.go flags this loudly — anyone copying stripFrameBlockingHeaders into 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 localhost to 127.0.0.1 in web_ui_preset_preview_default (lib/web_ui.sh). Surfaced while testing shop-theme: the v0.8.1 default http://localhost:9292/ produced a blank iframe even with pnpm dev serving correctly inside the container. Root cause is the same IPv6/IPv4 mismatch family as v0.7.x's IMMICH_HOST fix — docker-compose port forwards bind IPv4 only by default; macOS resolves localhost to ::1 (IPv6) first via getaddrinfo; the iframe request hits IPv6 loopback :9292 (nothing listening), gives up or noticeably delays before retry. Explicit 127.0.0.1 matches 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 of profile_load.
  • boring open foreground 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's dev.command in the FOREGROUND via devcontainer 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-dev was passed: drop into the existing interactive bash shell (back-compat with pre-v0.9.0 boring open).
  • --no-dev flag on boring open. Skip dev.command even if the profile sets it; drop into bash shell instead. Documented in boring help + the top-of-file usage block. Use when debugging the container or the dev process itself.

Changed

  • Trap chain hardened (boring). Teardown logic for cmd_open (audit collector stop + UI stack stop) is now centralized in a single EXIT trap (_cmd_open_teardown_all). INT/TERM traps just exit 130; EXIT does the work — devcontainer exec frequently eats SIGINT, so the EXIT path is the only reliable safety net. UI teardown is gated on a new BORING_OPEN_UI_STARTED flag set by _cmd_open_maybe_start_ui on 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 using boring open --ui for now without dev: 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 concurrently or npm-run-all in the dev.command string. A future dev: { 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 --ui can wait before opening the browser.

Files touched

  • boring--no-dev parse, new _cmd_open_teardown_all + _cmd_open_maybe_run_dev_or_shell helpers, trap chain centralization, BORING_OPEN_UI_STARTED flag, help/usage updates, VERSION → 0.9.0.
  • lib/profile.shdev: schema validation (dev.command required + string-or-list shape; dev.workdir absolute-path; dev.port int range) + normalization (.dev.command is always a string downstream; .dev.workdir defaults to /workspace; .dev is 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-dev flag surfacing, runner argv via PATH-shimmed devcontainer stub, --no-dev short-circuit, failure-hint + bash-drop fallback. No live devcontainer / docker / claude invocation.
  • CHANGELOG.md — this entry.

[0.8.1] — 2026-05-26

Fixed

  • In-container claude failed to start under --ui with Invalid MCP configuration: mcpServers: Invalid input: expected record, received undefined. v0.8.0's web_ui_ensure_container_claude wrote printf "{}" > /etc/boring/empty-mcp.json — but claude's MCP validator rejects bare {} (already verified empirically when boring-ui-backend's emptyMCPConfigFile() was written; the only accepted shape is the literal {"mcpServers":{}}). Fix: write the exact accepted shape; also remove the if [ ! -f ] guard so v0.8.0-installed bad files get corrected on next --ui run; 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: new web_ui_preset_preview_default() in lib/web_ui.sh codifies the table (shopify→9292, django-node→5173, node→3000, node-postgres→3000, python→empty); cmd_open --ui falls back to it when both profile fields are empty. python preset still requires explicit preview_url: since there's no canonical dev-server port.

[0.8.0] — 2026-05-26

Added

  • boring open --ui + --no-ui flags. Single-command path to bring the dev container up AND wire the boring-ui web stack (singleton host proxy on :8090, per-project ttyd serving docker exec -it <c> claude with 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-complete is 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-ui force-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 --ui nor --no-ui is passed) and ui.preview_url (string; absolute URL the right-pane iframe loads, wins over the top-level preview_url for UI consumers). Validated + surfaced in the normalized JSON output of profile_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 via printf '%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-proxy socketAllowedPrefixes.
  • boring ui {status|stop|open} [<slug>] subcommand. status prints 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 --ui use via make build in each tool dir. Requires go on PATH (with ttyd, docker — pre-flighted by web_ui_required_binaries_present with 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_start produces the exact ARD-0029 §3 argv (verified via shell-function stub that records argv). No live claude / 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. Use boring ui stop <slug> to tear them down explicitly. A future minor will wire this into the existing INT/TERM trap chain.
  • In-container claude OAuth is still a one-time manual step. First time you use --ui against 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-auth dev mode. boring open --ui starts the proxy without TLS or token auth so the marketer flow works without the boring proxy install ceremony. TLS + per-user token + autostart (ARD-0021 §5-§8) still require boring proxy install explicitly; the dev-mode proxy is bound to 127.0.0.1:8090 only, so it's not exposed beyond loopback.
  • Custom Dockerfile presets must install claude themselves. For boring's bundled presets (shopify, django-node, python, node, node-postgres) claude is image-baked. For custom stack.dockerfile: profiles (e.g. immich), web_ui_ensure_container_claude will 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 upgrade subcommand. Pulls the latest boring from origin/main at the install root (SCRIPT_DIR per the existing readlink -f resolution). 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.3 to 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 enable no longer fails in postCreateCommand for shopify + django-node presets. Both presets install Node 20 via NodeSource, which does NOT enable corepack by default (unlike the official node Docker image). Profiles that included corepack enable in setup: then hit EACCES: permission denied, symlink ... -> /usr/bin/pnpm because the dev user can't write to /usr/bin/. Fix: add corepack enable at image-build time (as root) right after the npm install -g line in both Dockerfiles. Profiles can keep corepack enable in setup: for idempotence or drop it; pnpm install works either way. The python preset is intentionally unchanged — it ships without runtime npm by design (use node / node-postgres / django-node for 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_file re-runs now succeed. v0.7.1 and earlier wrote .devcontainer/boring-runtime/egress.allow then chmod 0444'd it (so an in-container agent can't overwrite via the bind mount). Subsequent boring open invocations on the same repo failed with EACCES at lib/egress.sh:31 because the redirect > couldn't open a 0444 file for writing. Fix: write to .tmp, chmod 0444 .tmp, then mv -f (atomic rename works against the parent dir's perms, bypasses the destination's read-only mode). Matches the atomicWriteFile pattern in tools/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.sh no longer collides with BORING_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 run boring proxy install or 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_ROOT env (always honored) → legacy ~/.local/share/boring/.git if it's a steig/boring checkout → 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's save: configuration (ARD-0022 §7). Reads save.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 via gh 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). start creates boring/wip/<marketer>/<ts>; commit --prompt <text> stages all + commits with an AI-summarized message via claude --print; discard deletes (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_paths computes (preset default + profile.allowed_paths) − profile.disallowed_paths. cmd_open now calls guardrails_emit_codegen_dir after 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 time
  • save: block: target_branch, reviewers_from/reviewers, draft_by_default, branch_prefix, pr_template
  • preview_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 to allowed_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.yaml per ARD-0022 §5.2 verbatim.
  • examples/immich/ — first real-world stack example, separate from the curated presets. Custom stack.dockerfile: FROM ghcr.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.md to /home/dev/.config/opencode/AGENTS.md:ro per ARD-0028 §3.
  • Test fixtures + smoke tests:
  • tests/fixtures/profile-with-boring-ui-fields.yaml exercises every new field
  • tests/fixtures/profile-with-deprecated-allowed-claude-tools.yaml exercises the back-compat path
  • tests/smoke-boring-ui-schema.sh — 27-assertion schema smoke
  • tests/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 --print shell-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 502
  • IMMICH_SERVER_URL: "http://localhost:2283/" env — vite.config.ts default proxy target is http://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 .immich files 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-0021
  • tools/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 tracking
  • scripts/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 with docker compose down -v on exit). Claude prompt is the only input shape — for shell commands use devcontainer exec directly. Same secret-resolution code path as boring 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, --profile validation, 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.
  • target is cross-referenced against services: (fails validation if it names a non-existent sidecar).
  • transform is REQUIRED when data_sensitivity: sanitized per ARD-0012's safety interlock (the field that's been parsed-but-no-op since v0.2 now becomes load-bearing).
  • when is one of first_up | every_up | manual; defaults to first_up.
  • Profile-level: restore: is rejected when data_sensitivity: internal (the meaning of "internal" is "no real data ever in this container").
  • _cmd_open_run_restores fires between devcontainer up and setup: so migrations/seeds run against prod-shaped data, not against an empty schema. Walks the restore list, invokes dbx restore <source> [--transform=<path>] --into <container> (container name resolved as <profile>-<target>-1 via 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); --refresh clears markers and promotes manual: entries to every_up so they fire on demand.
  • Compose project name pinned to the profile name (compose_generate ... --project-name "$name" in cmd_open) so sidecar containers get predictable names rather than the unpredictable devcontainer-<service>-1 default.
  • boring doctor pre-flights dbx restore --help for --transform and --into; warns explicitly when missing rather than failing mid-boring open. Requires dbx ≥ commit d1f585d (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-egress runs as root at container boot, installs OUTPUT rules from the bind-mounted allowlist file, then drops to the dev user via gosu before execing user code. enforce mode default; BORING_EGRESS_MODE=learn swaps REJECT for NFLOG.
  • boring open --learn-mode records every outbound connection attempt and prints a proposed egress.allow: diff on Ctrl-C — the authoring path that makes the allowlist tractable.
  • ulogd2 sidecar (ARD-0015) replaces the original dmesg-based learn-mode reader. New templates/_common/egress-logger/ ships a Debian-slim sidecar with ulogd2 + JSON output plugin; shares the dev container's netns via network_mode: "service:dev"; reads NFLOG packets and writes JSON to a host-bind-mounted shared volume that boring's egress_propose_allowlist_diff parses. Works on Mac+Orbstack, which the dmesg path could not — the dogfood team's daily platform.
  • lib/egress.sh completed (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, dnsutils and the install-egress entrypoint 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 open time and bind-mounted RO into the container at /workspace/.devcontainer/boring-runtime/:
  • pre-push hook from guardrails.forbid_branches: — refuses pushes whose target ref matches a forbidden branch. Repointed core.hooksPath from /etc/boring/git-hooks/ to the bind-mount so the runtime version wins.
  • bin/<cmd> wrappers from guardrails.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.json from guardrails.allowed_claude_tools:jq deep-merge of the image-baked baseline (ARD-0006 deny rules + ARD-0010 audit hooks) with the per-profile permissions.allow list. In-container ~/.claude/settings.json symlinks 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.jsonl by default; opt-in audit.prompts: shared routes to the shared file.
  • Claude Code native hooks (UserPromptSubmit, PostToolUse, Stop) wired in the image-baked settings.json to invoke audit-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 deny rules now cover /workspace/.devcontainer/boring-runtime/** and /home/dev/.claude/settings.json so an in-container agent can't disable its own observability.
  • audit-emit shim moved from image-baked → host-emitted RO bind-mount (fix in dcce24f). The original v0.3-dev shipped the script at /usr/local/boring/bin/audit-emit in the container's writable layer, where sudo rm could 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-postgres were 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/boring FIFO mount target, core.hooksPath repoint, PATH prepend for boring-runtime/bin, settings.json symlink swap, install-egress entrypoint chain, removed USER dev (install-egress drops via gosu).

Added — infrastructure (2026-05-23/24)

  • scripts/deploy-site.sh — push docs/index.html to MinIO at s3.steig.io/public/boring/. Idempotent; verifies live response after upload.
  • scripts/test.sh — unified smoke test runner. Discovers smoke*.sh / test*.sh under scripts/ (excluding non-test scripts like deploy-site.sh) and any *.sh under tests/. Per-test PASS/FAIL/SKIP (exit 77 = skip per autoconf convention). -v for 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. syntax job runs bash -n on every shell file + advisory shellcheck. smoke job installs jq + mikefarah/yq + @devcontainers/cli, runs boring doctor (warns on missing optional deps like dbx), then scripts/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 unpredictable devcontainer default from the .devcontainer/ directory). Sidecar containers now get predictable names like <profile>-<service>-1.
  • Marketing site rewritten for the thesis pivot (docs/index.html, commit 8bb7ce7). 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-emit script location (dcce24f). Originally shipped in the container's writable layer where sudo rm could 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++)) with c=$((c + 1)) (the ++ form returns nonzero on c=0 which set -e treats as failure).
  • local bad shadowing in _profile_validate_jsonbad was used without local before being redeclared later. Fixed.
  • cd frontend && npm install in setup chains — the cwd persisted across the joined shell expression, breaking later commands. Subshelled (cd frontend && npm install) in the dogfood profile; documented as a setup: ergonomics note.
  • Stale dmesg-based egress smoke removed (4f09100); replaced by scripts/smoke-ard-0015.sh exercising the ulogd2 path.

[0.2.0-dev] — 2026-05-23

Added (django-node + multi-service compose — 2026-05-23, v0.2 slice)

  • ARD-0007preset: 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 in lib/profile.sh (_BORING_PROFILE_DEPRECATIONS_V1).
  • theme:preset: rename (soft deprecation). lib/profile.sh accepts both for v1 schema; warns on theme: and rewrites in-memory to preset:. v2 will remove theme:. shop-theme's existing theme: shopify profile continues to work with a warning until migrated.
  • services: structured schema. Sidecars declared as {name, image, env, volumes, healthcheck, depends_on} objects. Top-level volumes: list for named-volume declarations. lib/compose.sh emits multi-service compose with auto-wired depends_on on the dev service (condition: service_healthy when sidecar declares a healthcheck, else service_started).
  • setup: lifecycle hook. List of shell commands. lib/compose.sh emits them as postCreateCommand in devcontainer.json (devcontainer-native, fires once on container creation, works with VS Code "Reopen in Container"). cmd_open also writes a /var/lib/boring/setup-complete marker 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 like bootstrap_data racing Postgres readiness).
  • Secret URI resolution at container start. cmd_open walks normalized env entries, calls secret_resolve from lib/secrets.sh for each secret://... URI, and passes the resolved pairs to devcontainer 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 ship OPENROUTER_API_KEY as a literal in a checked-in profile).
  • templates/django-node/preset: django-node Dockerfile + supporting files. Base python:3.14-slim-bookworm; installs uv (pinned ARG), Node 20 (NodeSource), libpq5, postgresql-client (psql + pg_isready), git, gh, sudo, tini, Claude Code. Non-root dev user (uid 1000) with NOPASSWD sudo. /workspace, /home/dev/.config, /var/lib/boring pre-created with dev:dev ownership. xdg-open shim verbatim from shopify preset; ARD-0006 trust-anchor enforcement verbatim. Claude defaults via the shared common build context (templates/_common/claude/).
  • preset: django-node defaults seeding. When a profile declares preset: django-node without authoring sidecars/volumes/forward_ports/DATABASE_URL, the normalizer seeds: postgres:17 sidecar (POSTGRES_DB=content_infra, POSTGRES_PASSWORD=postgres, named volume postgres-data, pg_isready healthcheck), top-level volumes: [postgres-data], forward_ports: [8000, 5173], DATABASE_URL pointing at the sidecar. User-authored values win on conflict (per-key merge for env, whole-array replacement for services/volumes/forward_ports).
  • Second dogfood profile: ~/code/work/content-infrastructure/.boring/profile.yaml. Django + Django Ninja + React/Vite + Postgres 17. Demonstrates preset: 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, and guardrails.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.yaml if 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-expands mounts host paths; classifies env values as {kind: literal} vs. {kind: secret, uri: ...} (using the secret://... convention per the v1 yq-tag pragma).
  • lib/compose.sh — full implementation (replaces the STUB). Emits .devcontainer/docker-compose.yml (single dev service 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 to cmd_open.
  • boring open <path> — functional. Loads profile, generates .devcontainer/, calls devcontainer up. URL cloning, secret resolution, egress enforcement, guardrails codegen all deferred.
  • templates/shopify/theme: shopify preset Dockerfile + supporting files. Base ruby:3.3-slim-bookworm (matches a typical Shopify theme dev shell — same toolchain flake.nix-using projects pin); installs Node 20, Shopify CLI, gh, git, tini, Claude Code. Non-root dev user (uid 1000), /workspace working 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 generated devcontainer.json and docker-compose.yml. Fixed by emitting ..:/workspace:cached. (880c9b8)
  • /home/dev/.config was created as root when boring's bind-mount for ~/.config/shopify triggered Docker to materialize the parent. That blocked sibling CLIs like shopify-cli-kit-nodejs from writing their own config; shopify auth login failed with EACCES. Fixed by pre-creating /home/dev/.config with dev:dev ownership in the Dockerfile. (7edcdb9)
  • CLIs that auto-open browsers crashed with spawn xdg-open ENOENT in the headless container, abandoning their polling loops (so even manual browser auth couldn't complete). Fixed by dropping a tiny xdg-open shim into /usr/local/bin that 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's npm run dev script read $SHOPIFY_STORE (matching its .env.example convention). 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.
  • /workspace correctly mounts the repo root; git operations inside the container match host state.
  • Port 9292 forwards host↔container (shopify theme dev hot-reload).
  • Shopify auth via device-code flow completes successfully and persists across container rebuilds via the RW bind-mount of ~/.config/shopify/.
  • npm run dev serves the dev store with hot-reload visible at http://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 permission deny + system-wide git pre-commit hook installed via core.hooksPath in /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-repo CLAUDE.local.md for project-specific rules.
  • settings.json — the trust-anchor deny rules (moved out of inline printf in the Dockerfile into a real JSON file for readability).
  • skills/grill-me/SKILL.md/grill-me available to the user inside the container.

Added (v0.6 headless boring run slice — 2026-05-24)

  • ARD-0013 — headless boring run for one-shot Claude invocations in a profile-scoped sandbox. Fresh container per invocation, identical secret code path to boring open, same trust-anchor and guardrails posture.
  • boring run "<prompt>" --profile <name> [--repo <path>] — replaces the v0.1 stub. Pre-flights all secret:// 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 interactive boring open of the same profile. Brings up via devcontainer up --remove-existing-container with resolved secrets injected as --remote-env KEY=VAL (devcontainer-CLI surface; never written to docker-compose.yml). Invokes claude -p "<prompt>" inside the container; streams stdout to the host; exits with Claude's exit code. SIGINT / SIGTERM / normal-exit teardown all converge on docker compose --project-name … down -v --remove-orphans (the -v removes the run's named volumes, which is the reproducibility property).
  • lib/compose.shcompose_generate now accepts an optional --project-name <name> flag that writes a top-level name: field into the generated docker-compose.yml. Used by boring run only; boring open continues to omit it.
  • tests/smoke_run.sh — orchestration smoke for cmd_run. Uses on-PATH mocks for op, claude, devcontainer, and docker (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), --profile mismatch rejection, non-slug --profile rejection, no-secrets profile (empty --remote-env arg list), and --help.

Known UX gaps (filed for next slices)

  • boring open does not auto-recreate the container when the compose file changes. Workaround: docker compose --project-name <name> down before re-running.
  • The theme: shopify preset'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.sh is documented as the eventual curl | bash install 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: dbx is 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/cli for container lifecycle.
  • boring CLI scaffold: subcommand dispatcher (open, run, doctor, version, help). open and run print "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!secret URI resolver. Supports op://, 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 the dbx CLI (dbx_restore, dbx_vault_get).
  • lib/devcontainer.sh — thin wrappers around @devcontainers/cli (devcontainer_up, devcontainer_exec, devcontainer_down).
  • lib/doctor.shboring doctor environment 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/boring and ~/.local/lib/boring/. Does not auto-install runtimes (ARD-0001 Q9: surprise installers tank trust).
  • docs/index.html — marketing/intro page, also published to s3.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.yaml parser, 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 path
  • boring doctor correctly reports docker present, dbx present, devcontainer missing