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/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 47decef..56177b8 100644 --- a/README.md +++ b/README.md @@ -221,6 +221,7 @@ 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: 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 new file mode 100644 index 0000000..6fcc231 --- /dev/null +++ b/docs/roadmap/docker-access.md @@ -0,0 +1,209 @@ +# 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:** implemented in 0.3.0. This document describes the design; where the +> shipped behaviour differs it is noted inline. + +## 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 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. `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 +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. + +**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 +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. 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:** 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..d283e84 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-docker-access.md @@ -0,0 +1,1252 @@ +# 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, 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 + 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 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` (`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: `SocketsPolicy` and `SandboxPolicy.sockets` are gone. + +`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('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(false); + expect(result.errors[0].message).toMatch(/docker:/); +}); +``` + +```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 — `sockets:` still validates successfully. + +- [ ] **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. + +In `src/shared/localmostrc.ts`, delete `validateSocketsPolicy` and replace its +call in `validatePolicy` with a rejection: + +```typescript + // 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).', + }); + } +``` + +In `runner-manager.ts`, spread the grant env into the job environment at the +spawn built in Task 6: + +```typescript + env: { ...env, ...dockerGrants.env }, +``` + +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` +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/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" +``` + +--- + +### 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 +- **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 + `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" +``` 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/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/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 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), 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); + } + }); +}); diff --git a/src/shared/docker-access.test.ts b/src/shared/docker-access.test.ts new file mode 100644 index 0000000..16f6f1b --- /dev/null +++ b/src/shared/docker-access.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from '@jest/globals'; +import { + DOCKER_ACCESS_LEVELS, + isDockerAccessLevel, + resolveDockerEndpoint, + dockerSandboxGrants, + DockerFsProbe, + DockerGrants, +} 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', () => { + 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); + }); +}); + +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 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: () => { + throw new Error('ENOENT'); + }, + }; + + 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(); + }); +}); + +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 new file mode 100644 index 0000000..8c89771 --- /dev/null +++ b/src/shared/docker-access.ts @@ -0,0 +1,132 @@ +/** + * 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. + */ + +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'; + +/** 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); +} + +/** 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; +} + +/** 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; +} diff --git a/src/shared/localmostrc.test.ts b/src/shared/localmostrc.test.ts index c3d832f..0fc5a41 100644 --- a/src/shared/localmostrc.test.ts +++ b/src/shared/localmostrc.test.ts @@ -796,3 +796,93 @@ 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/); + }); +}); + +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'); + }); +}); + +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 5ce4eb5..f39ea1d 100644 --- a/src/shared/localmostrc.ts +++ b/src/shared/localmostrc.ts @@ -6,8 +6,9 @@ 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'; // ============================================================================= // Types @@ -223,15 +224,37 @@ 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 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 { @@ -269,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` }); @@ -385,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. */ @@ -417,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, }; } @@ -485,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) { @@ -523,14 +529,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) { @@ -608,8 +606,21 @@ 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); + // 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); 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 688f462..e1343be 100644 --- a/src/shared/sandbox-profile.ts +++ b/src/shared/sandbox-profile.ts @@ -8,6 +8,11 @@ import * as os from 'os'; import * as path from 'path'; +import { + dockerSandboxGrants, + type DockerAccessLevel, + type DockerEndpoint, +} from './docker-access'; // ============================================================================= // Types @@ -24,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[]; @@ -37,8 +37,9 @@ 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; } export interface SandboxProfileOptions { @@ -48,6 +49,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 */ @@ -357,18 +362,27 @@ 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}"))`); + // 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}"))`); - // Also need file-write for socket operations + 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(''); } 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'], };