feat(security): run a thread unattended inside a hardened container - #2348
Conversation
|
Readiness review: retaining this as a prototype draft. Its own product gate still lists unproven real-provider execution, no deferred-action approval/replay surface, and no startup orphan reconciliation/TTL. The scripted container test and UI shots establish useful plumbing, but do not discharge those security/lifecycle requirements; I am not merging unattended execution on that evidence alone. |
|
Additional review findings against current head 5e2abcd, beyond the previously noted readiness gaps:
These are source-review findings, not newly executed reproductions. No code changes were made during this follow-up review. |
|
All four findings are fixed in 4bfd03c, each with the coverage the review asked for. 1. Snapshot the thread checkout. The service now resolves the checkout through 2. No successful completion when retrieval or teardown fails. 3. Image invalidation. Reuse is keyed to a fingerprint of the guest bundle, the Dockerfile, the entrypoint, the worker uid and the sandbox-runtime version, written as a 4. Independently bounded deadline. Validation: These are fixes to the findings only — the readiness gaps you listed earlier (real-provider execution, deferred-action approval/replay, orphan reconciliation/TTL) are unchanged and still recorded as not-in-this-PR in Generated by Claude Code |
|
Followed up on What I verified. The new
So the happy path still judges clean, and the two failure shapes are now caught. The entrypoint is One ordering problem, fixed in
as the failure, with the leak demoted to a warning underneath. A container left behind is a chore; a credential that escaped is an incident. The canary verdict now sits above the cleanup branch (still in What I could not check. The full Docker integration test cannot run in this session: the network policy blocks Generated by Claude Code |
|
Current-head validation is green and current with |
d0e9813 to
cf22cbb
Compare
cf22cbb to
3ad842a
Compare
Prototype for docs/plans/thread-in-container.md: the product's headless agent loop runs inside a disposable local Docker container with no user prompts. - Contained-effect gate branch: when an unattended run is armed and the runtime attests to container containment, the shell gate allows in-guest effects, defers outward effects (git push, GitHub writes, publishes) under the new prompt cause `shell-outward-effect`, and refuses host escapes. Harm-gate hard denies stay hard. - Runtime containment declaration from a host-written attestation; refused when short of the bar (unprivileged uid, read-only rootfs, cap-drop ALL, no-new-privileges, brokered-or-no network, no foreign mounts). - Unattended-run ledger beside Guarded YOLO, mutually exclusive both ways; arming begins deferral mode and requires budgets. - Container worker image and entry; host runner with git-bundle carry-in and carry-out to refs/copse/runs/<id>, --network none plus a per-origin unix-socket egress broker, secret canary, idempotent teardown, a managed container sweep, and a JSON review record. `pnpm run thread:container`. - Unit tests for classification, attestation, the ledger, the gate matrix, Docker argv and the bundle round trip; an opt-in end-to-end test against a real daemon with a scripted OpenAI-compatible model behind the broker. - Plan document, plans index row, and a cross-reference from unattended-runs.md recording where this diverges from its Decisions 1 and 3. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Move the container runner into the main process and let the UI trigger it. - The runner, egress broker and guest worker live under src/main/services/container-runtime/; the worker is a standalone main bundle (dist/main/thread-container-worker.cjs) with node-pty stubbed and only the sandbox runtime external, which the image context stages by copying the package from the app's own node_modules (no npm at run time). The Dockerfile and entrypoint are embedded strings. The CLI wrapper now bundles its entry the way the autonomy runner does. - resolveContainerProvider maps a product model id to the guest's provider (OpenAI-compatible URL for local servers, OpenAI, OpenRouter and extra providers; the product's own resolver in the guest for Anthropic), the one egress origin, and the key, which travels as a run-scoped environment variable and is blanked once the guest holds it. - ContainerRunService owns one run per thread, refuses SSH projects and unresolvable models before Docker, records the arming as a mode-arming decision, and pushes ContainerRunProgress snapshots over container:* IPC; preload and the demo API expose api.container. - The composer footer gains "Run unattended in a container…": one dialog that is the arming form (draft prefilled, budgets, model and reachable origin) before a run and the status/review view (phase, log tail, deferrals, commits and their ref, canary, teardown) during and after; a banner over the composer mirrors the phase and a toast announces the end. - Visual evidence: browser-tier spec over a seeded finished run and an Electron spec through real IPC, with screenshots; unit tests for the provider plan and the service; the Docker integration test still passes with the moved runner. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Four findings from the readiness review of the container run, each with the
test the review asked for.
**Snapshot the thread's checkout, not the project root.** A thread in an
isolated worktree has its own branch and its own uncommitted edits, so the
service resolved the wrong tree. It now resolves the checkout through
`resolveThreadExecutionContext` — the cold resolver the supervisor already
injects — refuses a root git cannot snapshot with a readable reason instead of
a raw `git rev-parse` failure mid-run, and never falls back to the project root
when a worktree is broken. `start` claims the thread's slot before its first
await so two clicks cannot start two containers, and releases it when
resolution fails. The record and the dialog name which checkout ran.
**Never report a clean finish when retrieval or cleanup failed.** The record
now carries `carryOut { expected, ref, error }` and `cleanupError` instead of a
bare ref, and `judgeRun` decides the phase: commits that were produced but
could not be fetched, a container that would not stop or reap, and a leaked
secret canary keep a run out of `finished` and surface as the failure reason or
a warning. The banner no longer says commits are back when no ref was fetched.
**Key image reuse to the worker build.** `copse-worker:local` survives app
upgrades, so tag existence alone kept running the previous guest — including
its permission behaviour. The image is labelled with a fingerprint of the guest
bundle, the Dockerfile, the entrypoint, the uid and the sandbox-runtime
version, and is rebuilt when the shipped worker differs.
**Bound the wall-clock deadline independently.** `docker stop` failing or
hanging while `docker wait` stayed open could leave a run pending forever,
never reaching its cleanup block. The stop has its own timeout and a bounded
grace period settles the wait either way; the abandoned wait is killed and the
failure is reported rather than swallowed, with `docker rm --force` in teardown
as the next line of defence. Both Docker calls are injectable so a failed and a
hung stop are covered by unit tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
The plan quoted `container:runThread` / `container:runChanged`; the merge renamed both to the kebab-case wire names the frozen protocol derives from the facade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
`judgeRun` pushed the canary onto `warnings` and then tested the cleanup branch first, so a run that both leaked the secret canary and failed to reap its container reported "The container could not be removed" as the failure and buried the leak in the warnings list. A container left behind is a chore; a credential that escaped the run is an incident, and it should be the line the user reads first. Moves the canary verdict above the cleanup branch. The canary stays in `warnings` too, so the dialog's warning strip still shows it either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Reconstructs the adaptation the branch's `Merge origin/main` commit carried. A rebase drops that merge, so the rename it performed has to land as its own commit or the preload facade would invoke channels the main process never registers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BCcGaKPRL3fmRpLR3rK5WX
`thread-container.ts` carried its own `isRecord`, byte-identical to the one #2407 consolidated into `@shared/unknown-value.ts`. #2419 then made the type predicate inventory shrink-only, so the duplicate registered as a new unlisted predicate and failed `check` on the merge with main. The file already imports through `@shared`, so the alias resolves on every path this module is built for, the guest bundle included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
3ad842a to
59e0bc0
Compare
The composer footer gained "Run unattended in a container…", and two e2e specs pin the overflow roster: `footer-overflow-bounds` by count and `developer-mode` by exact labels, in both its default and developer-mode cases. Only the unit-level roster in `input-bar.test.ts` was updated when the action landed, so these three assertions still described a six-item menu. They went unnoticed because the e2e tier never ran on this PR: the `e2e` job is gated on `draft == false` or the `ci-full` label, and this PR is a draft. Labelling it `ci-full` dispatched the shards and they failed on exactly these. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…up continue from the card alone Two more from the follow-ups tried after the fixes: To the thread: the turn had history now, but Codex asked what "try again" meant. An ACP agent's turn is replayed the thread as text, and the run's turn in the history was a tool call with no assistant text. The turn recorded for a settled run now carries the review record as the assistant's own message as well as the tool result. To the container: still refused. The continuation could answer only from the run the main process held or its record on disk, and a record the sweep removed, or one an earlier build wrote under the guest's own thread id, left it nothing. The thread's card says what the run was asked and reported and where its commits are, so the follow-up sends that along and the continuation falls back to it when the record is unusable; the card's arguments gain the agent's last words for it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…file A real run's install died with EROFS on a file copy into the checkout, the worker went on regardless, and the sign-in restore then died with ENOENT on a mkdir under the home; the host could not remove the container either. The volume had gone from under the container (the disk Docker keeps its volumes on full, or the mount lost), so every path under /workspace fell through to the read-only rootfs, and neither error named that. The worker now probes the checkout's volume after a failed install, refuses to go on when it no longer takes writes, and says the same beside any filesystem error its fatal handler sees. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
chore(e2e): review screenshots for #2348
…default The feature is reachable from every thread's composer and needs Docker, copies a sign-in into the guest when asked, and has fixed something on each real run so far. It joins the other opt-in features under Settings › Experimental: a containerRunsEnabled setting, off by default, with a toggle that says what a run does and what it carries. The run service refuses to start anything while it is off, whatever the renderer asks; the composer's menu entry and its target picker appear only once it is on. The demo scenarios and the e2e spec that exercise the feature turn it on in their seeds. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
Main bumped the same three security pins (xmldom under a 0.8 range, the merge keeps that spelling), refreshed reference screenshots this branch had also re-rendered (main's are taken; the container feature is off by default now, so the footer menus it re-rendered match main's), and moved Electron to 44.1. Main's new design lint keeps the accent rail for nesting alone, so the run dialog's warning box becomes a plate on the danger hue, as the callout surfaces in styles/global/base.css prescribe. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
…rted The experimental setting hides the "Run unattended in a container…" entry by default, so the developer-mode and footer-overflow-bounds specs seed it on, matching container-run-dialog. The developer-mode helpers take an optional settings bag for this. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv
refactor(container): share validated run and attestation contracts
refactor(container): report lifecycle phases as structured events
refactor(agent): share transcript content and tool updates
refactor(container): track external runs with the task supervisor
Summary
Run a Copse thread unattended inside a disposable, hardened local Docker container with no user prompts, per the plan
docs/plans/thread-in-container.md(the loop runs in the guest, not on the desktop), startable from the app, able to run key-capable ACP agents (Claude, Codex, Gemini) in the guest under a vendor API key — or, for Codex and Gemini, on the user's desktop sign-in when they opt in per run — and, since the later commits, a turn on the thread that launched it, with follow-ups that continue the run.Experimental and off by default. Settings › Experimental › Unattended container runs (
containerRunsEnabled) turns it on. The run service refuses to start anything while it is off, whatever the renderer asks, and the composer's menu entry and target picker exist only once it is on. Needs Docker.The runtime and the gate
ensureShellCommandPermittedtakes one new branch when the run is an armed unattended run and the runtime declaredcontainer: the harm gate still runs (hard denies stay hard), thendecideContainedShellEffect(@copse/shell-guard/container-effects.ts) refuses host escapes, defers outward effects (git push, GitHub writes, publishes, HTTP writes, cloud CLIs) under the new prompt causeshell-outward-effect, and allows everything else.security/runtime-containment.ts): the host writes an attestation of the hardening it applied; the guest declares from it and refuses anything short of the bar (unprivileged uid, read-only rootfs, cap-drop ALL, no-new-privileges, no unmediated network, no host mount outside/run/copse).security/unattended-run.ts): session-only, per-thread, beside Guarded YOLO and mutually exclusive with it; arming begins deferral mode and requires budgets.runHeadlessAgentin the guest with a fail-closed approval handler that counts prompts (must be zero); workspace carried in as a git bundle and carried out as commits underrefs/copse/runs/<id>. The container is the sandbox (no nested bubblewrap).docker runargv (the same source as the attestation),--network noneplus one broker socket and a token-gated HTTP CONNECT proxy in the guest with a pattern allowlist (host:port,*.suffix:port), a connection log, a secret canary check, idempotent teardown, a managed-container sweep, and a JSON review record per run. A pnpm store volume is shared across installing runs.From the app
resolveContainerProvideradapts the desktop's own provider resolution for the guest:describeProvider→ProviderDescription(protocol, endpoint, tuned parameters, OpenRouter privacy and OpenAI transport settings; keys apart) travels inrun.jsonwith the desktop's context window, and the guest builds the same client from it. A loopback endpoint is renamed to a broker alias; LM Studio's WebSocket transport becomes its OpenAI-compatible endpoint.ContainerRunServiceowns one run per thread, refuses SSH projects before touching Docker, records the arming as a decision, and pushesContainerRunProgresssnapshots overcontainer:*channels; preload and the demo API exposeapi.container.container_runtool call whose subagent session holds the guest's transcript (folded with the desktop's own text planner) and the review record; the dialog closes on start and a banner shows the phase. The composer gets a target picker: to container continues the run (carrying in its commits, or a fresh snapshot when it made none, with what it was asked and reported prefixed to the prompt); to thread talks to the desktop model, which is told the run's outcome through the thread's model history. Apply N commits to this checkout cherry-picks the run's commits onto HEAD.Changes to shared paths (the merge risk)
buildProvidernow goes throughdescribeProvider+buildProviderFromDescription: the same branches split in two, covered byprovider-selection.test.ts.git-snapshot.ts). A clean tree's backup ref now points at HEAD rather than an identical fresh commit.runHeadlessAgentreturns the turn'sturnOutcome.@xmldom/xmldom0.8.15,sharp0.35.4,js-yaml4.3.2 (main carries the same).developer-mode,footer-overflow-bounds) seed the experimental setting on, since the container entry is hidden by default.Validation
pnpm run checkgreen (typecheck, lint, format, dead code, 8970 unit tests); CI green on every head since the review rounds.judgeRun, the provider description and plan, the run service (key hidden and blanked, stop before the container exists, continuation from memory, disk and the card), the egress grammar/broker/proxy over real sockets (including close during a dial), the guest transcript fold, the turn's failure and token tally, the thread history record, the composer target and the experimental gate.COPSE_THREAD_CONTAINER_E2E=1 pnpm test -- thread-container.integration,acp-container.integration): a scripted model and a scripted ACP agent behind the broker; not run in CI.Not in this PR (recorded in the plan)
Attaching from the desktop (ACP role + lease), approving a deferral from the dialog, the macOS broker variant.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NzkY3kYdcQvYk8EH3EW8uv