Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/actions/docker-access/action.yml
Original file line number Diff line number Diff line change
@@ -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:-<unset>}"

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"
42 changes: 42 additions & 0 deletions .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions .localmostrc
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
209 changes: 209 additions & 0 deletions docs/roadmap/docker-access.md
Original file line number Diff line number Diff line change
@@ -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://<socket>` 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.
20 changes: 20 additions & 0 deletions docs/roadmap/localmostrc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:**
Expand Down
Loading
Loading