From d61892d52126517dff4c8a3eb97db9e2be44bf2a Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 16:49:41 -0400 Subject: [PATCH 01/13] Design doc: opt-in Docker daemon access for approved repos 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- README.md | 1 + docs/roadmap/docker-access.md | 202 ++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 docs/roadmap/docker-access.md diff --git a/README.md b/README.md index 47decef..af704e7 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,7 @@ Future feature ideas: - **Fail a blocked job visibly** - a job refused by the filter is cancelled through the GitHub API before any worker starts, so it appears as cancelled rather than failing with a message explaining why. - **Roll discovery output up further** - `--updaterc` now drops paths already covered by a listed ancestor, which removes the bulk of the redundancy. It still records content-addressed cache paths (npm's `_cacache/content-v2/sha512/...`) verbatim, which differ per machine and per dependency change; those want rolling up to their cache directory. +- **[Docker access](docs/roadmap/docker-access.md)** - an opt-in `.localmostrc` key letting an approved repo reach the Docker daemon, at a declared level, since container workflows currently have no way to ask for the socket. - **Approve policies in the app** - approval is CLI-only today (`localmost policy diff`, `localmost policy approve`). The app refuses the job and logs the diff, but there is no UI to review and accept it, and no audit log of approvals. - **Show a diff when `--updaterc` rewrites a policy** - it writes directly, with no diff and no confirmation, so a discovery run can widen a checked-in policy without the change being obvious. - **Homebrew formula** - `npx localmost` works; `brew install localmost` does not exist. diff --git a/docs/roadmap/docker-access.md b/docs/roadmap/docker-access.md new file mode 100644 index 0000000..a403998 --- /dev/null +++ b/docs/roadmap/docker-access.md @@ -0,0 +1,202 @@ +# Docker Access — Opt-In Daemon Reachability + +A `.localmostrc` key that lets an approved repository reach the Docker daemon, at +a declared level, from inside the runner sandbox. + +> **Status:** designed, not implemented. This document describes the intended +> behaviour and the decisions behind it. + +## Problem + +A job that needs Docker — integration tests against a containerised service, a +container build — cannot reach the daemon under the sandbox at any policy level, +and there is no way to ask for it: + +1. **The socket is unreachable.** The job profile allows TCP to `localhost:*` but + grants no `network-outbound` to any unix socket, so a connection to + `/var/run/docker.sock` is denied. +2. **The Docker CLI cannot read its own configuration.** `~/.docker` sits on the + unconditional deny-read list in `process-sandbox.ts`, alongside `~/.ssh`, + `~/.aws`, `~/.gnupg` and `Library/Keychains`, so the CLI cannot resolve its + endpoint or read `config.json`. +3. **The mechanism that exists is orphaned.** `SocketsPolicy` in + `src/shared/sandbox-profile.ts` emits `network-bind`, `network-outbound` and + `file-write*` for a declared socket path, and names `/var/run/docker.sock` as + its example — but nothing outside tests constructs it, and the runner profile + is built by `process-sandbox.ts`, a different code path entirely. + +The result is silent degradation: container-based workflows find the daemon +unreachable, tests that need it skip, and the operator has no way to opt in even +on their own machine. + +## Solution + +A repository declares the access it needs, and localmost honours it once the +policy is approved: + +```yaml +# .localmostrc +version: 1 +level: strict + +shared: + docker: socket # off (default) | socket | contexts | credentials +``` + +Each level names the thing it opens, so the level is legible in an approval diff +rather than requiring a trip to the docs: + +| Level | What it grants | +|---|---| +| `off` (or absent) | Nothing. Current behaviour. | +| `socket` | The resolved daemon socket: `network-outbound`, `file-read*` and `file-write*` on that one literal path, plus `DOCKER_HOST=unix://` in the job environment. | +| `contexts` | The above, plus `file-read*` on `~/.docker/contexts`, so the job can resolve and switch contexts itself. | +| `credentials` | The above, plus `file-read*` on `~/.docker/config.json`, so pulls from private registries can authenticate. | + +Levels are cumulative, and nothing under `~/.docker` is opened beyond the paths +named above — no level grants the directory itself. + +## What This Actually Grants + +**A job with Docker access is not sandboxed.** This is the central fact about the +feature and belongs anywhere it is documented. + +The network and filesystem allowlists widen what the sandboxed process may do, +and the seatbelt profile still contains it. Docker access is different in kind: +the container is not subject to the profile at all. A job that can reach the +daemon can + +- bind-mount host paths into a container and read or write them — + `docker run -v /Users/you:/host` reaches the `~/.ssh` this profile explicitly + denies, because Docker Desktop shares `/Users` by default; +- make arbitrary outbound network connections from inside a container, bypassing + the policy's network allowlist entirely. + +So `docker: socket` is closer in effect to `level: permissive` plus unrestricted +egress than it is to adding a host to the network allowlist. The design does not +try to hide that behind a mechanism; it makes the level visible at approval time +and states the consequence in the docs. + +## Key Design Decisions + +### The repository policy is the only gate + +An approved `.localmostrc` is sufficient authority — there is no second, +machine-level switch. This matches how network and filesystem allowances already +work, and keeps one mechanism instead of two. + +The consequence is that policy approval carries more weight than it did: it is +the only thing between a repository and host file access. That places the burden +on the approval surface, below. + +### A closed enum, not a socket list + +The key takes one of four known values. It does not take a path, and there is no +general `sockets:` list. + +A path-taking form would let a repository name any unix socket on the machine — +the SSH agent, `~/.gnupg/S.gpg-agent`, a database socket — which is a much larger +capability than "can use Docker" and one that is hard to review in a diff. Since +the repository is the only gate, the narrowest expressible request is the right +one. + +Alternative runtimes are still supported, because localmost resolves the endpoint +from the operator's own Docker configuration rather than from anything the +repository says. Colima and Podman work without the repository naming a path. + +`docker: true` and `docker: false` are rejected with an error naming the four +levels. In a key that governs a sandbox escape, guessing which level a truthy +value meant is worse than failing. + +### The socket needs a hole in the deny, even at `socket` + +On macOS with Docker Desktop, `/var/run/docker.sock` is a symlink to +`~/.docker/run/docker.sock` — inside the directory that is denied as a credential +store. Seatbelt matches on the resolved path, so the grant has to name that +literal. + +The rules are therefore emitted after the deny block in `process-sandbox.ts`, so +the specific literal wins over the subtree deny, and the grant is a single +literal path rather than a subtree. `~/.docker/config.json` remains denied at +`socket` and `contexts`, and that is worth an explicit test rather than an +assumption about rule ordering. + +### One resolver, both sandboxes + +A new `src/shared/docker-access.ts` owns the level type, endpoint resolution and +the grant computation. Both `process-sandbox.ts` (runner jobs) and +`sandbox-profile.ts` (`localmost test`) call it. + +`localmost test` exists to predict what the runner will do. Two Docker code paths +would break that prediction for exactly the workflows most likely to behave +differently between the two — and the orphaned `SocketsPolicy` is what a second, +unshared path looks like after a while. That existing mechanism becomes the +internal primitive, driven only by `docker:`, rather than being exposed as its +own key. + +### Endpoint resolution happens outside the sandbox + +localmost resolves the socket in the app, before the profile is built: +`DOCKER_HOST` if the operator has set one, then `/var/run/docker.sock` followed +through its symlink, then Docker Desktop's per-user path. + +The job never has to discover the endpoint, which is why `socket` can inject +`DOCKER_HOST` and keep `~/.docker` closed. + +## Edge Cases + +**Declared but no daemon.** The socket does not resolve, or resolves to a path +that does not exist. Warn in the job log and run without the grant. The key is a +permission, not a requirement, and container tests that check for a reachable +daemon already skip. The job must not silently appear to have had access. + +**A dangling socket symlink.** `/var/run/docker.sock` exists as a symlink even +when Docker Desktop is stopped, so the path being present does not mean the +daemon is running. Resolution follows the link and checks the target. + +**A context pointing somewhere unexpected.** At `contexts`, a job can select a +context whose endpoint is a socket that was not granted. The connection is denied +by the profile and fails at connect — a clean failure, not a hang. Documented +rather than prevented; granting whatever a context names would defeat the closed +enum. + +**Per-workflow overrides.** `docker:` is settable in a `workflows:` block like +other keys, and the workflow value wins. A repository that needs Docker in one +job does not have to grant it to all of them. + +**Policy changes require re-approval.** Adding or raising `docker:` changes the +policy, so the existing approval flow holds the job and cancels the run until the +new policy is approved. No separate mechanism is needed — but see below. + +## Approval Surface + +`diffConfigs` already treats a change to `level:` as the largest change a policy +can make. A change to `docker:` gets equal prominence, so `docker: off → +credentials` cannot slide past in a diff that is otherwise routine. + +This is load-bearing rather than cosmetic: with the repository as the only gate, +the diff an operator reads at approval time is the whole of the access control. + +## Testing + +- Profile generation, per level, for **both** builders: the socket literal is + allowed; at `socket` and `contexts`, `~/.docker/config.json` is still denied. + The rule-ordering behaviour is asserted, not assumed. +- Endpoint resolution: `DOCKER_HOST` set, symlink followed, dangling symlink, + nothing found. +- Schema validation: the four levels accepted, `true`/`false` rejected with a + message naming them, workflow-level override applied. +- Diff output: a `docker:` change is surfaced with level-change prominence. +- An end-to-end run on a repository that needs the daemon, since profile + assertions cannot prove the daemon is actually reachable. + +## Documentation + +The capability is documented where its consequences are, not only where its +syntax is: + +- `docs/roadmap/localmostrc.md` — the key, the levels, the schema. +- `README.md` — the policy section. +- `SECURITY.md` — plainly, that a job at any level from `socket` upward can read + and write host paths through a bind mount, outside the sandbox. +- `CHANGELOG.md` — a new opt-in capability, default off. From 7ebdef460bd16e086aaa8295cbfbaf9fc8a9ddfa Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 17:40:31 -0400 Subject: [PATCH 02/13] Implementation plan for docker access 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- docs/roadmap/docker-access.md | 19 +- .../plans/2026-09-04-docker-access.md | 1229 +++++++++++++++++ 2 files changed, 1242 insertions(+), 6 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-04-docker-access.md diff --git a/docs/roadmap/docker-access.md b/docs/roadmap/docker-access.md index a403998..d8c4036 100644 --- a/docs/roadmap/docker-access.md +++ b/docs/roadmap/docker-access.md @@ -19,11 +19,14 @@ and there is no way to ask for it: unconditional deny-read list in `process-sandbox.ts`, alongside `~/.ssh`, `~/.aws`, `~/.gnupg` and `Library/Keychains`, so the CLI cannot resolve its endpoint or read `config.json`. -3. **The mechanism that exists is orphaned.** `SocketsPolicy` in +3. **The mechanism that exists is half-wired.** `SocketsPolicy` in `src/shared/sandbox-profile.ts` emits `network-bind`, `network-outbound` and `file-write*` for a declared socket path, and names `/var/run/docker.sock` as - its example — but nothing outside tests constructs it, and the runner profile - is built by `process-sandbox.ts`, a different code path entirely. + its example. `shared.sockets.allow` is a validated key that already reaches + the `localmost test` profile (`src/cli/test.ts`), but the runner profile is + built by `process-sandbox.ts`, a different code path that ignores it. So the + one existing way to ask for a socket works locally, does nothing on the + runner, and accepts arbitrary paths. The result is silent degradation: container-based workflows find the daemon unreachable, tests that need it skip, and the operator has no way to opt in even @@ -160,9 +163,13 @@ by the profile and fails at connect — a clean failure, not a hang. Documented rather than prevented; granting whatever a context names would defeat the closed enum. -**Per-workflow overrides.** `docker:` is settable in a `workflows:` block like -other keys, and the workflow value wins. A repository that needs Docker in one -job does not have to grant it to all of them. +**Shared section only.** `docker:` is read from `shared:`, not from a +`workflows:` block. The runner's sandbox profile is built before the workflow is +known, which is already why per-workflow `filesystem:` sections are not applied +(`src/main/index.ts`). Docker access changes the same profile, so a workflow-level +value could only be honoured by `localmost test` — reintroducing exactly the +runner/test divergence this design set out to avoid. A `docker:` key inside a +`workflows:` block is a validation error rather than a silently ignored setting. **Policy changes require re-approval.** Adding or raising `docker:` changes the policy, so the existing approval flow holds the job and cancels the run until the diff --git a/docs/superpowers/plans/2026-09-04-docker-access.md b/docs/superpowers/plans/2026-09-04-docker-access.md new file mode 100644 index 0000000..3259857 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-docker-access.md @@ -0,0 +1,1229 @@ +# Docker Access Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an approved repository reach the Docker daemon from inside the sandbox, at a level it declares in `.localmostrc`, with the same behaviour under `localmost test` and the background runner. + +**Architecture:** One new module, `src/shared/docker-access.ts`, owns the level type, endpoint resolution and grant computation. Both sandbox profile builders — `src/main/process-sandbox.ts` for runner jobs and `src/shared/sandbox-profile.ts` for `localmost test` — ask it what to emit. The level travels from the approved policy through the existing `RepoPolicyRuntime` → `RunnerManager` → `spawnSandboxed` path, and is folded into the policy stamp so a change retires stale workers. + +**Tech Stack:** TypeScript, Electron main process, Jest (`test/jest.config.js`, roots `src/`), macOS seatbelt (`sandbox-exec`) profiles, js-yaml. + +**Spec:** `docs/roadmap/docker-access.md` + +## Global Constraints + +- macOS only. No Windows or Linux branches (CLAUDE.md). +- TDD: every step writes the failing test first and watches it fail before implementing. +- Levels are exactly `off | socket | contexts | credentials`, cumulative, default `off`. +- `docker:` is read from `shared:` only. A `docker:` key inside a `workflows:` block is a validation error. +- `docker: true` / `docker: false` are validation errors naming the four levels. +- Nothing under `~/.docker` is opened beyond the exact paths a level names — never the directory. +- Docker grants are emitted **after** the `deny file-read*` block in `process-sandbox.ts` so the specific literal wins over the subtree deny. +- No new runtime dependencies. +- Every task ends green on `npm run lint`, `npm run typecheck`, `npm test`. + +--- + +### Task 1: Level type and schema validation + +**Files:** +- Create: `src/shared/docker-access.ts` +- Create: `src/shared/docker-access.test.ts` +- Modify: `src/shared/localmostrc.ts` (`SandboxPolicy` import site, `validatePolicy` ~line 204, `validateSocketsPolicy` neighbourhood ~line 272) +- Modify: `src/shared/sandbox-profile.ts:37-42` (`SandboxPolicy` interface) +- Test: `src/shared/localmostrc.test.ts` + +**Interfaces:** +- Consumes: nothing. +- Produces: `type DockerAccessLevel = 'off' | 'socket' | 'contexts' | 'credentials'`, `const DOCKER_ACCESS_LEVELS: readonly DockerAccessLevel[]`, `function isDockerAccessLevel(value: unknown): value is DockerAccessLevel`. `SandboxPolicy` gains `docker?: DockerAccessLevel`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/docker-access.test.ts +import { describe, it, expect } from '@jest/globals'; +import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; + +describe('docker access levels', () => { + it('lists the four levels in increasing order of access', () => { + expect(DOCKER_ACCESS_LEVELS).toEqual(['off', 'socket', 'contexts', 'credentials']); + }); + + it('accepts every declared level', () => { + for (const level of DOCKER_ACCESS_LEVELS) { + expect(isDockerAccessLevel(level)).toBe(true); + } + }); + + it('rejects a boolean, which is ambiguous about which level was meant', () => { + expect(isDockerAccessLevel(true)).toBe(false); + expect(isDockerAccessLevel(false)).toBe(false); + }); + + it('rejects an unknown string', () => { + expect(isDockerAccessLevel('daemon')).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts` +Expected: FAIL — `Cannot find module './docker-access'` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/shared/docker-access.ts +/** + * Docker daemon access, declared per repository in .localmostrc. + * + * A job that can reach the daemon is not sandboxed: containers are not subject + * to the seatbelt profile, so a bind mount reaches host paths the profile + * denies. See docs/roadmap/docker-access.md. + */ + +/** How much Docker surface a repository's policy opens. Cumulative. */ +export type DockerAccessLevel = 'off' | 'socket' | 'contexts' | 'credentials'; + +/** In increasing order of access. */ +export const DOCKER_ACCESS_LEVELS: readonly DockerAccessLevel[] = [ + 'off', + 'socket', + 'contexts', + 'credentials', +]; + +export function isDockerAccessLevel(value: unknown): value is DockerAccessLevel { + return typeof value === 'string' && (DOCKER_ACCESS_LEVELS as readonly string[]).includes(value); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts` +Expected: PASS (4 tests) + +- [ ] **Step 5: Write the failing schema-validation test** + +```typescript +// src/shared/localmostrc.test.ts — add to the existing describe for parseLocalmostrcContent +it('accepts a declared docker level', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: socket\n'); + expect(result.success).toBe(true); + expect(result.config?.shared?.docker).toBe('socket'); +}); + +it('rejects docker: true, which does not say which level was meant', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: true\n'); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/off, socket, contexts, credentials/); +}); + +it('rejects an unknown docker level', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: daemon\n'); + expect(result.success).toBe(false); +}); + +it('rejects docker inside a workflows block', () => { + const result = parseLocalmostrcContent( + 'version: 1\nworkflows:\n build:\n docker: socket\n' + ); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/shared/); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts -t docker` +Expected: FAIL — the first assertion gets `undefined` for `shared.docker`; the rejection cases pass validation instead of erroring. + +- [ ] **Step 7: Implement validation** + +In `src/shared/sandbox-profile.ts`, extend the policy interface (line 37): + +```typescript +import type { DockerAccessLevel } from './docker-access'; + +export interface SandboxPolicy { + network?: NetworkPolicy; + filesystem?: FilesystemPolicy; + sockets?: SocketsPolicy; + env?: EnvPolicy; + /** Docker daemon access. Read from `shared:` only - see docker-access.ts. */ + docker?: DockerAccessLevel; +} +``` + +In `src/shared/localmostrc.ts`, import the guard and add a branch to `validatePolicy` (after the `sockets` branch, ~line 229). `validatePolicy` is called for both shared and workflow policies, so it takes the path it was given and refuses the key outside `shared`: + +```typescript +import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; + + // Validate docker access level + if (p.docker !== undefined) { + if (path !== 'shared') { + errors.push({ + message: + `${path}.docker is not supported: docker access is declared in shared, ` + + 'because the sandbox profile is built before the workflow is known', + }); + } else if (!isDockerAccessLevel(p.docker)) { + errors.push({ + message: `${path}.docker must be one of: ${DOCKER_ACCESS_LEVELS.join(', ')}`, + }); + } + } +``` + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts src/shared/docker-access.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/shared/docker-access.ts src/shared/docker-access.test.ts src/shared/localmostrc.ts src/shared/localmostrc.test.ts src/shared/sandbox-profile.ts +git commit -m "Add docker access level to the policy schema" +``` + +--- + +### Task 2: Endpoint resolution + +**Files:** +- Modify: `src/shared/docker-access.ts` +- Test: `src/shared/docker-access.test.ts` + +**Interfaces:** +- Consumes: `DockerAccessLevel` from Task 1. +- Produces: `interface DockerEndpoint { socketPath: string }`, `interface DockerFsProbe { exists(p: string): boolean; realpath(p: string): string }`, `function resolveDockerEndpoint(options?: { env?: NodeJS.ProcessEnv; homeDir?: string; fs?: DockerFsProbe }): DockerEndpoint | null`. + +Resolution runs in the app, outside the sandbox, so the job never has to discover the endpoint. Order: an operator-set `DOCKER_HOST`, then `/var/run/docker.sock` followed through its symlink, then Docker Desktop's per-user path. The filesystem is injected rather than mocked so the tests state the machine shape directly. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/docker-access.test.ts +import { resolveDockerEndpoint, DockerFsProbe } from './docker-access'; + +/** A fake machine: paths that exist, and where symlinks point. */ +const probe = (paths: Record): DockerFsProbe => ({ + exists: p => p in paths, + realpath: p => { + if (!(p in paths)) throw new Error(`ENOENT: ${p}`); + return paths[p]; + }, +}); + +describe('resolveDockerEndpoint', () => { + const homeDir = '/Users/dev'; + + it('follows /var/run/docker.sock to the Docker Desktop socket it links to', () => { + const fs = probe({ + '/var/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + '/Users/dev/.docker/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + }); + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.docker/run/docker.sock', + }); + }); + + it('prefers an operator-set DOCKER_HOST', () => { + const fs = probe({ + '/var/run/docker.sock': '/var/run/docker.sock', + '/Users/dev/.colima/default/docker.sock': '/Users/dev/.colima/default/docker.sock', + }); + const env = { DOCKER_HOST: 'unix:///Users/dev/.colima/default/docker.sock' }; + + expect(resolveDockerEndpoint({ env, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.colima/default/docker.sock', + }); + }); + + it('ignores a DOCKER_HOST that is not a unix socket', () => { + const fs = probe({ '/var/run/docker.sock': '/var/run/docker.sock' }); + const env = { DOCKER_HOST: 'tcp://127.0.0.1:2375' }; + + expect(resolveDockerEndpoint({ env, homeDir, fs })).toEqual({ + socketPath: '/var/run/docker.sock', + }); + }); + + it('falls back to the per-user path when /var/run/docker.sock is absent', () => { + const fs = probe({ + '/Users/dev/.docker/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + }); + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.docker/run/docker.sock', + }); + }); + + it('returns null for a dangling symlink, which is what a stopped daemon leaves', () => { + // /var/run/docker.sock survives Docker Desktop quitting; its target does not. + const fs: DockerFsProbe = { + exists: p => p === '/var/run/docker.sock', + realpath: () => { + throw new Error('ENOENT'); + }, + }; + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toBeNull(); + }); + + it('returns null when nothing is present', () => { + expect(resolveDockerEndpoint({ env: {}, homeDir, fs: probe({}) })).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts -t resolveDockerEndpoint` +Expected: FAIL — `resolveDockerEndpoint is not a function` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/shared/docker-access.ts +import * as fsNode from 'fs'; +import * as os from 'os'; + +/** The daemon socket, as a resolved real path. */ +export interface DockerEndpoint { + socketPath: string; +} + +/** The filesystem questions endpoint resolution asks, injected for testing. */ +export interface DockerFsProbe { + exists(p: string): boolean; + realpath(p: string): string; +} + +const nodeProbe: DockerFsProbe = { + exists: p => fsNode.existsSync(p), + realpath: p => fsNode.realpathSync(p), +}; + +/** Docker Desktop's per-user socket, which /var/run/docker.sock links to. */ +const perUserSocket = (homeDir: string): string => `${homeDir}/.docker/run/docker.sock`; + +const SYSTEM_SOCKET = '/var/run/docker.sock'; + +/** + * Find the daemon socket, resolving symlinks. Returns null when no socket is + * present - including the common case of a dangling /var/run/docker.sock left + * behind by a stopped Docker Desktop. + */ +export function resolveDockerEndpoint(options?: { + env?: NodeJS.ProcessEnv; + homeDir?: string; + fs?: DockerFsProbe; +}): DockerEndpoint | null { + const env = options?.env ?? process.env; + const homeDir = options?.homeDir ?? os.homedir(); + const fs = options?.fs ?? nodeProbe; + + const candidates: string[] = []; + + const dockerHost = env.DOCKER_HOST; + if (dockerHost && dockerHost.startsWith('unix://')) { + candidates.push(dockerHost.slice('unix://'.length)); + } + candidates.push(SYSTEM_SOCKET, perUserSocket(homeDir)); + + for (const candidate of candidates) { + if (!fs.exists(candidate)) continue; + try { + return { socketPath: fs.realpath(candidate) }; + } catch { + // A dangling symlink: the path exists, its target does not. + continue; + } + } + + return null; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts` +Expected: PASS (10 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/docker-access.ts src/shared/docker-access.test.ts +git commit -m "Resolve the Docker endpoint outside the sandbox" +``` + +--- + +### Task 3: Grant computation + +**Files:** +- Modify: `src/shared/docker-access.ts` +- Test: `src/shared/docker-access.test.ts` + +**Interfaces:** +- Consumes: `DockerAccessLevel`, `DockerEndpoint` from Tasks 1-2. +- Produces: `interface DockerGrants { socketLiterals: string[]; readLiterals: string[]; readSubpaths: string[]; env: Record }`, `function dockerSandboxGrants(level: DockerAccessLevel | undefined, endpoint: DockerEndpoint | null, homeDir: string): DockerGrants`. + +Both profile builders consume this, so the level-to-path mapping exists once. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/docker-access.test.ts +import { dockerSandboxGrants, DockerGrants } from './docker-access'; + +describe('dockerSandboxGrants', () => { + const homeDir = '/Users/dev'; + const endpoint = { socketPath: '/Users/dev/.docker/run/docker.sock' }; + const empty: DockerGrants = { socketLiterals: [], readLiterals: [], readSubpaths: [], env: {} }; + + it('grants nothing when the level is off', () => { + expect(dockerSandboxGrants('off', endpoint, homeDir)).toEqual(empty); + }); + + it('grants nothing when no level is declared', () => { + expect(dockerSandboxGrants(undefined, endpoint, homeDir)).toEqual(empty); + }); + + it('grants nothing when no daemon socket resolved', () => { + expect(dockerSandboxGrants('credentials', null, homeDir)).toEqual(empty); + }); + + it('grants the socket and injects DOCKER_HOST at socket level', () => { + expect(dockerSandboxGrants('socket', endpoint, homeDir)).toEqual({ + socketLiterals: ['/Users/dev/.docker/run/docker.sock'], + readLiterals: [], + readSubpaths: [], + env: { DOCKER_HOST: 'unix:///Users/dev/.docker/run/docker.sock' }, + }); + }); + + it('does not open config.json at socket level', () => { + const grants = dockerSandboxGrants('socket', endpoint, homeDir); + expect(grants.readLiterals).not.toContain('/Users/dev/.docker/config.json'); + }); + + it('adds the contexts directory at contexts level', () => { + const grants = dockerSandboxGrants('contexts', endpoint, homeDir); + expect(grants.readSubpaths).toEqual(['/Users/dev/.docker/contexts']); + expect(grants.readLiterals).toEqual([]); + }); + + it('adds config.json at credentials level, keeping the lower grants', () => { + const grants = dockerSandboxGrants('credentials', endpoint, homeDir); + expect(grants.socketLiterals).toEqual(['/Users/dev/.docker/run/docker.sock']); + expect(grants.readSubpaths).toEqual(['/Users/dev/.docker/contexts']); + expect(grants.readLiterals).toEqual(['/Users/dev/.docker/config.json']); + }); + + it('never grants the ~/.docker directory itself', () => { + for (const level of ['socket', 'contexts', 'credentials'] as const) { + const grants = dockerSandboxGrants(level, endpoint, homeDir); + expect(grants.readSubpaths).not.toContain('/Users/dev/.docker'); + } + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts -t dockerSandboxGrants` +Expected: FAIL — `dockerSandboxGrants is not a function` + +- [ ] **Step 3: Write minimal implementation** + +```typescript +// src/shared/docker-access.ts + +/** What a level opens in a sandbox profile. */ +export interface DockerGrants { + /** Socket paths to allow network-outbound, file-read* and file-write* on. */ + socketLiterals: string[]; + /** Single files to allow file-read* on. */ + readLiterals: string[]; + /** Directories to allow file-read* on. */ + readSubpaths: string[]; + /** Environment to inject into the job. */ + env: Record; +} + +const NO_GRANTS: DockerGrants = { + socketLiterals: [], + readLiterals: [], + readSubpaths: [], + env: {}, +}; + +/** Rank a level so cumulative comparisons read as comparisons. */ +const rank = (level: DockerAccessLevel): number => DOCKER_ACCESS_LEVELS.indexOf(level); + +/** + * What a declared level opens, given the resolved endpoint. Empty when the + * level is off or absent, or when no daemon socket was found - the declaration + * is a permission, not a requirement. + */ +export function dockerSandboxGrants( + level: DockerAccessLevel | undefined, + endpoint: DockerEndpoint | null, + homeDir: string +): DockerGrants { + if (!level || level === 'off' || !endpoint) { + return { ...NO_GRANTS }; + } + + const grants: DockerGrants = { + socketLiterals: [endpoint.socketPath], + readLiterals: [], + readSubpaths: [], + // The job never has to discover the endpoint, which is what lets the rest + // of ~/.docker stay closed at socket level. + env: { DOCKER_HOST: `unix://${endpoint.socketPath}` }, + }; + + if (rank(level) >= rank('contexts')) { + grants.readSubpaths.push(`${homeDir}/.docker/contexts`); + } + if (rank(level) >= rank('credentials')) { + grants.readLiterals.push(`${homeDir}/.docker/config.json`); + } + + return grants; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/shared/docker-access.test.ts` +Expected: PASS (18 tests) + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/docker-access.ts src/shared/docker-access.test.ts +git commit -m "Map each docker level to the paths it opens" +``` + +--- + +### Task 4: Runner profile emission + +**Files:** +- Modify: `src/main/process-sandbox.ts` (`RunnerProfileOptions` ~line 130, profile builder ~line 146, after the `deny file-read*` block ~line 309) +- Test: `src/main/process-sandbox.test.ts` + +**Interfaces:** +- Consumes: `dockerSandboxGrants`, `DockerAccessLevel` from Task 3. +- Produces: `RunnerProfileOptions` gains `dockerGrants?: DockerGrants`. The builder emits the rules. + +Grants are passed in rather than resolved here, so the profile builder stays a pure function of its options and the tests do not need a real Docker install. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/main/process-sandbox.test.ts — follow the existing profile-building describe +import { dockerSandboxGrants } from '../shared/docker-access'; + +describe('docker access in the runner profile', () => { + const endpoint = { socketPath: '/Users/dev/.docker/run/docker.sock' }; + const homeDir = '/Users/dev'; + + const profileWith = (level: 'off' | 'socket' | 'contexts' | 'credentials') => + buildRunnerProfile({ + instanceDir: '/Users/dev/.localmost/runner/sandbox/1', + dockerGrants: dockerSandboxGrants(level, endpoint, homeDir), + }); + + it('emits no docker rules when the level is off', () => { + expect(profileWith('off')).not.toContain('docker.sock'); + }); + + it('allows the resolved socket at socket level', () => { + const profile = profileWith('socket'); + expect(profile).toContain( + '(allow network-outbound (literal "/Users/dev/.docker/run/docker.sock"))' + ); + expect(profile).toContain( + '(allow file-write* (literal "/Users/dev/.docker/run/docker.sock"))' + ); + }); + + it('emits the socket allow after the deny block, so the literal wins', () => { + const profile = profileWith('socket'); + const deny = profile.indexOf('(deny file-read*'); + const allow = profile.indexOf('(allow file-read* (literal "/Users/dev/.docker/run/docker.sock"))'); + expect(deny).toBeGreaterThan(-1); + expect(allow).toBeGreaterThan(deny); + }); + + it('keeps config.json denied at socket level', () => { + const profile = profileWith('socket'); + expect(profile).not.toContain('config.json'); + }); + + it('keeps config.json denied at contexts level, adding only the directory', () => { + const profile = profileWith('contexts'); + expect(profile).toContain('(allow file-read* (subpath "/Users/dev/.docker/contexts"))'); + expect(profile).not.toContain('config.json'); + }); + + it('allows config.json at credentials level', () => { + expect(profileWith('credentials')).toContain( + '(allow file-read* (literal "/Users/dev/.docker/config.json"))' + ); + }); + + it('never grants the ~/.docker directory itself', () => { + for (const level of ['socket', 'contexts', 'credentials'] as const) { + expect(profileWith(level)).not.toContain('(allow file-read* (subpath "/Users/dev/.docker"))'); + } + }); +}); +``` + +If the profile builder is not exported, export it for the test rather than testing through `spawnSandboxed` — the test needs the profile text, not a spawned process. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/main/process-sandbox.test.ts -t "docker access"` +Expected: FAIL — `dockerGrants` is not an accepted option and no docker rules are emitted. + +- [ ] **Step 3: Write minimal implementation** + +Extend the options interface (`process-sandbox.ts:130`): + +```typescript +import type { DockerGrants } from '../shared/docker-access'; + +interface RunnerProfileOptions { + instanceDir: string; + brokerPort?: number; + allowDirectNetwork?: boolean; + filesystemPolicy?: SandboxFilesystemPolicy; + /** What the repository's declared docker level opens; empty when off. */ + dockerGrants?: DockerGrants; +} +``` + +Build the rule block, and interpolate it **after** the `deny file-read*` block so the literals win over the subtree deny: + +```typescript +const dockerRules = ((grants?: DockerGrants): string => { + if (!grants) return ''; + const lines: string[] = []; + + for (const socket of grants.socketLiterals) { + // The Docker Desktop socket lives inside the denied ~/.docker, so these + // literals have to come after the deny block above: last match wins. + 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}"))`); + } + + if (lines.length === 0) return ''; + return [ + '', + ';; Docker access, declared by the repository policy and approved. A job', + ';; that can reach the daemon can bind-mount host paths into a container,', + ';; which this profile cannot constrain. See docs/roadmap/docker-access.md.', + ...lines, + '', + ].join('\n'); +})(dockerGrants); +``` + +Insert `${dockerRules}` into the profile template immediately after the closing paren of the `deny file-read*` block. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/main/process-sandbox.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/process-sandbox.ts src/main/process-sandbox.test.ts +git commit -m "Emit docker grants in the runner sandbox profile" +``` + +--- + +### Task 5: Test-mode profile emission + +**Files:** +- Modify: `src/shared/sandbox-profile.ts` (socket emission ~line 360) +- Test: `src/shared/sandbox-profile.test.ts` + +**Interfaces:** +- Consumes: `dockerSandboxGrants` from Task 3; `SandboxPolicy.docker` from Task 1. +- Produces: `SandboxProfileOptions` gains `dockerEndpoint?: DockerEndpoint | null` and `homeDir?: string`; the builder derives grants from `policy.docker`. + +`localmost test` knows the workflow, but the level is read from `shared:` exactly as the runner reads it, so the two agree. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/sandbox-profile.test.ts +describe('docker access in the test-mode profile', () => { + const base = { + workDir: '/Users/dev/project', + proxyPort: 8080, + homeDir: '/Users/dev', + dockerEndpoint: { socketPath: '/Users/dev/.docker/run/docker.sock' }, + }; + + it('emits no docker rules without a declared level', () => { + const profile = buildSandboxProfile({ ...base, policy: {} }); + expect(profile).not.toContain('docker.sock'); + }); + + it('allows the socket at socket level', () => { + const profile = buildSandboxProfile({ ...base, policy: { docker: 'socket' } }); + expect(profile).toContain( + '(allow network-outbound (literal "/Users/dev/.docker/run/docker.sock"))' + ); + }); + + it('allows config.json only at credentials level', () => { + const contexts = buildSandboxProfile({ ...base, policy: { docker: 'contexts' } }); + const credentials = buildSandboxProfile({ ...base, policy: { docker: 'credentials' } }); + expect(contexts).not.toContain('config.json'); + expect(credentials).toContain( + '(allow file-read* (literal "/Users/dev/.docker/config.json"))' + ); + }); + + it('emits nothing when no daemon socket resolved', () => { + const profile = buildSandboxProfile({ + ...base, + dockerEndpoint: null, + policy: { docker: 'credentials' }, + }); + expect(profile).not.toContain('docker.sock'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/sandbox-profile.test.ts -t "docker access"` +Expected: FAIL — no docker rules emitted. + +- [ ] **Step 3: Write minimal implementation** + +In `SandboxProfileOptions` add: + +```typescript + /** Resolved Docker endpoint, or null when none was found. */ + dockerEndpoint?: DockerEndpoint | null; + /** Home directory, for the ~/.docker paths a level opens. */ + homeDir?: string; +``` + +In the builder, after the existing "Policy-defined socket access" block, emit from the level. The existing `sockets:` emission stays as it is — Task 7 deals with that key: + +```typescript + // Docker access, from the policy's declared level + const dockerGrants = dockerSandboxGrants( + policy?.docker, + options.dockerEndpoint ?? null, + options.homeDir ?? os.homedir() + ); + if (dockerGrants.socketLiterals.length > 0) { + lines.push(';; Docker access declared by the repository policy'); + for (const socket of dockerGrants.socketLiterals) { + const escaped = escapePath(socket); + lines.push(`(allow network-outbound (literal "${escaped}"))`); + lines.push(`(allow file-read* (literal "${escaped}"))`); + lines.push(`(allow file-write* (literal "${escaped}"))`); + } + for (const file of dockerGrants.readLiterals) { + lines.push(`(allow file-read* (literal "${escapePath(file)}"))`); + } + for (const dir of dockerGrants.readSubpaths) { + lines.push(`(allow file-read* (subpath "${escapePath(dir)}"))`); + } + lines.push(''); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/shared/sandbox-profile.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/sandbox-profile.ts src/shared/sandbox-profile.test.ts +git commit -m "Emit docker grants in the localmost test profile" +``` + +--- + +### Task 6: Thread the level from the approved policy to the runner + +**Files:** +- Modify: `src/main/index.ts:313-330` (the `getRepoPolicy` closure) +- Modify: `src/main/runner-manager.ts:1446-1450` (`stampFor`), `:1494-1523` (`resolveFilesystemPolicy`), `:911-921` (spawn) +- Modify: `src/main/process-sandbox.ts` (`spawnSandboxed` options → profile) +- Modify: `src/shared/types.ts` (`RepoPolicyRuntime`) +- Test: `src/main/runner-manager.test.ts` + +**Interfaces:** +- Consumes: `DockerAccessLevel` (Task 1), `dockerSandboxGrants` and `resolveDockerEndpoint` (Tasks 2-3), `RunnerProfileOptions.dockerGrants` (Task 4). +- Produces: `RepoPolicyRuntime` gains `docker: DockerAccessLevel`; `SandboxFilesystemPolicy` gains `docker: DockerAccessLevel`; the stamp covers it. + +The stamp matters: a worker spawned under one level must not claim a job approved under another. `stampFor` currently hashes `[level, readPaths, writePaths]`, and the docker level is baked into the profile the same way. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/main/runner-manager.test.ts +describe('policy stamp', () => { + it('changes when the docker level changes', () => { + const manager = new RunnerManager({}); + const stamp = (docker: 'off' | 'socket') => + // @ts-expect-error - exercising the private stamp directly + manager.stampFor({ level: 'strict', readPaths: [], writePaths: [], docker }); + + expect(stamp('off')).not.toEqual(stamp('socket')); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/main/runner-manager.test.ts -t "policy stamp"` +Expected: FAIL — both stamps are equal, because `stampFor` ignores the level. + +- [ ] **Step 3: Write minimal implementation** + +`src/shared/types.ts` — extend the runtime policy: + +```typescript + /** Docker access the repository declared, 'off' when it declared none. */ + docker: DockerAccessLevel; +``` + +`src/main/index.ts` — return it from both the unapproved and approved branches: + +```typescript + if (!cached?.approved) { + return { hosts: [], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const }; + } + ... + // Docker access, like filesystem, comes from the shared section only: + // the profile is built before the workflow is known. + docker: cached.config.shared?.docker ?? 'off', +``` + +`src/main/runner-manager.ts` — include it in the stamp and pass it to spawn: + +```typescript + private stampFor( + policy: Pick + ): string { + return createHash('sha256') + .update(JSON.stringify([policy.level, policy.readPaths, policy.writePaths, policy.docker])) + .digest('hex'); + } +``` + +In `resolveFilesystemPolicy`, add `docker: 'off'` to the `closed` fallback and `docker: policy.docker` to the resolved return. + +At line 911, resolve the grants once and pass them to spawn. Task 7 adds the env +spread to this same call, so bind it to a const now: + +```typescript + const filesystemPolicy = await this.resolveFilesystemPolicy(startupContextForPolicy); + const dockerGrants = dockerSandboxGrants( + filesystemPolicy.docker, + resolveDockerEndpoint(), + os.homedir() + ); + + instance.process = spawnSandboxed(runnerBinary, ['--once'], { + cwd: sandboxDir, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: true, + filesystemPolicy, + dockerGrants, + }); +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx jest --config test/jest.config.js src/main/runner-manager.test.ts` +Expected: PASS + +- [ ] **Step 5: Write the failing test for the no-daemon warning** + +The spec requires that a declared level with no reachable daemon warns and runs +without the grant, rather than appearing to have had access. + +```typescript +// src/main/runner-manager.test.ts +it('warns when a policy declares docker but no daemon socket resolved', () => { + const logged: string[] = []; + const manager = new RunnerManager({ onLog: (_level, message) => logged.push(message) }); + + // @ts-expect-error - exercising the private helper directly + manager.warnIfDockerUnavailable('socket', null); + + expect(logged.join('\n')).toMatch(/docker/i); + expect(logged.join('\n')).toMatch(/no daemon socket/i); +}); + +it('says nothing when no docker level was declared', () => { + const logged: string[] = []; + const manager = new RunnerManager({ onLog: (_level, message) => logged.push(message) }); + + // @ts-expect-error - exercising the private helper directly + manager.warnIfDockerUnavailable('off', null); + + expect(logged).toEqual([]); +}); +``` + +- [ ] **Step 6: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/main/runner-manager.test.ts -t "no daemon"` +Expected: FAIL — `manager.warnIfDockerUnavailable is not a function` + +- [ ] **Step 7: Implement the warning** + +```typescript + /** + * A declared level with no reachable daemon runs without the grant. Say so: + * the job must not look like it had access it did not get. + */ + private warnIfDockerUnavailable( + level: DockerAccessLevel, + endpoint: DockerEndpoint | null + ): void { + if (level === 'off' || endpoint) return; + this.log( + 'warn', + `Policy declares docker: ${level}, but no daemon socket resolved - ` + + 'running without Docker access' + ); + } +``` + +Call it from the spawn path, next to the `dockerGrants` const added in Step 3. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `npx jest --config test/jest.config.js src/main/runner-manager.test.ts` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add src/shared/types.ts src/main/index.ts src/main/runner-manager.ts src/main/process-sandbox.ts src/main/runner-manager.test.ts +git commit -m "Carry the docker level from approved policy into the worker profile" +``` + +--- + +### Task 7: Inject DOCKER_HOST, and settle the legacy sockets key + +**Files:** +- Modify: `src/main/runner-manager.ts:913-915` (the `env` passed to `spawnSandboxed`) +- Modify: `src/cli/test.ts:265` (effective policy → profile options) +- Modify: `src/shared/localmostrc.ts` (`validateSocketsPolicy` ~line 272) +- Test: `src/main/runner-manager.test.ts`, `src/shared/localmostrc.test.ts` + +**Interfaces:** +- Consumes: `DockerGrants.env` from Task 3. +- Produces: no new exports. + +`shared.sockets.allow` is a validated key that already reaches the `localmost test` profile and is ignored by the runner, accepting arbitrary socket paths. It keeps working — removing a shipped key is a breaking policy change — but it warns and points at `docker:`, and it is not wired into the runner path. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/localmostrc.test.ts +it('warns that sockets is superseded by docker and is test-mode only', () => { + const result = parseLocalmostrcContent( + 'version: 1\nshared:\n sockets:\n allow:\n - /var/run/docker.sock\n' + ); + expect(result.success).toBe(true); + expect(result.warnings.join('\n')).toMatch(/docker:/); + expect(result.warnings.join('\n')).toMatch(/localmost test/); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts -t sockets` +Expected: FAIL — no warning is produced. + +- [ ] **Step 3: Write minimal implementation** + +`validatePolicy` collects warnings alongside errors; pass the warnings array into `validateSocketsPolicy` and push: + +```typescript + warnings.push( + `${path}.sockets is honoured by "localmost test" only and is not applied to ` + + 'runner jobs. Declare docker access with `docker:` instead, which the runner ' + + 'and test mode both apply.' + ); +``` + +Inject the env in `runner-manager.ts`, where `env` is built for `spawnSandboxed`: + +```typescript + const dockerGrants = dockerSandboxGrants( + filesystemPolicy.docker, + resolveDockerEndpoint(), + os.homedir() + ); + + instance.process = spawnSandboxed(runnerBinary, ['--once'], { + cwd: sandboxDir, + env: { ...env, ...dockerGrants.env }, + ... + dockerGrants, + }); +``` + +In `src/cli/test.ts`, pass the endpoint through to the profile options: + +```typescript + policy = getEffectivePolicy(config, workflow.name); + // ... where the profile is built: + dockerEndpoint: resolveDockerEndpoint(), + homeDir: os.homedir(), +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts src/main/runner-manager.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/main/runner-manager.ts src/cli/test.ts src/shared/localmostrc.ts src/shared/localmostrc.test.ts +git commit -m "Inject DOCKER_HOST and mark the sockets key test-mode only" +``` + +--- + +### Task 8: Approval diff prominence and policy round-trip + +**Files:** +- Modify: `src/shared/localmostrc.ts` (`diffConfigs` ~line 567, `diffPolicies` ~line 579, `serializePolicy` ~line 461) +- Test: `src/shared/localmostrc.test.ts` + +**Interfaces:** +- Consumes: `SandboxPolicy.docker` from Task 1. +- Produces: no new exports. + +With the repository policy as the only gate, the diff an operator reads at approval time is the whole of the access control, so a docker change carries the same weight as a `level:` change. + +- [ ] **Step 1: Write the failing test** + +```typescript +// src/shared/localmostrc.test.ts +it('reports a docker level change as its own diff entry', () => { + const before = { version: 1, shared: { docker: 'off' as const } }; + const after = { version: 1, shared: { docker: 'credentials' as const } }; + + const diffs = diffConfigs(before, after); + const docker = diffs.find(d => d.path === 'shared.docker'); + + expect(docker).toEqual({ + path: 'shared.docker', + type: 'changed', + oldValue: 'off', + newValue: 'credentials', + }); +}); + +it('reports newly declared docker access as added', () => { + const diffs = diffConfigs({ version: 1 }, { version: 1, shared: { docker: 'socket' as const } }); + expect(diffs.find(d => d.path === 'shared.docker')?.type).toBe('added'); +}); + +it('round-trips a docker level through serialization', () => { + const config = { version: 1, shared: { docker: 'contexts' as const } }; + const reparsed = parseLocalmostrcContent(serializeLocalmostrc(config)); + expect(reparsed.config?.shared?.docker).toBe('contexts'); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts -t docker` +Expected: FAIL — no `shared.docker` diff entry; serialization drops the key. + +- [ ] **Step 3: Write minimal implementation** + +In `diffPolicies`, compare the scalar the way `level` is compared: + +```typescript + if (oldPolicy.docker !== newPolicy.docker) { + diffs.push({ + path: `${pathPrefix}.docker`, + type: oldPolicy.docker === undefined ? 'added' : newPolicy.docker === undefined ? 'removed' : 'changed', + oldValue: oldPolicy.docker, + newValue: newPolicy.docker, + }); + } +``` + +In `serializePolicy`, emit it before the nested sections: + +```typescript + if (policy.docker !== undefined) { + lines.push(`${indent}docker: ${policy.docker}`); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/localmostrc.ts src/shared/localmostrc.test.ts +git commit -m "Surface docker level changes in the policy diff" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Modify: `docs/roadmap/localmostrc.md` (schema section ~line 103) +- Modify: `README.md` (policy section; roadmap entry now implemented) +- Modify: `SECURITY.md` (sandbox section) +- Modify: `CHANGELOG.md` (0.3.0 Added) +- Modify: `docs/roadmap/docker-access.md` (status line) + +**Interfaces:** +- Consumes: the behaviour built in Tasks 1-8. +- Produces: no code. + +- [ ] **Step 1: Update the policy reference** + +In `docs/roadmap/localmostrc.md`, add to the full schema block: + +```yaml +shared: + # Docker daemon access. Cumulative; default off. + # socket - the daemon socket, with DOCKER_HOST set for the job + # contexts - the above, plus ~/.docker/contexts + # credentials - the above, plus ~/.docker/config.json + # A job that can reach the daemon is not sandboxed: see docs/roadmap/docker-access.md + docker: socket +``` + +Note in the same section that at `contexts`, a job selecting a context whose +endpoint is a different socket gets a connection refused by the sandbox - the +grant covers the resolved daemon socket, not whatever a context names. + +- [ ] **Step 2: Write the SECURITY.md statement** + +Under the sandbox section: + +```markdown +### Docker Access + +A repository may declare `docker:` in its approved `.localmostrc`. At any level +from `socket` upward, jobs from that repository are **not sandboxed**: containers +are not subject to the seatbelt profile, so a job can bind-mount host paths into +a container and read or write them - including paths the profile denies - and +make network connections that bypass the policy's allowlist. + +Default is off. It takes effect only through the normal policy approval, so the +diff shown at approval time is what grants it. +``` + +- [ ] **Step 3: Add the changelog entry** + +```markdown +- **Opt-in Docker access**: an approved `.localmostrc` may declare + `docker: socket | contexts | credentials` to let jobs reach the Docker daemon. + Default off. A job with Docker access is not sandboxed - see + `docs/roadmap/docker-access.md` +``` + +- [ ] **Step 4: Flip the design doc status and the roadmap entry** + +Change the status line in `docs/roadmap/docker-access.md` to `implemented in `, and move the README roadmap bullet out of "Future feature ideas" into the current release list. + +- [ ] **Step 5: Commit** + +```bash +git add docs README.md SECURITY.md CHANGELOG.md +git commit -m "Document docker access and what it gives up" +``` + +--- + +### Task 10: End-to-end verification + +**Files:** none — this task changes no code. + +**Interfaces:** +- Consumes: everything above. + +Profile assertions cannot prove the daemon is reachable. This runs the real thing. + +- [ ] **Step 1: Verify the full suite is green** + +Run: `npm run lint && npm run typecheck && npm test` +Expected: no lint or type errors; all suites pass. + +- [ ] **Step 2: Check the endpoint resolves to the symlink target** + +With Docker Desktop running: + +```bash +readlink /var/run/docker.sock +npx ts-node -e "import {resolveDockerEndpoint} from './src/shared/docker-access'; console.log(resolveDockerEndpoint())" +``` + +Expected: both print the same per-user path under `~/.docker/run/docker.sock`. If +the resolver returns `/var/run/docker.sock` instead, the symlink is not being +followed and every profile assertion above is testing the wrong path. + +- [ ] **Step 3: Run a Docker workflow through `localmost test`** + +In a repository whose `.localmostrc` declares `docker: socket`, run `localmost test`. Expected: `docker info` succeeds inside the job; `cat ~/.docker/config.json` is denied. + +- [ ] **Step 4: Run the same workflow on the runner** + +Approve the policy (`localmost policy approve`), push, and let the runner take the job. Expected: same result as Step 3 — that agreement is the point of the shared resolver. + +- [ ] **Step 5: Verify the negative case** + +Quit Docker Desktop, leaving the dangling `/var/run/docker.sock`, and re-run. Expected: the job logs a warning that no daemon socket resolved, runs without the grant, and the container tests skip rather than the job failing. + +- [ ] **Step 6: Commit any fixes** + +```bash +git commit -am "Fix issues found in end-to-end docker verification" +``` From 38c623c652fe213bc58aa5e4aab5127bab018aa5 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 18:58:39 -0400 Subject: [PATCH 03/13] Add docker access level to the policy schema Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- .../plans/2026-09-04-docker-access.md | 93 ++++++++++++------- src/shared/docker-access.test.ts | 23 +++++ src/shared/docker-access.ts | 22 +++++ src/shared/localmostrc.test.ts | 27 ++++++ src/shared/localmostrc.ts | 18 ++++ src/shared/sandbox-profile.ts | 3 + 6 files changed, 151 insertions(+), 35 deletions(-) create mode 100644 src/shared/docker-access.test.ts create mode 100644 src/shared/docker-access.ts diff --git a/docs/superpowers/plans/2026-09-04-docker-access.md b/docs/superpowers/plans/2026-09-04-docker-access.md index 3259857..d283e84 100644 --- a/docs/superpowers/plans/2026-09-04-docker-access.md +++ b/docs/superpowers/plans/2026-09-04-docker-access.md @@ -735,7 +735,7 @@ In `SandboxProfileOptions` add: homeDir?: string; ``` -In the builder, after the existing "Policy-defined socket access" block, emit from the level. The existing `sockets:` emission stays as it is — Task 7 deals with that key: +In the builder, replace the "Policy-defined socket access" block with emission from the level. Task 7 removes the `sockets:` key that fed the old block: ```typescript // Docker access, from the policy's declared level @@ -943,66 +943,86 @@ git commit -m "Carry the docker level from approved policy into the worker profi --- -### Task 7: Inject DOCKER_HOST, and settle the legacy sockets key +### Task 7: Inject DOCKER_HOST and remove the sockets key **Files:** - Modify: `src/main/runner-manager.ts:913-915` (the `env` passed to `spawnSandboxed`) - Modify: `src/cli/test.ts:265` (effective policy → profile options) -- Modify: `src/shared/localmostrc.ts` (`validateSocketsPolicy` ~line 272) -- Test: `src/main/runner-manager.test.ts`, `src/shared/localmostrc.test.ts` +- Modify: `src/shared/localmostrc.ts` (`validatePolicy` ~line 227, delete `validateSocketsPolicy` ~line 272) +- Modify: `src/shared/sandbox-profile.ts` (delete `SocketsPolicy` ~line 27, its field on `SandboxPolicy`, and the emission block ~line 360) +- Test: `src/main/runner-manager.test.ts`, `src/shared/localmostrc.test.ts`, `src/shared/sandbox-profile.test.ts` **Interfaces:** - Consumes: `DockerGrants.env` from Task 3. -- Produces: no new exports. +- Produces: `SocketsPolicy` and `SandboxPolicy.sockets` are gone. -`shared.sockets.allow` is a validated key that already reaches the `localmost test` profile and is ignored by the runner, accepting arbitrary socket paths. It keeps working — removing a shipped key is a breaking policy change — but it warns and points at `docker:`, and it is not wired into the runner path. +`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 the +runner — and it let a repository name any socket, which is what the closed enum +exists to prevent. No approved policy declares it and `localmostrc.md` never +documented it. It is removed rather than deprecated, and rejected with an error +naming `docker:` so a policy that used it fails loudly instead of quietly losing +behaviour. - [ ] **Step 1: Write the failing test** ```typescript // src/shared/localmostrc.test.ts -it('warns that sockets is superseded by docker and is test-mode only', () => { +it('rejects the removed sockets key, pointing at docker', () => { const result = parseLocalmostrcContent( 'version: 1\nshared:\n sockets:\n allow:\n - /var/run/docker.sock\n' ); - expect(result.success).toBe(true); - expect(result.warnings.join('\n')).toMatch(/docker:/); - expect(result.warnings.join('\n')).toMatch(/localmost test/); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/docker:/); }); ``` -- [ ] **Step 2: Run test to verify it fails** +```typescript +// src/main/runner-manager.test.ts +it('injects DOCKER_HOST into the job environment when a socket is granted', () => { + const grants = dockerSandboxGrants( + 'socket', + { socketPath: '/Users/dev/.docker/run/docker.sock' }, + '/Users/dev' + ); + const env = { PATH: '/usr/bin', ...grants.env }; + + expect(env.DOCKER_HOST).toBe('unix:///Users/dev/.docker/run/docker.sock'); + expect(env.PATH).toBe('/usr/bin'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts -t sockets` -Expected: FAIL — no warning is produced. +Expected: FAIL — `sockets:` still validates successfully. -- [ ] **Step 3: Write minimal implementation** +- [ ] **Step 3: Remove the key and inject the env** + +In `src/shared/sandbox-profile.ts`, delete the `SocketsPolicy` interface, the +`sockets?: SocketsPolicy` field on `SandboxPolicy`, and the "Policy-defined +socket access" emission block. -`validatePolicy` collects warnings alongside errors; pass the warnings array into `validateSocketsPolicy` and push: +In `src/shared/localmostrc.ts`, delete `validateSocketsPolicy` and replace its +call in `validatePolicy` with a rejection: ```typescript - warnings.push( - `${path}.sockets is honoured by "localmost test" only and is not applied to ` + - 'runner jobs. Declare docker access with `docker:` instead, which the runner ' + - 'and test mode both apply.' - ); + // Removed in favour of docker:, which is applied by the runner as well as + // by localmost test, and cannot name an arbitrary socket. + if (p.sockets !== undefined) { + errors.push({ + message: + `${path}.sockets is no longer supported. Use \`docker:\` in shared to ` + + 'declare Docker access (off, socket, contexts, credentials).', + }); + } ``` -Inject the env in `runner-manager.ts`, where `env` is built for `spawnSandboxed`: +In `runner-manager.ts`, spread the grant env into the job environment at the +spawn built in Task 6: ```typescript - const dockerGrants = dockerSandboxGrants( - filesystemPolicy.docker, - resolveDockerEndpoint(), - os.homedir() - ); - - instance.process = spawnSandboxed(runnerBinary, ['--once'], { - cwd: sandboxDir, env: { ...env, ...dockerGrants.env }, - ... - dockerGrants, - }); ``` In `src/cli/test.ts`, pass the endpoint through to the profile options: @@ -1016,14 +1036,15 @@ In `src/cli/test.ts`, pass the endpoint through to the profile options: - [ ] **Step 4: Run tests to verify they pass** -Run: `npx jest --config test/jest.config.js src/shared/localmostrc.test.ts src/main/runner-manager.test.ts` -Expected: PASS +Run: `npx jest --config test/jest.config.js` +Expected: PASS. Any existing `sockets:` test in `sandbox-profile.test.ts` is +deleted with the feature, not adapted. - [ ] **Step 5: Commit** ```bash -git add src/main/runner-manager.ts src/cli/test.ts src/shared/localmostrc.ts src/shared/localmostrc.test.ts -git commit -m "Inject DOCKER_HOST and mark the sockets key test-mode only" +git add src/main/runner-manager.ts src/cli/test.ts src/shared/localmostrc.ts src/shared/sandbox-profile.ts src/shared/localmostrc.test.ts src/shared/sandbox-profile.test.ts src/main/runner-manager.test.ts +git commit -m "Inject DOCKER_HOST and remove the superseded sockets key" ``` --- @@ -1164,6 +1185,8 @@ diff shown at approval time is what grants it. - [ ] **Step 3: Add the changelog entry** ```markdown +- **Removed `sockets:`**: the key reached `localmost test` only, never the runner, + and accepted arbitrary socket paths. Declare `docker:` instead - **Opt-in Docker access**: an approved `.localmostrc` may declare `docker: socket | contexts | credentials` to let jobs reach the Docker daemon. Default off. A job with Docker access is not sandboxed - see diff --git a/src/shared/docker-access.test.ts b/src/shared/docker-access.test.ts new file mode 100644 index 0000000..9e06308 --- /dev/null +++ b/src/shared/docker-access.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from '@jest/globals'; +import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; + +describe('docker access levels', () => { + it('lists the four levels in increasing order of access', () => { + expect(DOCKER_ACCESS_LEVELS).toEqual(['off', 'socket', 'contexts', 'credentials']); + }); + + it('accepts every declared level', () => { + for (const level of DOCKER_ACCESS_LEVELS) { + expect(isDockerAccessLevel(level)).toBe(true); + } + }); + + it('rejects a boolean, which is ambiguous about which level was meant', () => { + expect(isDockerAccessLevel(true)).toBe(false); + expect(isDockerAccessLevel(false)).toBe(false); + }); + + it('rejects an unknown string', () => { + expect(isDockerAccessLevel('daemon')).toBe(false); + }); +}); diff --git a/src/shared/docker-access.ts b/src/shared/docker-access.ts new file mode 100644 index 0000000..5c0e7a7 --- /dev/null +++ b/src/shared/docker-access.ts @@ -0,0 +1,22 @@ +/** + * Docker daemon access, declared per repository in .localmostrc. + * + * A job that can reach the daemon is not sandboxed: containers are not subject + * to the seatbelt profile, so a bind mount reaches host paths the profile + * denies. See docs/roadmap/docker-access.md. + */ + +/** How much Docker surface a repository's policy opens. Cumulative. */ +export type DockerAccessLevel = 'off' | 'socket' | 'contexts' | 'credentials'; + +/** In increasing order of access. */ +export const DOCKER_ACCESS_LEVELS: readonly DockerAccessLevel[] = [ + 'off', + 'socket', + 'contexts', + 'credentials', +]; + +export function isDockerAccessLevel(value: unknown): value is DockerAccessLevel { + return typeof value === 'string' && (DOCKER_ACCESS_LEVELS as readonly string[]).includes(value); +} diff --git a/src/shared/localmostrc.test.ts b/src/shared/localmostrc.test.ts index c3d832f..2e76a92 100644 --- a/src/shared/localmostrc.test.ts +++ b/src/shared/localmostrc.test.ts @@ -796,3 +796,30 @@ describe('serializing a declared level', () => { expect(serializeLocalmostrc(config)).not.toContain('level:'); }); }); + +describe('docker access', () => { + it('accepts a declared docker level', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: socket\n'); + expect(result.success).toBe(true); + expect(result.config?.shared?.docker).toBe('socket'); + }); + + it('rejects docker: true, which does not say which level was meant', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: true\n'); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/off, socket, contexts, credentials/); + }); + + it('rejects an unknown docker level', () => { + const result = parseLocalmostrcContent('version: 1\nshared:\n docker: daemon\n'); + expect(result.success).toBe(false); + }); + + it('rejects docker inside a workflows block', () => { + const result = parseLocalmostrcContent( + 'version: 1\nworkflows:\n build:\n docker: socket\n' + ); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/shared/); + }); +}); diff --git a/src/shared/localmostrc.ts b/src/shared/localmostrc.ts index 5ce4eb5..7b9ef7d 100644 --- a/src/shared/localmostrc.ts +++ b/src/shared/localmostrc.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { SandboxPolicy, NetworkPolicy, FilesystemPolicy, SocketsPolicy, EnvPolicy } from './sandbox-profile'; import { SandboxPolicyLevel } from './types'; +import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; // ============================================================================= // Types @@ -232,6 +233,23 @@ function validatePolicy(policy: unknown, path: string, errors: ParseError[]): vo if (p.env !== undefined) { validateEnvPolicy(p.env, `${path}.env`, errors); } + + // Validate docker access level. The sandbox profile is built before the + // workflow is known, so this is only meaningful in the shared section - the + // same reason per-workflow filesystem sections are not applied. + if (p.docker !== undefined) { + if (path !== 'shared') { + errors.push({ + message: + `${path}.docker is not supported: docker access is declared in shared, ` + + 'because the sandbox profile is built before the workflow is known', + }); + } else if (!isDockerAccessLevel(p.docker)) { + errors.push({ + message: `${path}.docker must be one of: ${DOCKER_ACCESS_LEVELS.join(', ')}`, + }); + } + } } function validateNetworkPolicy(policy: unknown, path: string, errors: ParseError[]): void { diff --git a/src/shared/sandbox-profile.ts b/src/shared/sandbox-profile.ts index 688f462..b11beb2 100644 --- a/src/shared/sandbox-profile.ts +++ b/src/shared/sandbox-profile.ts @@ -8,6 +8,7 @@ import * as os from 'os'; import * as path from 'path'; +import type { DockerAccessLevel } from './docker-access'; // ============================================================================= // Types @@ -39,6 +40,8 @@ export interface SandboxPolicy { filesystem?: FilesystemPolicy; sockets?: SocketsPolicy; env?: EnvPolicy; + /** Docker daemon access. Read from `shared:` only - see docker-access.ts. */ + docker?: DockerAccessLevel; } export interface SandboxProfileOptions { From 1ffbce5ad6d77dd4e5a1d0c23efbb5365222ce63 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 18:59:21 -0400 Subject: [PATCH 04/13] Resolve the Docker endpoint outside the sandbox Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/shared/docker-access.test.ts | 78 +++++++++++++++++++++++++++++++- src/shared/docker-access.ts | 62 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/shared/docker-access.test.ts b/src/shared/docker-access.test.ts index 9e06308..bfcccfe 100644 --- a/src/shared/docker-access.test.ts +++ b/src/shared/docker-access.test.ts @@ -1,5 +1,19 @@ import { describe, it, expect } from '@jest/globals'; -import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; +import { + DOCKER_ACCESS_LEVELS, + isDockerAccessLevel, + resolveDockerEndpoint, + DockerFsProbe, +} from './docker-access'; + +/** A fake machine: paths that exist, and where symlinks point. */ +const probe = (paths: Record): DockerFsProbe => ({ + exists: p => p in paths, + realpath: p => { + if (!(p in paths)) throw new Error(`ENOENT: ${p}`); + return paths[p]; + }, +}); describe('docker access levels', () => { it('lists the four levels in increasing order of access', () => { @@ -21,3 +35,65 @@ describe('docker access levels', () => { expect(isDockerAccessLevel('daemon')).toBe(false); }); }); + +describe('resolveDockerEndpoint', () => { + const homeDir = '/Users/dev'; + + it('follows /var/run/docker.sock to the Docker Desktop socket it links to', () => { + const fs = probe({ + '/var/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + '/Users/dev/.docker/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + }); + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.docker/run/docker.sock', + }); + }); + + it('prefers an operator-set DOCKER_HOST', () => { + const fs = probe({ + '/var/run/docker.sock': '/var/run/docker.sock', + '/Users/dev/.colima/default/docker.sock': '/Users/dev/.colima/default/docker.sock', + }); + const env = { DOCKER_HOST: 'unix:///Users/dev/.colima/default/docker.sock' }; + + expect(resolveDockerEndpoint({ env, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.colima/default/docker.sock', + }); + }); + + it('ignores a DOCKER_HOST that is not a unix socket', () => { + const fs = probe({ '/var/run/docker.sock': '/var/run/docker.sock' }); + const env = { DOCKER_HOST: 'tcp://127.0.0.1:2375' }; + + expect(resolveDockerEndpoint({ env, homeDir, fs })).toEqual({ + socketPath: '/var/run/docker.sock', + }); + }); + + it('falls back to the per-user path when /var/run/docker.sock is absent', () => { + const fs = probe({ + '/Users/dev/.docker/run/docker.sock': '/Users/dev/.docker/run/docker.sock', + }); + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toEqual({ + socketPath: '/Users/dev/.docker/run/docker.sock', + }); + }); + + it('returns null for a dangling symlink, which is what a stopped daemon leaves', () => { + // /var/run/docker.sock survives Docker Desktop quitting; its target does not. + const fs: DockerFsProbe = { + exists: p => p === '/var/run/docker.sock', + realpath: () => { + throw new Error('ENOENT'); + }, + }; + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toBeNull(); + }); + + it('returns null when nothing is present', () => { + expect(resolveDockerEndpoint({ env: {}, homeDir, fs: probe({}) })).toBeNull(); + }); +}); diff --git a/src/shared/docker-access.ts b/src/shared/docker-access.ts index 5c0e7a7..cefcd39 100644 --- a/src/shared/docker-access.ts +++ b/src/shared/docker-access.ts @@ -6,6 +6,9 @@ * denies. See docs/roadmap/docker-access.md. */ +import * as fsNode from 'fs'; +import * as os from 'os'; + /** How much Docker surface a repository's policy opens. Cumulative. */ export type DockerAccessLevel = 'off' | 'socket' | 'contexts' | 'credentials'; @@ -20,3 +23,62 @@ export const DOCKER_ACCESS_LEVELS: readonly DockerAccessLevel[] = [ export function isDockerAccessLevel(value: unknown): value is DockerAccessLevel { return typeof value === 'string' && (DOCKER_ACCESS_LEVELS as readonly string[]).includes(value); } + +/** The daemon socket, as a resolved real path. */ +export interface DockerEndpoint { + socketPath: string; +} + +/** The filesystem questions endpoint resolution asks, injected for testing. */ +export interface DockerFsProbe { + exists(p: string): boolean; + realpath(p: string): string; +} + +const nodeProbe: DockerFsProbe = { + exists: p => fsNode.existsSync(p), + realpath: p => fsNode.realpathSync(p), +}; + +/** Docker Desktop's per-user socket, which /var/run/docker.sock links to. */ +const perUserSocket = (homeDir: string): string => `${homeDir}/.docker/run/docker.sock`; + +const SYSTEM_SOCKET = '/var/run/docker.sock'; + +/** + * Find the daemon socket, resolving symlinks. Returns null when no socket is + * present - including the common case of a dangling /var/run/docker.sock left + * behind by a stopped Docker Desktop. + * + * This runs in the app, outside the sandbox, so the job never has to discover + * the endpoint itself. + */ +export function resolveDockerEndpoint(options?: { + env?: NodeJS.ProcessEnv; + homeDir?: string; + fs?: DockerFsProbe; +}): DockerEndpoint | null { + const env = options?.env ?? process.env; + const homeDir = options?.homeDir ?? os.homedir(); + const fs = options?.fs ?? nodeProbe; + + const candidates: string[] = []; + + const dockerHost = env.DOCKER_HOST; + if (dockerHost && dockerHost.startsWith('unix://')) { + candidates.push(dockerHost.slice('unix://'.length)); + } + candidates.push(SYSTEM_SOCKET, perUserSocket(homeDir)); + + for (const candidate of candidates) { + if (!fs.exists(candidate)) continue; + try { + return { socketPath: fs.realpath(candidate) }; + } catch { + // A dangling symlink: the path exists, its target does not. + continue; + } + } + + return null; +} From be577cbbaca3abf14219adcb67dbce971684440a Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:00:02 -0400 Subject: [PATCH 05/13] Map each docker level to the paths it opens Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/shared/docker-access.test.ts | 54 ++++++++++++++++++++++++++++++++ src/shared/docker-access.ts | 48 ++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/src/shared/docker-access.test.ts b/src/shared/docker-access.test.ts index bfcccfe..166e415 100644 --- a/src/shared/docker-access.test.ts +++ b/src/shared/docker-access.test.ts @@ -3,7 +3,9 @@ import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel, resolveDockerEndpoint, + dockerSandboxGrants, DockerFsProbe, + DockerGrants, } from './docker-access'; /** A fake machine: paths that exist, and where symlinks point. */ @@ -97,3 +99,55 @@ describe('resolveDockerEndpoint', () => { expect(resolveDockerEndpoint({ env: {}, homeDir, fs: probe({}) })).toBeNull(); }); }); + +describe('dockerSandboxGrants', () => { + const homeDir = '/Users/dev'; + const endpoint = { socketPath: '/Users/dev/.docker/run/docker.sock' }; + const empty: DockerGrants = { socketLiterals: [], readLiterals: [], readSubpaths: [], env: {} }; + + it('grants nothing when the level is off', () => { + expect(dockerSandboxGrants('off', endpoint, homeDir)).toEqual(empty); + }); + + it('grants nothing when no level is declared', () => { + expect(dockerSandboxGrants(undefined, endpoint, homeDir)).toEqual(empty); + }); + + it('grants nothing when no daemon socket resolved', () => { + expect(dockerSandboxGrants('credentials', null, homeDir)).toEqual(empty); + }); + + it('grants the socket and injects DOCKER_HOST at socket level', () => { + expect(dockerSandboxGrants('socket', endpoint, homeDir)).toEqual({ + socketLiterals: ['/Users/dev/.docker/run/docker.sock'], + readLiterals: [], + readSubpaths: [], + env: { DOCKER_HOST: 'unix:///Users/dev/.docker/run/docker.sock' }, + }); + }); + + it('does not open config.json at socket level', () => { + const grants = dockerSandboxGrants('socket', endpoint, homeDir); + expect(grants.readLiterals).not.toContain('/Users/dev/.docker/config.json'); + }); + + it('adds the contexts directory at contexts level', () => { + const grants = dockerSandboxGrants('contexts', endpoint, homeDir); + expect(grants.readSubpaths).toEqual(['/Users/dev/.docker/contexts']); + expect(grants.readLiterals).toEqual([]); + }); + + it('adds config.json at credentials level, keeping the lower grants', () => { + const grants = dockerSandboxGrants('credentials', endpoint, homeDir); + expect(grants.socketLiterals).toEqual(['/Users/dev/.docker/run/docker.sock']); + expect(grants.readSubpaths).toEqual(['/Users/dev/.docker/contexts']); + expect(grants.readLiterals).toEqual(['/Users/dev/.docker/config.json']); + }); + + it('never grants the ~/.docker directory itself', () => { + for (const level of ['socket', 'contexts', 'credentials'] as const) { + const grants = dockerSandboxGrants(level, endpoint, homeDir); + expect(grants.readSubpaths).not.toContain('/Users/dev/.docker'); + } + }); +}); diff --git a/src/shared/docker-access.ts b/src/shared/docker-access.ts index cefcd39..8c89771 100644 --- a/src/shared/docker-access.ts +++ b/src/shared/docker-access.ts @@ -82,3 +82,51 @@ export function resolveDockerEndpoint(options?: { return null; } + +/** What a level opens in a sandbox profile. */ +export interface DockerGrants { + /** Socket paths to allow network-outbound, file-read* and file-write* on. */ + socketLiterals: string[]; + /** Single files to allow file-read* on. */ + readLiterals: string[]; + /** Directories to allow file-read* on. */ + readSubpaths: string[]; + /** Environment to inject into the job. */ + env: Record; +} + +/** Rank a level so cumulative comparisons read as comparisons. */ +const rank = (level: DockerAccessLevel): number => DOCKER_ACCESS_LEVELS.indexOf(level); + +/** + * What a declared level opens, given the resolved endpoint. Empty when the + * level is off or absent, or when no daemon socket was found - the declaration + * is a permission, not a requirement. + */ +export function dockerSandboxGrants( + level: DockerAccessLevel | undefined, + endpoint: DockerEndpoint | null, + homeDir: string +): DockerGrants { + if (!level || level === 'off' || !endpoint) { + return { socketLiterals: [], readLiterals: [], readSubpaths: [], env: {} }; + } + + const grants: DockerGrants = { + socketLiterals: [endpoint.socketPath], + readLiterals: [], + readSubpaths: [], + // The job never has to discover the endpoint, which is what lets the rest + // of ~/.docker stay closed at socket level. + env: { DOCKER_HOST: `unix://${endpoint.socketPath}` }, + }; + + if (rank(level) >= rank('contexts')) { + grants.readSubpaths.push(`${homeDir}/.docker/contexts`); + } + if (rank(level) >= rank('credentials')) { + grants.readLiterals.push(`${homeDir}/.docker/config.json`); + } + + return grants; +} From 451bfb8fcd9bb7a5fb8292da80bb526782871bca Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:04:47 -0400 Subject: [PATCH 06/13] Emit docker grants in the runner sandbox profile 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/main/process-sandbox.test.ts | 87 ++++++++++++++++++++++++++++++++ src/main/process-sandbox.ts | 43 ++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/src/main/process-sandbox.test.ts b/src/main/process-sandbox.test.ts index 1fca5d9..8f836a2 100644 --- a/src/main/process-sandbox.test.ts +++ b/src/main/process-sandbox.test.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; import { EventEmitter } from 'events'; +import { dockerSandboxGrants } from '../shared/docker-access'; // Mock fs jest.mock('fs', () => ({ @@ -263,6 +264,90 @@ describe('Process Sandbox', () => { jest.resetModules(); }); + describe('docker access in the runner profile', () => { + const endpoint = { socketPath: '/Users/dev/.docker/run/docker.sock' }; + + /** Build a profile with the grants a level produces, and return its text. */ + const profileFor = (level: 'off' | 'socket' | 'contexts' | 'credentials'): string => { + // Computed out here: the grants are plain data, and requiring the module + // inside the isolated registry below leaves the profile unwritten. + const dockerGrants = dockerSandboxGrants(level, endpoint, '/Users/dev'); + let profile = ''; + jest.isolateModules(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const mockProcess = createMockProcess(12360); + const localMockSpawn = jest.fn().mockReturnValue(mockProcess); + const mockWriteFileSync = jest.fn(); + jest.doMock('child_process', () => ({ spawn: localMockSpawn })); + jest.doMock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(true), + writeFileSync: mockWriteFileSync, + unlinkSync: jest.fn(), + mkdirSync: jest.fn(), + })); + + const { spawnSandboxed: sandboxedSpawn } = require('./process-sandbox'); + + const instanceDir = path.join(os.homedir(), '.localmost', 'runner-3'); + sandboxedSpawn(path.join(instanceDir, 'run.sh'), [], { + cwd: instanceDir, + dockerGrants, + }); + + profile = mockWriteFileSync.mock.calls[0][1]; + }); + return profile; + }; + + it('emits no docker rules when the level is off', () => { + expect(profileFor('off')).not.toContain('docker.sock'); + }); + + it('allows the resolved socket at socket level', () => { + const profile = profileFor('socket'); + expect(profile).toContain( + '(allow network-outbound (literal "/Users/dev/.docker/run/docker.sock"))' + ); + expect(profile).toContain( + '(allow file-write* (literal "/Users/dev/.docker/run/docker.sock"))' + ); + }); + + it('emits the socket allow after the deny block, so the literal wins', () => { + // The Docker Desktop socket lives inside the denied ~/.docker, and + // seatbelt takes the last matching rule. + const profile = profileFor('socket'); + const deny = profile.indexOf('(deny file-read*'); + const allow = profile.indexOf( + '(allow file-read* (literal "/Users/dev/.docker/run/docker.sock"))' + ); + expect(deny).toBeGreaterThan(-1); + expect(allow).toBeGreaterThan(deny); + }); + + it('keeps config.json denied at socket level', () => { + expect(profileFor('socket')).not.toContain('config.json'); + }); + + it('keeps config.json denied at contexts level, adding only the directory', () => { + const profile = profileFor('contexts'); + expect(profile).toContain('(allow file-read* (subpath "/Users/dev/.docker/contexts"))'); + expect(profile).not.toContain('config.json'); + }); + + it('allows config.json at credentials level', () => { + expect(profileFor('credentials')).toContain( + '(allow file-read* (literal "/Users/dev/.docker/config.json"))' + ); + }); + + it('never grants the ~/.docker directory itself', () => { + for (const level of ['socket', 'contexts', 'credentials'] as const) { + expect(profileFor(level)).not.toContain('(allow file-read* (subpath "/Users/dev/.docker"))'); + } + }); + }); + it('should use sandbox-exec on macOS', () => { // Re-require after platform change jest.isolateModules(() => { @@ -520,4 +605,6 @@ describe('Process Sandbox', () => { }); }); + + }); diff --git a/src/main/process-sandbox.ts b/src/main/process-sandbox.ts index 59df04e..0fa6273 100644 --- a/src/main/process-sandbox.ts +++ b/src/main/process-sandbox.ts @@ -14,6 +14,7 @@ import * as os from 'os'; import * as crypto from 'crypto'; import * as fs from 'fs'; import { SandboxPolicyLevel } from '../shared/types'; +import type { DockerGrants } from '../shared/docker-access'; import { expandPath } from '../shared/sandbox-profile'; import { getAppDataDir, @@ -136,6 +137,8 @@ interface RunnerProfileOptions { allowDirectNetwork?: boolean; /** The repository's approved policy; strict with nothing declared by default. */ filesystemPolicy?: SandboxFilesystemPolicy; + /** What the repository's declared docker level opens; empty when off. */ + dockerGrants?: DockerGrants; } function generateSandboxProfile({ @@ -143,7 +146,39 @@ function generateSandboxProfile({ brokerPort = DEFAULT_BROKER_PORT, allowDirectNetwork = false, filesystemPolicy = { level: 'strict', read: [], write: [] }, + dockerGrants, }: RunnerProfileOptions): string { + // Docker access, if the repository declared and had a level approved. + // + // These come after the deny block below on purpose: the Docker Desktop socket + // lives inside ~/.docker, which is denied wholesale, and seatbelt takes the + // last matching rule. Each grant is a single literal, never the directory. + 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}"))`); + } + + if (lines.length === 0) return ''; + return [ + ';; Docker access, declared by the repository policy and approved. A job', + ';; that can reach the daemon can bind-mount host paths into a container,', + ';; which this profile cannot constrain. See docs/roadmap/docker-access.md.', + ...lines, + '', + ].join('\n'); + })(dockerGrants); + const escapedDir = instanceDir.replace(/"/g, '\\"'); const homeDir = os.homedir().replace(/"/g, '\\"'); const appDataDir = getRunnerBaseDir().replace(/"/g, '\\"'); @@ -328,6 +363,7 @@ ${policyReads} (literal "${homeDir}/.cargo/credentials") (literal "${homeDir}/.cargo/credentials.toml") (literal "${homeDir}/.nuget/NuGet/NuGet.Config")) +${dockerRules} ;; Device files that need read/write access (git, many tools redirect to /dev/null) (allow file-write* @@ -444,6 +480,11 @@ export interface SandboxOptions extends SpawnOptions { * job gets. Absent means strict with nothing declared. */ filesystemPolicy?: SandboxFilesystemPolicy; + /** + * What the repository's declared docker level opens. A job that can reach + * the daemon is not confined by this profile: see docs/roadmap/docker-access.md. + */ + dockerGrants?: DockerGrants; /** Log prefix for identifying this process (e.g., runner instance ID) */ logPrefix?: string; /** Optional callback for logging sandbox events */ @@ -489,6 +530,7 @@ export function spawnSandboxed( const { allowDirectNetwork, filesystemPolicy, + dockerGrants, logPrefix, onLog, ...spawnOptions @@ -507,6 +549,7 @@ export function spawnSandboxed( instanceDir, allowDirectNetwork, filesystemPolicy, + dockerGrants, }); // The profile is the thing that confines the job, so it must not live From e7c47ec16e4025e4ef72019bba63b0bd938b0f91 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:05:53 -0400 Subject: [PATCH 07/13] Emit docker grants in the localmost test profile Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/shared/sandbox-profile.test.ts | 39 ++++++++++++++++++++++++++++++ src/shared/sandbox-profile.ts | 34 +++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/shared/sandbox-profile.test.ts b/src/shared/sandbox-profile.test.ts index 970caa1..d775d06 100644 --- a/src/shared/sandbox-profile.test.ts +++ b/src/shared/sandbox-profile.test.ts @@ -554,3 +554,42 @@ describe('Sandbox Profile Generator', () => { }); }); }); + +describe('docker access in the test-mode profile', () => { + const base = { + workDir: '/Users/dev/project', + proxyPort: 8080, + homeDir: '/Users/dev', + dockerEndpoint: { socketPath: '/Users/dev/.docker/run/docker.sock' }, + }; + + it('emits no docker rules without a declared level', () => { + const profile = generateSandboxProfile({ ...base, policy: {} }); + expect(profile).not.toContain('docker.sock'); + }); + + it('allows the socket at socket level', () => { + const profile = generateSandboxProfile({ ...base, policy: { docker: 'socket' } }); + expect(profile).toContain( + '(allow network-outbound (literal "/Users/dev/.docker/run/docker.sock"))' + ); + }); + + it('allows config.json only at credentials level', () => { + const contexts = generateSandboxProfile({ ...base, policy: { docker: 'contexts' } }); + const credentials = generateSandboxProfile({ ...base, policy: { docker: 'credentials' } }); + expect(contexts).not.toContain('config.json'); + expect(credentials).toContain( + '(allow file-read* (literal "/Users/dev/.docker/config.json"))' + ); + }); + + it('emits nothing when no daemon socket resolved', () => { + const profile = generateSandboxProfile({ + ...base, + dockerEndpoint: null, + policy: { docker: 'credentials' }, + }); + expect(profile).not.toContain('docker.sock'); + }); +}); diff --git a/src/shared/sandbox-profile.ts b/src/shared/sandbox-profile.ts index b11beb2..c9eb7cd 100644 --- a/src/shared/sandbox-profile.ts +++ b/src/shared/sandbox-profile.ts @@ -8,7 +8,11 @@ import * as os from 'os'; import * as path from 'path'; -import type { DockerAccessLevel } from './docker-access'; +import { + dockerSandboxGrants, + type DockerAccessLevel, + type DockerEndpoint, +} from './docker-access'; // ============================================================================= // Types @@ -51,6 +55,10 @@ export interface SandboxProfileOptions { proxyPort: number; /** Policy to enforce */ policy?: SandboxPolicy; + /** Resolved Docker endpoint, or null when none was found. */ + dockerEndpoint?: DockerEndpoint | null; + /** Home directory, for the ~/.docker paths a level opens. */ + homeDir?: string; /** Whether to run in permissive mode (log violations but don't block) */ permissive?: boolean; /** Log file for sandbox violations */ @@ -375,6 +383,30 @@ export function generateSandboxProfile(options: SandboxProfileOptions): string { lines.push(''); } + // Docker access, from the level the policy declared. The same grants the + // runner profile emits, so localmost test predicts what the runner does. + const dockerGrants = dockerSandboxGrants( + policy?.docker, + options.dockerEndpoint ?? null, + options.homeDir ?? os.homedir() + ); + if (dockerGrants.socketLiterals.length > 0) { + lines.push(';; Docker access declared by the repository policy'); + for (const socket of dockerGrants.socketLiterals) { + const escaped = escapePath(socket); + lines.push(`(allow network-outbound (literal "${escaped}"))`); + lines.push(`(allow file-read* (literal "${escaped}"))`); + lines.push(`(allow file-write* (literal "${escaped}"))`); + } + for (const file of dockerGrants.readLiterals) { + lines.push(`(allow file-read* (literal "${escapePath(file)}"))`); + } + for (const dir of dockerGrants.readSubpaths) { + lines.push(`(allow file-read* (subpath "${escapePath(dir)}"))`); + } + lines.push(''); + } + // ------------------------------------------------------------ // PROCESS OPERATIONS // ------------------------------------------------------------ From 5329d6c891b64977a9644ac36b8b6860ea7dda47 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:08:42 -0400 Subject: [PATCH 08/13] Carry the docker level from approved policy into the worker profile 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/main/index.ts | 11 ++++- src/main/runner-manager.test.ts | 72 +++++++++++++++++++++++++++++---- src/main/runner-manager.ts | 47 +++++++++++++++++++-- 3 files changed, 117 insertions(+), 13 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 8f0c75e..39a1d77 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -312,7 +312,13 @@ app.whenReady().then(async () => { // declared in the same file and approved with the rest of it. const cached = getCachedPolicy(`${owner}/${repo}`); if (!cached?.approved) { - return { hosts: [], level: 'strict' as const, readPaths: [], writePaths: [] }; + return { + hosts: [], + level: 'strict' as const, + readPaths: [], + writePaths: [], + docker: 'off' as const, + }; } const policy = getEffectivePolicy(cached.config, workflowName); return { @@ -326,6 +332,9 @@ app.whenReady().then(async () => { // policy drift. readPaths: cached.config.shared?.filesystem?.read || [], writePaths: cached.config.shared?.filesystem?.write || [], + // Docker access, like filesystem, comes from the shared section only: + // the profile is built before the workflow is known. + docker: cached.config.shared?.docker ?? 'off', }; }, onJobEvent: (event: JobEvent) => { diff --git a/src/main/runner-manager.test.ts b/src/main/runner-manager.test.ts index efe5889..8099c0e 100644 --- a/src/main/runner-manager.test.ts +++ b/src/main/runner-manager.test.ts @@ -627,7 +627,7 @@ describe('RunnerManager', () => { onLog: mockOnLog, onStatusChange: mockOnStatusChange, onJobHistoryUpdate: mockOnJobHistoryUpdate, - getRepoPolicy: async () => ({ hosts: ['index.crates.io'], level: 'strict' as const, readPaths: [], writePaths: [] }), + getRepoPolicy: async () => ({ hosts: ['index.crates.io'], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const }), }); const helper = new RunnerManagerTestHelper(manager); helper.setInstance(1, { @@ -654,7 +654,7 @@ describe('RunnerManager', () => { // whenever the job could not be identified, and the job ran with no // hosts - four concurrent runs failed that way before this changed. const setPolicyAllowedHosts = jest.fn(); - const getRepoPolicy = jest.fn().mockResolvedValue({ hosts: [], level: 'strict', readPaths: [], writePaths: [] } as never); + const getRepoPolicy = jest.fn().mockResolvedValue({ hosts: [], level: 'strict', readPaths: [], writePaths: [], docker: 'off' as const } as never); const manager = new RunnerManager({ onLog: mockOnLog, onStatusChange: mockOnStatusChange, @@ -691,8 +691,8 @@ describe('RunnerManager', () => { onJobHistoryUpdate: mockOnJobHistoryUpdate, getRepoPolicy: async (owner: string, repo: string) => repo === 'first' - ? { hosts: ['first.example'], level: 'strict' as const, readPaths: [], writePaths: [] } - : { hosts: ['second.example'], level: 'strict' as const, readPaths: [], writePaths: [] }, + ? { hosts: ['first.example'], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const } + : { hosts: ['second.example'], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const }, }); const helper = new RunnerManagerTestHelper(manager); helper.setProxy(1, { setPolicyAllowedHosts, setPolicyLevel: jest.fn() }); @@ -728,7 +728,7 @@ describe('RunnerManager', () => { onLog: mockOnLog, onStatusChange: mockOnStatusChange, onJobHistoryUpdate: mockOnJobHistoryUpdate, - getRepoPolicy: async () => ({ hosts: ['codeload.github.com'], level: 'strict' as const, readPaths: [], writePaths: [] }), + getRepoPolicy: async () => ({ hosts: ['codeload.github.com'], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const }), }); const helper = new RunnerManagerTestHelper(manager); helper.setPendingTargetContext('3', { @@ -766,6 +766,7 @@ describe('RunnerManager', () => { level: 'strict' as const, readPaths: [], writePaths: [], + docker: 'off' as const, }), }); const helper = new RunnerManagerTestHelper(manager); @@ -801,6 +802,7 @@ describe('RunnerManager', () => { level: 'strict' as const, readPaths: [], writePaths: [], + docker: 'off' as const, }), }); const helper = new RunnerManagerTestHelper(manager); @@ -840,6 +842,7 @@ describe('RunnerManager', () => { level: 'strict' as const, readPaths: [], writePaths: [], + docker: 'off' as const, }), }); const helper = new RunnerManagerTestHelper(manager); @@ -876,11 +879,17 @@ describe('RunnerManager', () => { level: 'strict' as const, readPaths: ['~/.npm'], writePaths: ['~/.npm'], + docker: 'off' as const, }), }); const helper = new RunnerManagerTestHelper(manager); const stamped = manager as unknown as { - stampFor(p: { level: string; readPaths: string[]; writePaths: string[] }): string; + stampFor(p: { + level: string; + readPaths: string[]; + writePaths: string[]; + docker: string; + }): string; }; helper.setInstance(1, { name: 'runner-1', @@ -889,6 +898,7 @@ describe('RunnerManager', () => { level: 'strict', readPaths: ['~/.npm'], writePaths: ['~/.npm'], + docker: 'off' as const, }), currentJob: { name: 'build', @@ -917,6 +927,7 @@ describe('RunnerManager', () => { level: 'strict' as const, readPaths: [], writePaths: [], + docker: 'off' as const, }), }); const helper = new RunnerManagerTestHelper(manager); @@ -949,7 +960,7 @@ describe('RunnerManager', () => { onLog: mockOnLog, onStatusChange: mockOnStatusChange, onJobHistoryUpdate: mockOnJobHistoryUpdate, - getRepoPolicy: async () => ({ hosts: [], level: 'moderate' as const, readPaths: [], writePaths: [] }), + getRepoPolicy: async () => ({ hosts: [], level: 'moderate' as const, readPaths: [], writePaths: [], docker: 'off' as const }), }); const helper = new RunnerManagerTestHelper(manager); helper.setInstance(1, { @@ -977,7 +988,7 @@ describe('RunnerManager', () => { onLog: mockOnLog, onStatusChange: mockOnStatusChange, onJobHistoryUpdate: mockOnJobHistoryUpdate, - getRepoPolicy: async () => ({ hosts: [], level: 'strict' as const, readPaths: [], writePaths: [] }), + getRepoPolicy: async () => ({ hosts: [], level: 'strict' as const, readPaths: [], writePaths: [], docker: 'off' as const }), }); const helper = new RunnerManagerTestHelper(manager); helper.setInstance(1, { @@ -1150,3 +1161,48 @@ describe('RunnerManager', () => { }); }); }); + +describe('docker access', () => { + const makeManager = () => + new RunnerManager({ + onLog: jest.fn(), + onStatusChange: jest.fn(), + onJobHistoryUpdate: jest.fn(), + }); + + it('changes the policy stamp when the docker level changes', () => { + const manager = makeManager(); + const stamp = (docker: 'off' | 'socket') => + (manager as any).stampFor({ + level: 'strict', + readPaths: [], + writePaths: [], + docker, + }); + + // A worker spawned under one level must not claim a job approved under + // another: the grant is baked into the profile at spawn. + expect(stamp('off')).not.toEqual(stamp('socket')); + }); + + it('warns when a policy declares docker but no daemon socket resolved', () => { + const manager = makeManager(); + const logged: string[] = []; + (manager as any).log = (_level: string, message: string) => logged.push(message); + + (manager as any).warnIfDockerUnavailable('socket', null); + + expect(logged.join('\n')).toMatch(/docker/i); + expect(logged.join('\n')).toMatch(/no daemon socket/i); + }); + + it('says nothing when no docker level was declared', () => { + const manager = makeManager(); + const logged: string[] = []; + (manager as any).log = (_level: string, message: string) => logged.push(message); + + (manager as any).warnIfDockerUnavailable('off', null); + + expect(logged).toEqual([]); + }); +}); diff --git a/src/main/runner-manager.ts b/src/main/runner-manager.ts index e8ff6ca..dbb3ec4 100644 --- a/src/main/runner-manager.ts +++ b/src/main/runner-manager.ts @@ -4,6 +4,12 @@ import { createHash } from 'crypto'; import * as fs from 'fs'; import * as os from 'os'; import * as yaml from 'js-yaml'; +import { + dockerSandboxGrants, + resolveDockerEndpoint, + type DockerAccessLevel, + type DockerEndpoint, +} from '../shared/docker-access'; import { SandboxPolicyLevel, RunnerState, RunnerStatus, LogEntry, RunnerConfig, JobHistoryEntry, JobStatus, LOG_LEVEL_PRIORITY, LogLevel, UserFilterConfig, SANDBOX_POLICY_LEVEL_DESCRIPTIONS } from '../shared/types'; import { DEFAULT_RUNNER_COUNT, DEFAULT_MAX_JOB_HISTORY, MIN_RUNNER_COUNT, MAX_RUNNER_COUNT } from '../shared/constants'; @@ -72,6 +78,8 @@ export interface RepoPolicyRuntime { readPaths: string[]; /** Paths the policy declares writable, applied when the worker is spawned. */ writePaths: string[]; + /** Docker access the policy declares; 'off' when it declares none. */ + docker: DockerAccessLevel; } interface RunnerManagerOptions { @@ -910,13 +918,24 @@ export class RunnerManager { const startupContextForPolicy = this.pendingTargetContext.get(String(instanceNum)); const filesystemPolicy = await this.resolveFilesystemPolicy(startupContextForPolicy); + // Resolved here, outside the sandbox, so the job never has to discover + // the endpoint - which is what lets ~/.docker stay closed at socket level. + const dockerEndpoint = resolveDockerEndpoint(); + this.warnIfDockerUnavailable(filesystemPolicy.docker, dockerEndpoint); + const dockerGrants = dockerSandboxGrants( + filesystemPolicy.docker, + dockerEndpoint, + os.homedir() + ); + instance.process = spawnSandboxed(runnerBinary, ['--once'], { cwd: sandboxDir, - env, + env: { ...env, ...dockerGrants.env }, stdio: ['ignore', 'pipe', 'pipe'], // Create a new process group so we can kill all child processes detached: true, filesystemPolicy, + dockerGrants, }); instance.policyStamp = filesystemPolicy.stamp; @@ -1443,12 +1462,30 @@ export class RunnerManager { * with a per-workflow network section look like it had drifted and its jobs * were refused. Only what the profile fixed at spawn belongs here. */ - private stampFor(policy: Pick): string { + private stampFor( + policy: Pick + ): string { return createHash('sha256') - .update(JSON.stringify([policy.level, policy.readPaths, policy.writePaths])) + .update(JSON.stringify([policy.level, policy.readPaths, policy.writePaths, policy.docker])) .digest('hex'); } + /** + * A declared level with no reachable daemon runs without the grant. Say so: + * the job must not look like it had access it did not get. + */ + private warnIfDockerUnavailable( + level: DockerAccessLevel, + endpoint: DockerEndpoint | null + ): void { + if (level === 'off' || endpoint) return; + this.log( + 'warn', + `Policy declares docker: ${level}, but no daemon socket resolved - ` + + 'running without Docker access' + ); + } + /** * The filesystem boundary for a worker about to be spawned. * @@ -1493,13 +1530,14 @@ export class RunnerManager { private async resolveFilesystemPolicy( context?: { targetDisplayName?: string; githubSha?: string } - ): Promise { + ): Promise { // No stamp rather than a sentinel: a sentinel is truthy, so it would fail // the drift check against every real hash and the worker would refuse // every job. The profile it got is the closed one, which is the safe // state to run under, so there is nothing to detect drift from. const closed = { level: 'strict' as SandboxPolicyLevel, + docker: 'off' as DockerAccessLevel, read: [], write: [], stamp: undefined, @@ -1513,6 +1551,7 @@ export class RunnerManager { const policy = await this.getRepoPolicy(repoInfo.owner, repoInfo.repo, context.githubSha, ''); return { level: policy.level, + docker: policy.docker, read: policy.readPaths, write: policy.writePaths, stamp: this.stampFor(policy), From bdcb9243c7ed19b382c7ee46a073bc3744768c55 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:11:51 -0400 Subject: [PATCH 09/13] Remove the sockets key, superseded by docker 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/cli/test.ts | 25 +++++++---------- src/shared/localmostrc.test.ts | 29 ++++++++++++++++++++ src/shared/localmostrc.ts | 49 ++++++++-------------------------- src/shared/sandbox-profile.ts | 21 --------------- src/shared/step-executor.ts | 15 ++++++++++- 5 files changed, 64 insertions(+), 75 deletions(-) diff --git a/src/cli/test.ts b/src/cli/test.ts index c0e908e..3597d46 100644 --- a/src/cli/test.ts +++ b/src/cli/test.ts @@ -1141,18 +1141,24 @@ async function handleUpdateRc( } } - // Report socket access + // Report socket access. There is no policy key for arbitrary sockets: the + // only socket a policy can ask for is the Docker daemon, via `docker:`. if (socketPaths.length > 0) { - console.log(` Sockets: ${socketPaths.length} socket(s) need access`); + console.log(` Sockets: ${socketPaths.length} socket(s) were reached`); for (const p of socketPaths) { console.log(` ${colors.dim}- ${p}${colors.reset}`); } + if (socketPaths.some(p => p.includes('docker.sock'))) { + console.log( + ` ${colors.yellow}Declare Docker access with \`docker: socket\` in shared${colors.reset}` + ); + } } console.log(); // Check if there's anything to add - if (discoveredHosts.length === 0 && readPaths.length === 0 && writePaths.length === 0 && socketPaths.length === 0) { + if (discoveredHosts.length === 0 && readPaths.length === 0 && writePaths.length === 0) { console.log(`${colors.yellow}No access to configure.${colors.reset}`); console.log('This may happen if:'); console.log(' - Your workflow doesn\'t make network requests'); @@ -1178,10 +1184,7 @@ async function handleUpdateRc( const existingWritePaths = new Set(existing.shared?.filesystem?.write || []); const newWritePaths = writePaths.filter(p => !existingWritePaths.has(p)); - const existingSocketPaths = new Set(existing.shared?.sockets?.allow || []); - const newSocketPaths = socketPaths.filter(p => !existingSocketPaths.has(p)); - - if (newHosts.length === 0 && newReadPaths.length === 0 && newWritePaths.length === 0 && newSocketPaths.length === 0) { + if (newHosts.length === 0 && newReadPaths.length === 0 && newWritePaths.length === 0) { console.log(`${colors.green}✓${colors.reset} ${path.relative(cwd, existingPath)} already includes all discovered access.`); return; } @@ -1200,10 +1203,6 @@ async function handleUpdateRc( read: newReadPaths.length > 0 ? [...(existing.shared?.filesystem?.read || []), ...newReadPaths] : existing.shared?.filesystem?.read, write: newWritePaths.length > 0 ? [...(existing.shared?.filesystem?.write || []), ...newWritePaths] : existing.shared?.filesystem?.write, } : undefined, - sockets: newSocketPaths.length > 0 || existing.shared?.sockets ? { - ...existing.shared?.sockets, - allow: [...(existing.shared?.sockets?.allow || []), ...newSocketPaths], - } : undefined, }, }; @@ -1212,7 +1211,6 @@ async function handleUpdateRc( { label: 'network.allow', items: newHosts }, { label: 'filesystem.read', items: newReadPaths }, { label: 'filesystem.write', items: newWritePaths }, - { label: 'sockets.allow', items: newSocketPaths }, ], assumeYes ); @@ -1236,9 +1234,6 @@ async function handleUpdateRc( read: readPaths.length > 0 ? readPaths : undefined, write: writePaths.length > 0 ? writePaths : undefined, } : undefined, - sockets: socketPaths.length > 0 ? { - allow: socketPaths, - } : undefined, }, workflows: { [workflow.name]: {}, diff --git a/src/shared/localmostrc.test.ts b/src/shared/localmostrc.test.ts index 2e76a92..1a43b84 100644 --- a/src/shared/localmostrc.test.ts +++ b/src/shared/localmostrc.test.ts @@ -823,3 +823,32 @@ describe('docker access', () => { expect(result.errors[0].message).toMatch(/shared/); }); }); + +describe('removed sockets key', () => { + it('rejects sockets, pointing at docker', () => { + const result = parseLocalmostrcContent( + 'version: 1\nshared:\n sockets:\n allow:\n - /var/run/docker.sock\n' + ); + expect(result.success).toBe(false); + expect(result.errors[0].message).toMatch(/docker:/); + }); +}); + +describe('docker level through policy merging', () => { + it('keeps the shared docker level in the effective policy for a workflow', () => { + // localmost test builds its profile from the effective policy, so a level + // dropped here would apply on the runner and not locally. + const config = { + version: 1, + shared: { docker: 'socket' as const, network: { allow: ['github.com'] } }, + workflows: { build: { network: { allow: ['npmjs.org'] } } }, + }; + + expect(getEffectivePolicy(config, 'build').docker).toBe('socket'); + }); + + it('keeps the shared docker level for a workflow with no overrides', () => { + const config = { version: 1, shared: { docker: 'credentials' as const } }; + expect(getEffectivePolicy(config, 'anything').docker).toBe('credentials'); + }); +}); diff --git a/src/shared/localmostrc.ts b/src/shared/localmostrc.ts index 7b9ef7d..a1c3ac8 100644 --- a/src/shared/localmostrc.ts +++ b/src/shared/localmostrc.ts @@ -6,7 +6,7 @@ import * as yaml from 'js-yaml'; import * as fs from 'fs'; import * as path from 'path'; -import { SandboxPolicy, NetworkPolicy, FilesystemPolicy, SocketsPolicy, EnvPolicy } from './sandbox-profile'; +import { SandboxPolicy, NetworkPolicy, FilesystemPolicy, EnvPolicy } from './sandbox-profile'; import { SandboxPolicyLevel } from './types'; import { DOCKER_ACCESS_LEVELS, isDockerAccessLevel } from './docker-access'; @@ -224,9 +224,14 @@ function validatePolicy(policy: unknown, path: string, errors: ParseError[]): vo validateFilesystemPolicy(p.filesystem, `${path}.filesystem`, errors); } - // Validate sockets policy + // Removed in favour of docker:, which the runner applies as well as + // localmost test, and which cannot name an arbitrary socket. if (p.sockets !== undefined) { - validateSocketsPolicy(p.sockets, `${path}.sockets`, errors); + errors.push({ + message: + `${path}.sockets is no longer supported. Use \`docker:\` in shared to ` + + 'declare Docker access (off, socket, contexts, credentials).', + }); } // Validate env policy @@ -287,19 +292,6 @@ function validateFilesystemPolicy(policy: unknown, path: string, errors: ParseEr } } -function validateSocketsPolicy(policy: unknown, path: string, errors: ParseError[]): void { - if (typeof policy !== 'object' || policy === null) { - errors.push({ message: `${path} must be an object` }); - return; - } - - const p = policy as Record; - - if (p.allow !== undefined) { - validateStringArray(p.allow, `${path}.allow`, errors); - } -} - function validateEnvPolicy(policy: unknown, path: string, errors: ParseError[]): void { if (typeof policy !== 'object' || policy === null) { errors.push({ message: `${path} must be an object` }); @@ -403,16 +395,6 @@ function mergeFilesystemPolicy( /** * Merge sockets policies. */ -function mergeSocketsPolicy(base?: SocketsPolicy, override?: SocketsPolicy): SocketsPolicy | undefined { - if (!base && !override) { - return undefined; - } - - return { - allow: mergeArrays(base?.allow, override?.allow), - }; -} - /** * Merge env policies. */ @@ -435,8 +417,10 @@ export function mergePolicies(base: SandboxPolicy, override: SandboxPolicy): San return { network: mergeNetworkPolicy(base.network, override.network), filesystem: mergeFilesystemPolicy(base.filesystem, override.filesystem), - sockets: mergeSocketsPolicy(base.sockets, override.sockets), env: mergeEnvPolicy(base.env, override.env), + // Docker access is declared in shared and nowhere else: a workflow cannot + // raise or lower it, so the base value carries through unchanged. + docker: base.docker, }; } @@ -541,14 +525,6 @@ function serializePolicy(policy: SandboxPolicy, indent: string): string[] { } } - if (policy.sockets?.allow?.length) { - lines.push(`${indent}sockets:`); - lines.push(`${indent} allow:`); - for (const socketPath of policy.sockets.allow) { - lines.push(`${indent} - "${socketPath}"`); - } - } - if (policy.env) { lines.push(`${indent}env:`); if (policy.env.allow?.length) { @@ -626,9 +602,6 @@ function diffPolicies( diffArrays(oldPolicy.filesystem?.write, newPolicy.filesystem?.write, `${prefix}.filesystem.write`, diffs); diffArrays(oldPolicy.filesystem?.deny, newPolicy.filesystem?.deny, `${prefix}.filesystem.deny`, diffs); - // Sockets - diffArrays(oldPolicy.sockets?.allow, newPolicy.sockets?.allow, `${prefix}.sockets.allow`, diffs); - // Env diffArrays(oldPolicy.env?.allow, newPolicy.env?.allow, `${prefix}.env.allow`, diffs); diffArrays(oldPolicy.env?.deny, newPolicy.env?.deny, `${prefix}.env.deny`, diffs); diff --git a/src/shared/sandbox-profile.ts b/src/shared/sandbox-profile.ts index c9eb7cd..e1343be 100644 --- a/src/shared/sandbox-profile.ts +++ b/src/shared/sandbox-profile.ts @@ -29,11 +29,6 @@ export interface FilesystemPolicy { deny?: string[]; } -export interface SocketsPolicy { - /** Unix domain socket paths to allow connections to (e.g., /var/run/docker.sock) */ - allow?: string[]; -} - export interface EnvPolicy { allow?: string[]; deny?: string[]; @@ -42,7 +37,6 @@ export interface EnvPolicy { export interface SandboxPolicy { network?: NetworkPolicy; filesystem?: FilesystemPolicy; - sockets?: SocketsPolicy; env?: EnvPolicy; /** Docker daemon access. Read from `shared:` only - see docker-access.ts. */ docker?: DockerAccessLevel; @@ -368,21 +362,6 @@ export function generateSandboxProfile(options: SandboxProfileOptions): string { lines.push(`(allow network-outbound (subpath "${escapedWorkDir}"))`); lines.push(''); - // Policy-defined socket access - if (policy?.sockets?.allow) { - lines.push(';; Policy-defined socket access'); - for (const socketPath of policy.sockets.allow) { - const expanded = expandPath(socketPath); - const escaped = escapePath(expanded); - // Allow both bind and outbound for socket paths - lines.push(`(allow network-bind (literal "${escaped}"))`); - lines.push(`(allow network-outbound (literal "${escaped}"))`); - // Also need file-write for socket operations - lines.push(`(allow file-write* (literal "${escaped}"))`); - } - lines.push(''); - } - // Docker access, from the level the policy declared. The same grants the // runner profile emits, so localmost test predicts what the runner does. const dockerGrants = dockerSandboxGrants( diff --git a/src/shared/step-executor.ts b/src/shared/step-executor.ts index 416e1f0..82df64f 100644 --- a/src/shared/step-executor.ts +++ b/src/shared/step-executor.ts @@ -11,6 +11,7 @@ import * as os from 'os'; import { spawn, SpawnOptions } from 'child_process'; import { WorkflowStep, WorkflowJob, MatrixCombination } from './workflow-parser'; import { SandboxPolicy, generateSandboxProfile, generateDiscoveryProfile } from './sandbox-profile'; +import { dockerSandboxGrants, resolveDockerEndpoint } from './docker-access'; import { PidTreeWatcher } from './pid-tree-watch'; import { parseActionRef, fetchAction, isInterceptedAction, readActionMetadata } from './action-fetcher'; import { getGitInfo } from './workspace'; @@ -1107,6 +1108,10 @@ async function runInSandbox( permissive: false, strictMode, 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(), }); } @@ -1135,9 +1140,17 @@ async function runInSandbox( spawnArgs = args; } + // The runner sets DOCKER_HOST for a job whose policy declares docker, so + // the CLI does too: the point of test mode is predicting the runner. + const dockerEnv = dockerSandboxGrants( + policy?.docker, + resolveDockerEndpoint(), + os.homedir() + ).env; + const spawnOptions: SpawnOptions = { cwd: options.cwd, - env: options.env, + env: { ...options.env, ...dockerEnv }, shell: false, stdio: ['ignore', 'pipe', 'pipe'], }; From 50c6e34a8ec1317edacef2c49593a0ad0181cd92 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:12:38 -0400 Subject: [PATCH 10/13] Surface docker level changes in the policy diff Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/shared/localmostrc.test.ts | 34 ++++++++++++++++++++++++++++++++++ src/shared/localmostrc.ts | 20 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/shared/localmostrc.test.ts b/src/shared/localmostrc.test.ts index 1a43b84..0fc5a41 100644 --- a/src/shared/localmostrc.test.ts +++ b/src/shared/localmostrc.test.ts @@ -852,3 +852,37 @@ describe('docker level through policy merging', () => { expect(getEffectivePolicy(config, 'anything').docker).toBe('credentials'); }); }); + +describe('docker level in the approval diff', () => { + it('reports a docker level change as its own diff entry', () => { + // With the repo policy as the only gate, the diff shown at approval time + // is the whole of the access control for this capability. + const before = { version: 1, shared: { docker: 'off' as const } }; + const after = { version: 1, shared: { docker: 'credentials' as const } }; + + const docker = diffConfigs(before, after).find(d => d.path === 'shared.docker'); + + expect(docker).toEqual({ + path: 'shared.docker', + type: 'changed', + oldValue: 'off', + newValue: 'credentials', + }); + }); + + it('reports newly declared docker access as added', () => { + const diffs = diffConfigs({ version: 1 }, { version: 1, shared: { docker: 'socket' as const } }); + expect(diffs.find(d => d.path === 'shared.docker')?.type).toBe('added'); + }); + + it('reports removed docker access', () => { + const diffs = diffConfigs({ version: 1, shared: { docker: 'socket' as const } }, { version: 1 }); + expect(diffs.find(d => d.path === 'shared.docker')?.type).toBe('removed'); + }); + + it('round-trips a docker level through serialization', () => { + const config = { version: 1, shared: { docker: 'contexts' as const } }; + const reparsed = parseLocalmostrcContent(serializeLocalmostrc(config)); + expect(reparsed.config?.shared?.docker).toBe('contexts'); + }); +}); diff --git a/src/shared/localmostrc.ts b/src/shared/localmostrc.ts index a1c3ac8..f39ea1d 100644 --- a/src/shared/localmostrc.ts +++ b/src/shared/localmostrc.ts @@ -487,6 +487,10 @@ export function serializeLocalmostrc(config: LocalmostrcConfig): string { function serializePolicy(policy: SandboxPolicy, indent: string): string[] { const lines: string[] = []; + if (policy.docker !== undefined) { + lines.push(`${indent}docker: ${policy.docker}`); + } + if (policy.network) { lines.push(`${indent}network:`); if (policy.network.allow?.length) { @@ -602,6 +606,22 @@ function diffPolicies( diffArrays(oldPolicy.filesystem?.write, newPolicy.filesystem?.write, `${prefix}.filesystem.write`, diffs); diffArrays(oldPolicy.filesystem?.deny, newPolicy.filesystem?.deny, `${prefix}.filesystem.deny`, diffs); + // Docker access. Scalar, and the largest change this section can make: + // above `off` the job is no longer confined by the sandbox at all. + if (oldPolicy.docker !== newPolicy.docker) { + diffs.push({ + path: `${prefix}.docker`, + type: + oldPolicy.docker === undefined + ? 'added' + : newPolicy.docker === undefined + ? 'removed' + : 'changed', + oldValue: oldPolicy.docker, + newValue: newPolicy.docker, + }); + } + // Env diffArrays(oldPolicy.env?.allow, newPolicy.env?.allow, `${prefix}.env.allow`, diffs); diffArrays(oldPolicy.env?.deny, newPolicy.env?.deny, `${prefix}.env.deny`, diffs); From e8fb6d06fc92bc3976ea590d55369f214a9f4c45 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:13:37 -0400 Subject: [PATCH 11/13] Document docker access and what it gives up Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- CHANGELOG.md | 8 ++++++++ README.md | 2 +- SECURITY.md | 11 +++++++++++ docs/roadmap/docker-access.md | 4 ++-- docs/roadmap/localmostrc.md | 20 ++++++++++++++++++++ 5 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e822d..fe03ab8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Theme: Test Locally, Secure by Default. Catch workflow problems before pushing, and enforce least-privilege sandboxing. ### Added +- **Opt-in Docker access**: an approved `.localmostrc` may declare + `docker: socket | contexts | credentials` to let jobs reach the Docker daemon, + applied by the runner and `localmost test` alike. Default off. A job with Docker + access is not sandboxed - see `docs/roadmap/docker-access.md` - **Workflow Test Mode**: Run workflows locally before pushing with `localmost test` - Intercepts `actions/checkout` to use local working tree - Intercepts `actions/cache` for local caching @@ -56,6 +60,10 @@ Theme: Test Locally, Secure by Default. Catch workflow problems before pushing, - Compare against any GitHub runner label - Suggestions for pinning versions in workflows +### Removed +- **`sockets:` policy key**: it was honoured by `localmost test` only, never by the + runner, and accepted arbitrary socket paths. Declare `docker:` instead + ### Security - Secret values are masked out of step output. A step that printed one - `set -x`, a tool dumping its config - previously spilled it into the console and the log diff --git a/README.md b/README.md index af704e7..56177b8 100644 --- a/README.md +++ b/README.md @@ -221,13 +221,13 @@ Current release: **0.3.0 — Test Locally, Secure by Default** - Sandbox policy levels (strict / moderate / permissive) declared per repository and enforced by the local proxy - Contributor-based job filtering for public repos - Repository policies require approval before the runner applies them +- Opt-in [Docker daemon access](docs/roadmap/docker-access.md) declared per repo, off by default - Environment comparison with GitHub runners Future feature ideas: - **Fail a blocked job visibly** - a job refused by the filter is cancelled through the GitHub API before any worker starts, so it appears as cancelled rather than failing with a message explaining why. - **Roll discovery output up further** - `--updaterc` now drops paths already covered by a listed ancestor, which removes the bulk of the redundancy. It still records content-addressed cache paths (npm's `_cacache/content-v2/sha512/...`) verbatim, which differ per machine and per dependency change; those want rolling up to their cache directory. -- **[Docker access](docs/roadmap/docker-access.md)** - an opt-in `.localmostrc` key letting an approved repo reach the Docker daemon, at a declared level, since container workflows currently have no way to ask for the socket. - **Approve policies in the app** - approval is CLI-only today (`localmost policy diff`, `localmost policy approve`). The app refuses the job and logs the diff, but there is no UI to review and accept it, and no audit log of approvals. - **Show a diff when `--updaterc` rewrites a policy** - it writes directly, with no diff and no confirmation, so a discovery run can widen a checked-in policy without the change being obvious. - **Homebrew formula** - `npx localmost` works; `brew install localmost` does not exist. diff --git a/SECURITY.md b/SECURITY.md index 0eaa585..3d87db2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -191,6 +191,17 @@ or stored by localmost. - `Metadata: Read` - Access basic repository information (required by GitHub for all apps) - `Self-hosted runners: Read & Write` (org-level) - Register runners at the organization level +### Docker Access + +A repository may declare `docker:` in its approved `.localmostrc`. At any level +from `socket` upward, jobs from that repository are **not sandboxed**: containers +are not subject to the seatbelt profile, so a job can bind-mount host paths into +a container and read or write them - including paths the profile denies, such as +`~/.ssh` - and can make network connections that bypass the policy's allowlist. + +Default is off. It takes effect only through the normal policy approval, so the +diff shown at approval time is what grants it. See `docs/roadmap/docker-access.md`. + ## Credential Storage - **Location**: Configuration stored in `~/.localmost/config.yaml` diff --git a/docs/roadmap/docker-access.md b/docs/roadmap/docker-access.md index d8c4036..6fcc231 100644 --- a/docs/roadmap/docker-access.md +++ b/docs/roadmap/docker-access.md @@ -3,8 +3,8 @@ A `.localmostrc` key that lets an approved repository reach the Docker daemon, at a declared level, from inside the runner sandbox. -> **Status:** designed, not implemented. This document describes the intended -> behaviour and the decisions behind it. +> **Status:** implemented in 0.3.0. This document describes the design; where the +> shipped behaviour differs it is noted inline. ## Problem diff --git a/docs/roadmap/localmostrc.md b/docs/roadmap/localmostrc.md index fd70d37..779dccb 100644 --- a/docs/roadmap/localmostrc.md +++ b/docs/roadmap/localmostrc.md @@ -125,6 +125,15 @@ shared: - "~/.aws/*" # Explicit paranoia - "~/.ssh/id_*" + # Docker daemon access. Cumulative; default off. Declared in shared only - + # the sandbox profile is built before the workflow is known. + # socket - the daemon socket, with DOCKER_HOST set for the job + # contexts - the above, plus ~/.docker/contexts + # credentials - the above, plus ~/.docker/config.json + # A job that can reach the daemon is not sandboxed: containers are not + # subject to the profile. See docs/roadmap/docker-access.md + docker: socket + env: allow: - DEVELOPER_DIR @@ -248,6 +257,17 @@ Discovered access for build.yml: Add to .localmostrc under workflows.build? [y/n] ``` +### Docker access + +`docker:` opens the daemon socket, and nothing else under `~/.docker` beyond the +paths its level names. At `contexts`, a job that selects a context pointing at a +different socket has that connection denied by the sandbox - the grant covers +the daemon socket localmost resolved, not whatever a context names. + +There is no key for arbitrary unix sockets. `localmost test --updaterc` reports +sockets a run reached, but writes no socket declaration; the only socket a policy +can ask for is the Docker daemon. + ## Why Checked Into Git **Version controlled:** From ea61db8af49052ac8526ef9f88d7caa122b5cf47 Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 19:14:41 -0400 Subject: [PATCH 12/13] Correct what the dangling-socket test claims to cover 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- src/shared/docker-access.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/shared/docker-access.test.ts b/src/shared/docker-access.test.ts index 166e415..16f6f1b 100644 --- a/src/shared/docker-access.test.ts +++ b/src/shared/docker-access.test.ts @@ -83,8 +83,11 @@ describe('resolveDockerEndpoint', () => { }); }); - it('returns null for a dangling symlink, which is what a stopped daemon leaves', () => { - // /var/run/docker.sock survives Docker Desktop quitting; its target does not. + it('returns null when a path exists but cannot be resolved', () => { + // Covers the socket being removed between the two calls. Note a dangling + // /var/run/docker.sock - what a stopped Docker Desktop leaves behind - does + // not reach here: existsSync follows symlinks, so it reports false and the + // candidate is skipped. Verified against the real machine. const fs: DockerFsProbe = { exists: p => p === '/var/run/docker.sock', realpath: () => { @@ -95,6 +98,13 @@ describe('resolveDockerEndpoint', () => { expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toBeNull(); }); + it('returns null when the daemon is stopped, leaving a dangling symlink', () => { + // existsSync follows the link, so a dangling one simply is not there. + const fs = probe({}); + + expect(resolveDockerEndpoint({ env: {}, homeDir, fs })).toBeNull(); + }); + it('returns null when nothing is present', () => { expect(resolveDockerEndpoint({ env: {}, homeDir, fs: probe({}) })).toBeNull(); }); From f48ad4e94ded841de284038746ccdfe70d75d0cd Mon Sep 17 00:00:00 2001 From: Bright Fulton Date: Fri, 4 Sep 2026 20:22:02 -0400 Subject: [PATCH 13/13] Exercise docker access for real, locally and in CI 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 Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo --- .github/actions/docker-access/action.yml | 46 +++++++++ .github/workflows/docker.yaml | 42 ++++++++ .localmostrc | 5 + src/shared/docker-access.sandbox.test.ts | 117 +++++++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 .github/actions/docker-access/action.yml create mode 100644 .github/workflows/docker.yaml create mode 100644 src/shared/docker-access.sandbox.test.ts diff --git a/.github/actions/docker-access/action.yml b/.github/actions/docker-access/action.yml new file mode 100644 index 0000000..c8e9edf --- /dev/null +++ b/.github/actions/docker-access/action.yml @@ -0,0 +1,46 @@ +name: Docker access check +description: > + Reach the Docker daemon, run a container, and confirm the policy level + boundary holds. Used from both the Linux and localmost runner jobs so the + two check exactly the same things. + +runs: + using: composite + steps: + - name: Reach the daemon + shell: bash + run: | + set -euo pipefail + echo "DOCKER_HOST=${DOCKER_HOST:-}" + + if ! docker version --format 'client {{.Client.Version}} / server {{.Server.Version}}'; then + echo "::error::No reachable Docker daemon." + echo "On the localmost runner this means the docker: socket grant did not" + echo "apply, or Docker Desktop is not running on the host." + exit 1 + fi + + - name: Run a container + shell: bash + run: | + set -euo pipefail + # The daemon pulls this, outside the sandbox, so it does not go through + # the job's proxy allowlist. That is the documented cost of granting + # Docker: see docs/roadmap/docker-access.md. + docker run --rm alpine:3 echo "container ran" + + - name: Check the level boundary holds + shell: bash + run: | + set -euo pipefail + # DOCKER_HOST is set only by localmost, so it marks the sandboxed run. + if [ -z "${DOCKER_HOST:-}" ]; then + echo "Not the sandboxed path (no DOCKER_HOST); nothing to check" + exit 0 + fi + + if cat "$HOME/.docker/config.json" >/dev/null 2>&1; then + echo "::error::~/.docker/config.json was readable at docker: socket" + exit 1 + fi + echo "config.json is denied, as socket level requires" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..ec71449 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,42 @@ +name: Docker Access + +# Exercises the docker: socket grant this repo declares in .localmostrc. +# +# Two jobs run the same composite action: +# +# docker-linux GitHub-hosted, Docker native, nothing sandboxed. Confirms +# the workflow itself is sound where localmost is absent. +# docker-localmost The self-hosted runner, where the daemon is only reachable +# because the sandbox profile allows its socket. This is the +# end-to-end test of the grant, and it is skipped when no +# localmost runner is online. +on: + push: + branches: [main] + pull_request: + types: [opened, synchronize, reopened] + workflow_dispatch: + +permissions: + actions: read + contents: read + +jobs: + check: + uses: ./.github/workflows/check.yaml + with: + fallback: ubuntu-latest + + docker-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/docker-access + + docker-localmost: + needs: check + if: needs.check.outputs.runner == 'self-hosted' + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/docker-access diff --git a/.localmostrc b/.localmostrc index fee6a78..41ed3fb 100644 --- a/.localmostrc +++ b/.localmostrc @@ -2,6 +2,11 @@ version: 1 level: strict shared: + # The test suite exercises Docker access end to end: docker-access.sandbox.test.ts + # runs real seatbelt against the real daemon, and CI runs containers below. + # A job with this is not sandboxed - see docs/roadmap/docker-access.md. + docker: socket + network: allow: - "*.github.com" diff --git a/src/shared/docker-access.sandbox.test.ts b/src/shared/docker-access.sandbox.test.ts new file mode 100644 index 0000000..fe186d2 --- /dev/null +++ b/src/shared/docker-access.sandbox.test.ts @@ -0,0 +1,117 @@ +/** + * Integration coverage for Docker access. + * + * The unit tests assert which rules the profile contains. They cannot show + * that those rules let a process reach the daemon, or that its absence stops + * one - which is the only thing this feature is for. This runs real seatbelt + * against the real socket, both ways round. + * + * macOS only, and only when a daemon is reachable: seatbelt does not exist + * elsewhere, and there is nothing to connect to without Docker running. + */ + +import { describe, it, expect } from '@jest/globals'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { generateSandboxProfile } from './sandbox-profile'; +import { resolveDockerEndpoint, DockerAccessLevel } from './docker-access'; + +const endpoint = process.platform === 'darwin' ? resolveDockerEndpoint() : null; + +/** A raw HTTP request to the daemon over its unix socket. */ +const PING = `printf 'GET /_ping HTTP/1.0\\r\\nHost: localhost\\r\\n\\r\\n' | /usr/bin/nc -U`; + +/** + * Whether a daemon is actually listening, not merely a socket file present. + * + * Any HTTP status counts: Docker Desktop answers this raw request with a 500, + * and a status line is proof the connection was accepted and the daemon spoke. + * What the sandbox changes is whether there is a reply at all. + */ +const daemonResponds = (): boolean => { + if (!endpoint) return false; + try { + const out = execFileSync('/bin/sh', ['-c', `${PING} ${endpoint.socketPath}`], { + encoding: 'utf-8', + timeout: 5000, + }); + return out.includes('HTTP/'); + } catch { + return false; + } +}; + +const runnable = Boolean(endpoint) && daemonResponds(); + +if (!runnable) { + // Say why, so a silent skip is not mistaken for coverage. + const reason = + process.platform !== 'darwin' + ? `platform is ${process.platform}, seatbelt is macOS only` + : endpoint + ? 'no daemon answered on the socket' + : 'no Docker socket resolved'; + console.log(`[docker-access.sandbox] skipped: ${reason}`); +} + +const describeIfRunnable = runnable ? describe : describe.skip; + +describeIfRunnable('docker access through real seatbelt', () => { + /** Ask the daemon for /_ping from inside a sandbox built for `level`. */ + const pingFromSandbox = (level: DockerAccessLevel): { ok: boolean; output: string } => { + const socketPath = endpoint!.socketPath; + const profile = generateSandboxProfile({ + workDir: process.cwd(), + proxyPort: 8080, + homeDir: os.homedir(), + dockerEndpoint: endpoint, + policy: level === 'off' ? {} : { docker: level }, + }); + + const profilePath = path.join(os.tmpdir(), `localmost-docker-test-${process.pid}-${level}.sb`); + fs.writeFileSync(profilePath, profile); + + try { + const output: string = execFileSync( + '/usr/bin/sandbox-exec', + [ + '-f', + profilePath, + '/bin/sh', + '-c', + `${PING} ${socketPath}`, + ], + { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 15000 } + ); + return { ok: output.includes('HTTP/'), output }; + } catch (error) { + const err = error as { stderr?: Buffer | string; status?: number }; + return { ok: false, output: String(err.stderr ?? '') }; + } finally { + fs.unlinkSync(profilePath); + } + }; + + it('reaches the daemon when the policy declares docker: socket', () => { + const { ok, output } = pingFromSandbox('socket'); + + expect(ok).toBe(true); + expect(output).toContain('HTTP/'); + }); + + it('cannot reach the daemon when the policy declares nothing', () => { + // The negative case is what makes the positive one meaningful: without it, + // a profile that granted everything would pass the test above. + const { ok } = pingFromSandbox('off'); + + expect(ok).toBe(false); + }); + + it('reaches the daemon at every level above off', () => { + for (const level of ['socket', 'contexts', 'credentials'] as const) { + expect(pingFromSandbox(level).ok).toBe(true); + } + }); +});