Opt-in Docker daemon access for approved repos - #37
Conversation
A job that needs Docker cannot reach the daemon at any policy level and has no way to ask: the socket gets no network-outbound grant, ~/.docker is denied as a credential store, and the SocketsPolicy mechanism that would express it is orphaned in the test-mode profile builder. Design: a `docker:` key taking off (default) / socket / contexts / credentials, each level naming what it opens so the grant is legible in an approval diff. One shared resolver drives both sandbox builders. Records the fact that shapes the whole feature: a job that can reach the daemon is not sandboxed. Containers are not subject to the profile, so a bind mount reaches host paths the profile denies. The design surfaces that at approval time rather than hiding it behind a mechanism. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Ten TDD tasks: level type and schema validation, endpoint resolution, grant computation, emission into both sandbox profile builders, the policy-to-worker plumbing including the stamp, DOCKER_HOST injection, approval diff prominence, docs, and an end-to-end pass. Also corrects the design doc on two points found while reading the plumbing: - docker: must be a shared-only key. The runner's profile is built before the workflow is known, which is already why per-workflow filesystem sections are refused; a workflow-level docker value could only be honoured by localmost test, recreating the divergence the shared resolver exists to prevent. - shared.sockets.allow is not merely orphaned. It is a validated key that already reaches the localmost test profile with arbitrary paths while the runner ignores it, so it works locally and does nothing on the runner. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The rules go after the unconditional deny block: the Docker Desktop socket lives inside ~/.docker, which is denied wholesale, and seatbelt takes the last matching rule. Each grant is a single literal, so config.json stays denied below the credentials level. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The level joins the policy stamp: a worker spawned under one level must not claim a job approved under another, because the grant is baked into the profile at spawn and cannot change afterwards. A declared level with no reachable daemon logs a warning and runs without the grant - the declaration is a permission, not a requirement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
shared.sockets.allow reached the localmost test profile with arbitrary socket paths and was ignored by the runner, so it worked locally and did nothing on a real job - and it let a repository name any socket on the machine, which is what the closed docker enum exists to prevent. No approved policy declares it and localmostrc.md never documented it, so it is removed rather than deprecated, and rejected with an error naming docker: so a policy that used it fails loudly. --updaterc no longer writes the key. It still reports sockets a run reached, and points at docker: when one of them is the daemon. Also carries the shared docker level through mergePolicies, which drops any field it does not name - the localmost test path reads the merged policy, so the level would have applied on the runner and not locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
existsSync follows symlinks, so a dangling /var/run/docker.sock - what a stopped Docker Desktop leaves - reports false and is skipped before realpath is reached. Verified against the real machine. The realpath guard still covers the socket disappearing between the two calls. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The unit tests assert which rules the profile contains; none of them showed that those rules let a process reach the daemon, which is the only thing the feature is for. - docker-access.sandbox.test.ts runs real seatbelt against the real socket, both ways round: reachable with the grant, refused without. The negative case is what makes the positive one mean anything. macOS only, skipped with a stated reason when no daemon answers. - This repo now declares docker: socket, so its own policy exercises the key it added. - A composite action reaches the daemon, runs a container, and asserts ~/.docker/config.json stays denied at socket level. Two jobs run it: ubuntu-latest, where Docker is native and nothing is sandboxed, and the self-hosted runner, where the grant is the only reason it works. Verified under localmost test: DOCKER_HOST injected, daemon reached, container ran, config.json denied - and the whole workflow fails at the daemon check when docker: socket is removed from .localmostrc. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
There was a problem hiding this comment.
🟡 Changes recommended
The runner seatbelt profile currently interpolates docker socket allow rules before a later (deny network*) block, which likely overrides Docker’s network-outbound allow and breaks daemon reachability.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds an opt-in, policy-approved mechanism for repositories to reach the Docker daemon from within the localmost sandbox, aligning behavior between localmost test and the background runner while removing the previous arbitrary-socket escape hatch.
Changes:
- Introduces
shared.dockeraccess levels (off | socket | contexts | credentials) with endpoint resolution and grant computation centralized insrc/shared/docker-access.ts. - Threads Docker grants through runner spawning and test-mode sandbox profile generation, including injecting
DOCKER_HOSTfor granted jobs. - Removes the legacy
shared.sockets.allowkey, updates approval diffing/serialization, and adds documentation + CI coverage for the new capability.
File summaries
| File | Description |
|---|---|
| src/shared/step-executor.ts | Injects DOCKER_HOST in CLI test mode and passes resolved Docker endpoint into profile generation. |
| src/shared/sandbox-profile.ts | Replaces sockets handling with docker-level-driven grants in the test-mode sandbox profile. |
| src/shared/sandbox-profile.test.ts | Adds assertions for docker grant emission in the test-mode profile. |
| src/shared/localmostrc.ts | Validates shared.docker, rejects sockets, carries docker through policy merging, serialization, and approval diffs. |
| src/shared/localmostrc.test.ts | Adds tests for docker schema validation, sockets rejection, merge behavior, and diff/round-trip behavior. |
| src/shared/docker-access.ts | New module: docker access levels, endpoint resolution, and sandbox grant computation. |
| src/shared/docker-access.test.ts | Unit tests for levels, endpoint resolution, and grants. |
| src/shared/docker-access.sandbox.test.ts | macOS-only integration test that exercises real seatbelt behavior against a real daemon socket. |
| src/main/runner-manager.ts | Resolves endpoint outside the sandbox, computes grants, injects env, includes docker in stamp, and warns when unavailable. |
| src/main/runner-manager.test.ts | Updates policy fixtures and adds coverage for docker-related stamping and warning behavior. |
| src/main/process-sandbox.ts | Extends runner seatbelt profile generator to accept and emit docker grants. |
| src/main/process-sandbox.test.ts | Adds assertions for docker grant emission and rule ordering. |
| src/main/index.ts | Threads docker through the runtime repo policy, defaulting to off for unapproved repos. |
| src/cli/test.ts | Stops writing sockets allowlists and improves reporting guidance toward shared.docker. |
| SECURITY.md | Documents the security implications of Docker access (jobs effectively not sandboxed). |
| README.md | Adds mention of opt-in Docker daemon access. |
| docs/superpowers/plans/2026-09-04-docker-access.md | Adds an implementation plan document for the feature. |
| docs/roadmap/localmostrc.md | Documents the docker: key semantics, levels, and boundary behavior. |
| docs/roadmap/docker-access.md | New design doc describing the feature, tradeoffs, and constraints. |
| CHANGELOG.md | Records the new feature and removal of sockets:. |
| .localmostrc | Declares docker: socket for this repo to exercise the feature. |
| .github/workflows/docker.yaml | Adds CI workflow that validates Docker access on both hosted and self-hosted runners. |
| .github/actions/docker-access/action.yml | Adds composite action used by CI to verify daemon reachability and policy boundary. |
Review details
- Files reviewed: 23/23 changed files
- Comments generated: 4
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| (literal "${homeDir}/.cargo/credentials") | ||
| (literal "${homeDir}/.cargo/credentials.toml") | ||
| (literal "${homeDir}/.nuget/NuGet/NuGet.Config")) | ||
| ${dockerRules} |
| const dockerRules = ((grants?: DockerGrants): string => { | ||
| if (!grants) return ''; | ||
| const lines: string[] = []; | ||
|
|
||
| for (const socket of grants.socketLiterals) { | ||
| lines.push(`(allow network-outbound (literal "${socket}"))`); | ||
| lines.push(`(allow file-read* (literal "${socket}"))`); | ||
| lines.push(`(allow file-write* (literal "${socket}"))`); | ||
| } | ||
| for (const file of grants.readLiterals) { | ||
| lines.push(`(allow file-read* (literal "${file}"))`); | ||
| } | ||
| for (const dir of grants.readSubpaths) { | ||
| lines.push(`(allow file-read* (subpath "${dir}"))`); | ||
| } |
|
|
||
|
|
||
| }); |
| logFile: options.sandboxLogFile, | ||
| // Resolved out here, outside the sandbox, exactly as the runner does | ||
| // it - so localmost test and the runner grant the same socket. | ||
| dockerEndpoint: resolveDockerEndpoint(), | ||
| homeDir: os.homedir(), |
|
Folded into #35 — the Docker access work is on |
A job that needs Docker could not reach the daemon under the sandbox at any policy level, and had no way to ask. The socket got no
network-outboundgrant,~/.dockeris denied as a credential store, and the one mechanism that could express it —SocketsPolicy— reachedlocalmost testonly, was ignored by the runner, and accepted arbitrary socket paths.What this adds
off(absent)socketDOCKER_HOSTin the job envcontexts~/.docker/contextscredentials~/.docker/config.jsonCumulative, and nothing under
~/.dockeris opened beyond the paths a level names. Declared inshared:only — the sandbox profile is built before the workflow is known, which is already why per-workflowfilesystem:sections are not applied.Design doc:
docs/roadmap/docker-access.md. Plan:docs/superpowers/plans/2026-09-04-docker-access.md.What it gives up, stated plainly
A job with Docker access is not sandboxed. Containers are not subject to the seatbelt profile, so a job can bind-mount host paths and read or write them — including the
~/.sshthis profile explicitly denies — and reach the network without going through the policy allowlist.docker: socketis closer in effect tolevel: permissiveplus unrestricted egress than to adding a host to an allowlist.The repository policy is the only gate, so the approval diff is the whole of the access control.
docker:therefore diffs with the same prominence aslevel:, and a change to it holds the job until re-approved.Verification
The unit tests assert which rules a profile contains. That is not evidence the rules work, so:
docker-access.sandbox.test.ts): reachable with the grant, refused without. The negative case is what makes the positive one mean anything. macOS-only, and it states its reason when skipped.localmost test:DOCKER_HOSTinjected, daemon reached, container ran,config.jsondenied — and the workflow fails at the daemon check whendocker: socketis removed.docker.yaml): one composite action run fromubuntu-latest(Docker native, nothing sandboxed) and from the self-hosted runner (where the grant is the only reason it works).This repo declares
docker: socketitself, so it exercises the key it added.Removed
shared.sockets.allowis gone. It worked underlocalmost test, did nothing on the runner, and let a repository name any socket on the machine — an SSH or GPG agent included. No approved policy declared it andlocalmostrc.mdnever documented it. It is rejected with an error namingdocker:, and--updatercno longer writes it, though it still reports sockets a run reached.Notes for review
mergePoliciesreturns a fresh object naming each field, so the level had to be carried through explicitly orlocalmost testwould have silently dropped it while the runner applied it./var/run/docker.sockis a symlink into the denied~/.docker, so evensocketpunches a single-literal hole through that deny, emitted after it — seatbelt takes the last matching rule, and there is a test pinning that ordering.🤖 Generated with Claude Code
https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo