Skip to content

Opt-in Docker daemon access for approved repos - #37

Closed
bfulton wants to merge 13 commits into
mainfrom
feat/docker-access
Closed

Opt-in Docker daemon access for approved repos#37
bfulton wants to merge 13 commits into
mainfrom
feat/docker-access

Conversation

@bfulton

@bfulton bfulton commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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-outbound grant, ~/.docker is denied as a credential store, and the one mechanism that could express it — SocketsPolicy — reached localmost test only, was ignored by the runner, and accepted arbitrary socket paths.

What this adds

shared:
  docker: socket        # off (default) | socket | contexts | credentials
Level Adds
off (absent) nothing — current behaviour
socket the resolved daemon socket, plus DOCKER_HOST in the job env
contexts + ~/.docker/contexts
credentials + ~/.docker/config.json

Cumulative, and nothing under ~/.docker is opened beyond the paths a level names. Declared in shared: only — the sandbox profile is built before the workflow is known, which is already why per-workflow filesystem: 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 ~/.ssh this profile explicitly denies — and reach the network without going through the policy allowlist. docker: socket is closer in effect to level: permissive plus 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 as level:, 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:

  • Real seatbelt, real socket (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_HOST injected, daemon reached, container ran, config.json denied — and the workflow fails at the daemon check when docker: socket is removed.
  • CI, both platforms (docker.yaml): one composite action run from ubuntu-latest (Docker native, nothing sandboxed) and from the self-hosted runner (where the grant is the only reason it works).

This repo declares docker: socket itself, so it exercises the key it added.

Removed

shared.sockets.allow is gone. It worked under localmost 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 and localmostrc.md never documented it. It is rejected with an error naming docker:, and --updaterc no longer writes it, though it still reports sockets a run reached.

Notes for review

  • mergePolicies returns a fresh object naming each field, so the level had to be carried through explicitly or localmost test would have silently dropped it while the runner applied it.
  • The docker level joins the policy stamp: a worker spawned under one level must not claim a job approved under another, since the grant is baked into the profile at spawn.
  • On macOS /var/run/docker.sock is a symlink into the denied ~/.docker, so even socket punches 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

bfulton and others added 13 commits September 4, 2026 16:49
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
Copilot AI lite review requested due to automatic review settings September 5, 2026 00:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.docker access levels (off | socket | contexts | credentials) with endpoint resolution and grant computation centralized in src/shared/docker-access.ts.
  • Threads Docker grants through runner spawning and test-mode sandbox profile generation, including injecting DOCKER_HOST for granted jobs.
  • Removes the legacy shared.sockets.allow key, 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}
Comment on lines +156 to +170
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}"))`);
}
Comment on lines +608 to 610


});
Comment on lines 1110 to +1114
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(),
@bfulton bfulton closed this Sep 5, 2026
@bfulton

bfulton commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Folded into #35 — the Docker access work is on feat/cli-targets alongside the CLI target management and the App permission docs, so it all reviews and merges as one change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants