Skip to content

CLI target management, Docker isolation, config hardening, and App permission docs - #35

Merged
bfulton merged 52 commits into
mainfrom
feat/cli-targets
Sep 6, 2026
Merged

CLI target management, Docker isolation, config hardening, and App permission docs#35
bfulton merged 52 commits into
mainfrom
feat/cli-targets

Conversation

@bfulton

@bfulton bfulton commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Related work on the same runner-and-policy surface, stacked on one branch.

  • CLI target managementlocalmost targets, plus two runtime bugs it exposed
  • Docker isolation (stage 1) — localmost stops handing jobs the daemon socket and serves a filtering one, checked against the repository's policy
  • Config data-loss hardening — a bad config read can no longer become a permanent overwrite with defaults
  • Runtime fixes — per-workflow policy binds to the real workflow name; job notifications name the repository
  • GitHub App permission docsContents: Read, and a check so the docs stop drifting

Design: docs/superpowers/specs/2026-09-05-docker-isolation-design.md. Plan: docs/superpowers/plans/2026-09-05-docker-isolation-stage1.md. Supersedes docs/roadmap/docker-access.md, which describes the docker: socket level this branch replaces.


CLI target management

Registering runners for a repo was UI-only, and hand-editing config.yaml doesn't work — adding a target also has to register runner proxies with GitHub and store their credentials.

localmost targets                        # list (default)
localmost targets add <owner>/<repo>     # register runners for a repo
localmost targets add <owner> --org      # register runners for an org
localmost targets remove <ref> [--yes]
localmost targets enable|disable <ref>

<ref> is owner/repo, a bare owner for org targets, or the 8-char id; every subcommand takes --json. remove unregisters runners from GitHub and isn't locally reversible, so it confirms interactively, takes --yes, and refuses to run unconfirmed outside a terminal.

Supporting changes: the wire protocol moves to src/shared/cli-protocol.ts so the two hand-mirrored copies can't drift; sendCommand gets a per-command timeout (registering four proxies takes ~30s, well past the old hardcoded 5s); and the post-add broker wiring moves into TargetManager so the CLI and the UI share one path.

Two bugs this surfaced, both pre-existing and both affecting the Targets page identically — the CLI just made them reachable without a renderer in the loop:

  • Targets added at runtime were lost on quit. targets is a persisted store key; the store loads it at boot and flushes on quit, overwriting config.yaml with its stale snapshot. The UI survived only because the renderer re-lists targets after an add, which happens to re-sync the store.
  • Targets added at runtime never got a heartbeat. Heartbeat targets were computed once at auto-start, so LOCALMOST_HEARTBEAT was never written for a later target and workflows gating on it read the machine as offline.

Docker isolation (stage 1)

docker: socket handed the job the operator's real daemon socket, which is a sandbox escape by construction: a container can bind-mount any host path Docker Desktop shares, its egress bypasses the network policy, and credentials worked by letting the job read ~/.docker/config.json.

localmost now serves each worker its own unix socket at the root of the worker's ephemeral sandbox directory and points DOCKER_HOST at it. A filtering proxy behind that socket parses every Docker Engine API request, checks it against the repository's approved policy, and forwards only what passes to a backend daemon. Because localmost owns the socket: host reach is decided per request rather than granted wholesale, policy can vary per workflow, credentials are attached by the proxy and never enter the sandbox, and the daemon behind the socket is a swappable DockerBackend (stage 2's managed VM is seamed, not built).

shared:
  docker:
    pull:
      registries: [docker.io]
    run:
      images: ["alpine:3"]
      mounts:
        - path: ./
          mode: ro
      network: bridge

Actions are CLI-shaped (pull, run, build) with the conditions the proxy checks: registries, images, mounts (resolved through symlinks and required to stay inside the job workspace, so ../ and absolute host paths fail structurally), network, context. Anything not listed is denied. There is no key that spells PidMode, Devices, SecurityOpt, NetworkMode: host, or a daemon-socket mount — a capability that cannot be named cannot be requested. privileged is in the grammar but rejected until a managed-VM backend exists. /_ping, /version, /info and reads about the job's own containers are an always-on baseline. The filter fails closed on unknown endpoints, unknown API versions and unparseable bodies, and every denial logs the exact policy line that would permit it, which localmost test --updaterc writes back.

Breaking policy change: docker: socket | contexts | credentials is now a validation error naming the actions that replace it. This repo's own .localmostrc is migrated. The sandbox profile no longer punches a hole for the daemon socket; ~/.docker stays denied in full.

Verified at three levels: the SECURITY.md escapes are executable tests against the evaluator (host bind mount, daemon-socket mount, --privileged/--pid=host/--network=host/--device, ../ and symlink traversal, undeclared image/registry/mount); the proxy is tested over a real unix socket; and an end-to-end test drives the real docker CLI and a real daemon through the filter. That e2e runs on every CI leg — serving the socket itself where the native daemon is reachable, and driving the socket the runner serves inside a localmost job — and fails, never skips, when Docker is absent. docker-localmost likewise now runs on every push, queueing for a runner rather than skipping.

Config data-loss hardening

A stale localmost.app.bak launched against the shared ~/.localmost and left config.yaml empty; the next real launch loaded the empty file and cleanly re-saved it, erasing every target and setting. The root cause was that the persistence layer treated "read nothing" as success and then overwrote disk with factory defaults. Three defenses, so a transient bad read or an older bundle can no longer destroy a good config:

  • persist: if the config file exists but parses to nothing, or was written by a newer build, refuse to persist rather than stamping defaults over it.
  • config: saveConfig is atomic (temp file + rename), so a reader or second instance never sees a half-written file.
  • both: every save stamps a configVersion; neither writer will overwrite a file carrying a higher one, so a stale or older app bundle cannot downgrade it.

Runtime fixes

  • Per-workflow policy bound to the wrong name. workflows.<name> keys match the workflow filename, but the runtime looked them up by the job name scraped from runner stdout, so per-workflow sections fired only by coincidence. The broker already receives github.workflow; it is now threaded through to policy binding. This was a live defect in per-workflow network policy and a blocker for workflows.<name>.docker.
  • Job notifications said "on unknown". The job-started handler looked up the job's target context by the runner's full name, which is never a storage key; it now uses the numeric instance id where spawnWorkerForJob stores it.

GitHub App permission docs

The App now requests Contents: Read, needed to fetch .localmostrc from private repos — without it the API answers Resource not accessible by integration and the job is refused rather than run under a weaker sandbox.

The permission set was written out in three places nothing kept in sync: the App description text, the README table, and the SECURITY.md list. They're now checked against one declared list in src/shared/github-app-permissions.ts, in both directions. That check found existing drift on its first run — neither README nor SECURITY.md documented Variables: Read & Write, at either scope, though the App requests it and the heartbeat depends on it.


Copilot's review on the CLI portion is addressed and its threads resolved.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh

bfulton and others added 2 commits September 4, 2026 11:29
Registering runners for a repo was UI-only: the CLI had no target
commands, and hand-editing config.yaml would leave a target with no
runners registered and no credentials on disk.

Add `localmost targets` with list/add/remove/enable/disable, plus
--json on every subcommand for scripting.

- Move the CLI wire protocol to src/shared/cli-protocol.ts so the two
  hand-mirrored copies in cli-server.ts and cli/index.ts can't drift,
  and extend CliRequest with an args payload.
- Give sendCommand a per-command timeout. Registering four runner
  proxies takes ~30s against the GitHub API, well past the previous
  hardcoded 5s.
- Move the post-add broker wiring out of the targets IPC handler into
  TargetManager.addTargetAndAttach / removeTargetAndDetach, so the CLI
  and the UI share one path and a new target is picked up without an
  app restart.
- `remove` confirms interactively, takes --yes to skip, and refuses to
  run unconfirmed outside a terminal. Mutations exit non-zero when the
  app isn't running, rather than reporting a no-op as success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Adding a target from the CLI wrote it to config.yaml, and quitting the
app wrote it back out again without it.

Two separate gaps, both in code the UI happened to paper over:

- `targets` is a persisted store key. The store loads it at boot and
  flushes on quit, so a target added while the app was running was
  clobbered by the stale snapshot. The Targets page survived this only
  because the renderer re-lists targets after an add, which re-syncs
  the store. Sync the store from TargetManager instead, so every write
  path agrees regardless of who drove it.

- Heartbeat targets were computed once at auto-start, so a target added
  later never got a LOCALMOST_HEARTBEAT variable and workflows gating
  on it would read the machine as offline. Add/remove the target on the
  running HeartbeatManager, writing its first heartbeat immediately and
  marking it stale on removal.

Also clear the 3s race timer in HeartbeatManager.clear(), which was
holding the process open, and drop the duplicated target->heartbeat
mapping in index.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Copilot AI lite review requested due to automatic review settings September 4, 2026 16:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

A few correctness/robustness issues in the new CLI target command paths (notably input validation over the CLI socket and heartbeat clearing semantics) should be addressed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds first-class target management to the localmost CLI, enabling users to list/add/remove/enable/disable repo/org targets via the running app’s CLI socket, while consolidating target attach/detach + heartbeat behavior so CLI and UI share the same runtime codepaths.

Changes:

  • Introduces a shared CLI wire protocol (CliRequest/CliResponse) and extends it with target-management commands and arguments.
  • Adds localmost targets subcommands (with --json support) and implements corresponding main-process handlers in CliServer.
  • Refactors target add/remove to attach/detach broker proxy + update heartbeat immediately, and fixes runtime-added targets persistence/heartbeat behavior.
File summaries
File Description
src/shared/cli-protocol.ts New shared CLI socket protocol definitions (commands, args, responses, target summaries).
src/main/target-manager.ts Adds ref resolution + shared add/attach and remove/detach paths; syncs target writes back into persisted store.
src/main/target-manager.test.ts Expands tests for store sync, ref resolution, attach/detach, and heartbeat hooks.
src/main/ipc-handlers/targets.ts Switches IPC add/remove to the new TargetManager shared attach/detach methods.
src/main/index.ts Uses toHeartbeatTarget helper when seeding heartbeat targets at startup.
src/main/heartbeat-manager.ts Adds dynamic add/remove target support, immediate heartbeat writes, and fixes shutdown timeout timer leak.
src/main/heartbeat-manager.test.ts New tests for dynamic target add/remove behavior and heartbeat clearing.
src/main/cli-server.ts Migrates request/response types to shared protocol and adds targets-list/add/remove/update commands.
src/main/cli-server.test.ts Adds CLI server tests for target commands and summaries (including runner counts).
src/cli/targets.ts New CLI implementation for localmost targets UX, formatting, confirmation flow, and JSON output.
src/cli/targets.test.ts New unit tests covering parsing, formatting, ref resolution, prompts, and JSON behavior.
src/cli/index.ts Adds targets command routing; moves to request-shaped socket messages and per-command timeouts.
README.md Documents localmost targets usage and behavioral notes.
CHANGELOG.md Adds release notes entry for CLI target management.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/main/cli-server.ts Outdated
Comment thread src/main/cli-server.ts Outdated
Comment thread src/main/cli-server.ts Outdated
Comment thread src/main/heartbeat-manager.ts Outdated
Comment thread src/main/target-manager.ts
Comment thread src/cli/targets.ts Outdated
- Validate target command payloads in the CLI server. Requests arrive as
  arbitrary JSON, and only truthiness was checked: a non-string `ref`
  threw inside findTargetByRef and surfaced as a generic "Invalid
  request" parse error, and a bogus `type` flowed through addTarget's
  only guard to build an "owner-undefined" proxy runner name and
  register runners under it. Names are now type-checked and trimmed.

- Clear a removed target's heartbeat even when the heartbeat isn't
  running. Pausing the runner stops the timer but leaves the last
  timestamp in place, so a target removed just after a pause kept
  looking online for the rest of the 90s window.

- Make duplicate detection case-insensitive. The proxy runner name is
  lowercased, so adding both `owner/repo` and `Owner/Repo` produced two
  targets whose runners registered under identical names and --replace'd
  each other. Ref lookup now takes an exact match first and resolves to
  nothing when only case-insensitive matches are ambiguous, on both the
  server and the CLI.

- Only `add` requires owner/repo; the subcommands that resolve an
  existing target now say a target id is accepted too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Copilot AI review requested due to automatic review settings September 4, 2026 16:31

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Heartbeat add/remove introduces new awaited GitHub variable writes without timeouts, which can block target add/remove indefinitely on stalled network calls.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

src/main/heartbeat-manager.ts:103

  • addTarget awaits a GitHub variable write with no timeout. Since the underlying GitHub API client uses fetch without a request timeout, a hung network call can block target adds (and therefore localmost targets add) indefinitely until the CLI socket timeout fires, leaving the user with a perceived failure even if the target was added.

This issue also appears on line 122 of the same file.

src/main/heartbeat-manager.ts:122

  • removeTarget awaits clearHeartbeat without any timeout. Because clearHeartbeat ultimately calls the GitHub API via fetch (no request timeout), a stalled request can block target removal and runner unregistration indefinitely. This contradicts the docstring intent that clearing must not block target removal.
    await this.clearHeartbeat(target);
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

bfulton and others added 17 commits September 4, 2026 14:04
The GitHub App description was updated to request `Contents: Read`,
needed to fetch `.localmostrc` from private repositories — without it
the contents API answers "Resource not accessible by integration" and
the job is refused.

The permission set is written out in three places that nothing keeps in
sync, so update all of them together: the App description text, the
README table, and the SECURITY.md list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The App's permissions are configured on github.com and restated in three
documents that nothing kept in sync: the description text, the README
table, and the SECURITY.md list.

Declare them once in src/shared/github-app-permissions.ts and check all
three against it, in both directions so a permission added to a doc but
not declared fails too. The list is documentation, not configuration -
no app code imports it, so it stays out of the bundle, and editing it
does not change what the App requests.

The check found existing drift on its first run: neither the README nor
SECURITY.md documented `Variables: Read & Write`, at either scope, even
though the App requests it and the heartbeat depends on it to write
LOCALMOST_HEARTBEAT. Both now list it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
A job that needs Docker cannot reach the daemon at any policy level and
has no way to ask: the socket gets no network-outbound grant, ~/.docker
is denied as a credential store, and the SocketsPolicy mechanism that
would express it is orphaned in the test-mode profile builder.

Design: a `docker:` key taking off (default) / socket / contexts /
credentials, each level naming what it opens so the grant is legible in
an approval diff. One shared resolver drives both sandbox builders.

Records the fact that shapes the whole feature: a job that can reach the
daemon is not sandboxed. Containers are not subject to the profile, so a
bind mount reaches host paths the profile denies. The design surfaces
that at approval time rather than hiding it behind a mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Ten TDD tasks: level type and schema validation, endpoint resolution,
grant computation, emission into both sandbox profile builders, the
policy-to-worker plumbing including the stamp, DOCKER_HOST injection,
approval diff prominence, docs, and an end-to-end pass.

Also corrects the design doc on two points found while reading the
plumbing:

- docker: must be a shared-only key. The runner's profile is built
  before the workflow is known, which is already why per-workflow
  filesystem sections are refused; a workflow-level docker value could
  only be honoured by localmost test, recreating the divergence the
  shared resolver exists to prevent.

- shared.sockets.allow is not merely orphaned. It is a validated key
  that already reaches the localmost test profile with arbitrary paths
  while the runner ignores it, so it works locally and does nothing on
  the runner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The rules go after the unconditional deny block: the Docker Desktop
socket lives inside ~/.docker, which is denied wholesale, and seatbelt
takes the last matching rule. Each grant is a single literal, so
config.json stays denied below the credentials level.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The level joins the policy stamp: a worker spawned under one level must
not claim a job approved under another, because the grant is baked into
the profile at spawn and cannot change afterwards.

A declared level with no reachable daemon logs a warning and runs
without the grant - the declaration is a permission, not a requirement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
shared.sockets.allow reached the localmost test profile with arbitrary
socket paths and was ignored by the runner, so it worked locally and did
nothing on a real job - and it let a repository name any socket on the
machine, which is what the closed docker enum exists to prevent.

No approved policy declares it and localmostrc.md never documented it,
so it is removed rather than deprecated, and rejected with an error
naming docker: so a policy that used it fails loudly.

--updaterc no longer writes the key. It still reports sockets a run
reached, and points at docker: when one of them is the daemon.

Also carries the shared docker level through mergePolicies, which drops
any field it does not name - the localmost test path reads the merged
policy, so the level would have applied on the runner and not locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
existsSync follows symlinks, so a dangling /var/run/docker.sock - what a
stopped Docker Desktop leaves - reports false and is skipped before
realpath is reached. Verified against the real machine. The realpath
guard still covers the socket disappearing between the two calls.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
The unit tests assert which rules the profile contains; none of them
showed that those rules let a process reach the daemon, which is the
only thing the feature is for.

- docker-access.sandbox.test.ts runs real seatbelt against the real
  socket, both ways round: reachable with the grant, refused without.
  The negative case is what makes the positive one mean anything. macOS
  only, skipped with a stated reason when no daemon answers.

- This repo now declares docker: socket, so its own policy exercises the
  key it added.

- A composite action reaches the daemon, runs a container, and asserts
  ~/.docker/config.json stays denied at socket level. Two jobs run it:
  ubuntu-latest, where Docker is native and nothing is sandboxed, and
  the self-hosted runner, where the grant is the only reason it works.

Verified under localmost test: DOCKER_HOST injected, daemon reached,
container ran, config.json denied - and the whole workflow fails at the
daemon check when docker: socket is removed from .localmostrc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjihKhGsbH9EPHGuHEVTMo
Copilot AI review requested due to automatic review settings September 5, 2026 00:51
@bfulton bfulton changed the title Add target management to the localmost CLI CLI target management, Docker access, and App permission docs Sep 5, 2026
bfulton and others added 15 commits September 5, 2026 21:12
docker: is now an object of pull, run and build actions, each with the
conditions the filtering socket checks, and it is valid under workflows
as well as shared since the socket is bound to the merged policy on
claim. The 0.3.0 levels and `true` are rejected with an error naming the
actions that replace them.

The level round-trip test goes with the levels; the action-block
round-trip arrives with serialization in a following commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
SandboxPolicy.docker is now a DockerPolicy, and mergePolicies composes
it additively like the rest of the policy: lists concatenate, network
and build context take the workflow's value, privileged is granted if
either side asks.

The test-mode profile no longer feeds policy.docker into the level
grants. A docker policy names requests the filtering socket may forward,
not a level that unlocks the daemon, and passing it through would have
opened the raw socket for any non-empty policy; the block and its two
options go, fail closed.

The docker scalar branch in diffPolicies and the remaining level
consumers (index.ts, step-executor.ts) are migrated by the following
commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The approval diff reports one entry per docker grant - each registry,
image and mount (as path:mode), plus network, build context and
privileged - so nothing under docker: collapses into a line. The
serializer writes the block in the documented shape and round-trips
through the parser at both scopes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
evaluateDockerRequest is the security core of the filtering socket: it
takes a parsed request and the bound policy and returns a verdict with
a Docker-API-shaped reason and the policy line that would permit it.
The SECURITY.md escapes are its tests - host bind mounts, the daemon
socket, privileged, --pid/--network=host, --device and the other
namespace and capability keys the grammar cannot spell, ../ traversal
and symlinks resolving outside the workspace, undeclared images,
registries, mounts and network modes - along with the verb-to-endpoint
mapping and the fail-closed cases (unknown endpoint, malformed body,
no policy bound).

RepoPolicyRuntime.docker is now the DockerPolicy, and getRepoPolicy
returns it merged across shared and workflow, since the socket is
bound after the workflow is known. The runner-manager test stubs
follow the type; the spawn path still consumes the old level and is
migrated by the socket-minting change.

The validator also refuses `network: host` and `container:`, so what
the evaluator never permits cannot be written into a policy either.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
DockerFilterProxy listens on a unix socket inside the worker's sandbox
directory, parses each Docker Engine API request, evaluates it against
the policy bound to the socket and forwards only what is permitted to
the backend daemon. It is born denying everything; bind() attaches one
repository's policy on claim, and boundRepository() is the single
source of truth the caller checks a claim against.

Denials are Docker API errors with the policy hint logged for
discovery. API versions outside the pinned range are refused, an
unversioned request is pinned to the newest we understand, and the
daemon's advertised version is clamped on ping and /version so the CLI
negotiates down to it. Registry auth is attached to pulls by the proxy
and whatever the job sent is stripped, along with hop-by-hop headers on
both legs. JSON bodies are read (capped) before deciding; anything else
streams through once the request line is approved. Attach arrives as an
HTTP upgrade and is relayed raw once judged.

Two things the real CLI taught the relay: a wait's headers must be
flushed before its body, or docker run never sends start; and an answer
that arrives while the job is still uploading must not end the response
until the upload is drained, or a client whose write fails first never
reads it. Upstream requests never keep-alive, since a pooled socket
sheds its error listener while a write may still be pending.

HEAD /_ping joins the verb map: the CLI pings with HEAD first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
Each spawn gets a DockerFilterProxy listening at <sandboxDir>/docker.sock,
started before the runner exists and pointed at by DOCKER_HOST. The socket
is born denying everything and is bound to the claimed repository's docker
policy - only when that repository is the one the worker was spawned for
and claimed from; a mismatched claim is recorded on the instance so the
socket stays closed when the job-started line later attributes the job to
the spawn repository. The socket is stopped when its process exits.

The old dockerGrants wiring leaves the spawn path with it, including the
spawn-time no-daemon warning and its two tests: the socket itself warns
once when no daemon is behind it, and its log entries are now forwarded to
the runner log, which is tested here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The runner profile now takes the path of the filtering socket the app
serves the worker and grants it network-outbound and file-read*, with an
explicit file-write* deny after the sandbox directory's write allow so the
job cannot unlink it and bind its own. The daemon socket is never granted
and ~/.docker stays denied in full, so the post-deny ordering rule and
its test go; dockerGrants and the level-based profile tests go with them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
DockerAccessLevel, DOCKER_ACCESS_LEVELS, isDockerAccessLevel, DockerGrants
and dockerSandboxGrants have no consumers left; docker-access.ts keeps
endpoint resolution for the desktop backend. localmost test stops setting
DOCKER_HOST and opening the daemon socket, since its profile never grants
that socket now and the app serves no filtering socket in test mode yet.

The seatbelt integration test is rewritten around the new model: a socket
served inside the workspace is reachable under a constructed profile, the
daemon's socket is not whatever the docker policy declares, and inside a
job the ambient profile reaches only the socket the runner serves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
How a discovery run's hosts and paths are folded into a .localmostrc was
inline in handleUpdateRc, between the report and the prompt, with no seam
a unit test could reach. Lift it into a pure exported mergeDiscoveredAccess
that returns the merged config and the grants it adds, so the merge can be
asserted directly before docker joins it. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
A denial from the filtering socket logs a policy hint: the YAML under
`docker:` that would have permitted the request. This is what lets
discovery write docker policy the way it writes network and filesystem
policy, and the consumer for it was missing.

parseDockerPolicyHint reads a hint back into a DockerPolicy, and reads
nothing at all for anything that is not exactly a valid docker block - a
hint ends up in a checked-in policy, so a bad one must never widen it. An
evaluator test now round-trips every hint it can emit: parse it, merge it
into the policy that produced the denial, and the same request passes.
That is the shape contract between the proxy and --updaterc, asserted
rather than assumed; the proxy already logged hints in it.

mergeDiscoveredAccess takes the hints alongside hosts and paths, folds
them into one suggested policy, composes that with the shared docker
policy already declared (additively, like the rest of the grammar), and
lists each new grant by the key the approval diff uses. A bare `run: {}`
has no item for the diff to name, so it is listed as its own grant.

The socket report's advice still named `docker: socket`, a level that is
now a validation error; it names the actions instead. The create path
also stops listing `sockets.allow` under "these will be added": nothing
writes it, and `sockets:` is a rejected key.

localmost test serves no filtering socket of its own yet, so a run under
it has no docker denials to feed this; the merge is in place for the
producer, whichever side supplies it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The unit tests prove what the filter decides against a fake daemon; this
drives the real docker CLI, as a workflow step would, through a socket
served the way RunnerManager serves one, to the operator's real daemon.
A declared image pulls and runs with the declared read-only workspace
mount; a bind outside the workspace and a writable mount of the
read-only workspace are both refused as errors the CLI prints, with the
policy hint logged for discovery. Skipped, with the reason on the tests
and in the terminal, when there is no daemon or no CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The end-to-end suite exists to prove a real CLI, daemon and container work
through the filtering socket; skipping when Docker is absent let it report
green having run nothing, and faking the daemon would test the integration
we already cover, not the real thing. It now asserts a real daemon and CLI up
front and fails naming what to install, so the answer to "no Docker here" is
to provision Docker where the tests run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The repo's own .localmostrc still declared `docker: socket`, which the
filtering-socket migration now rejects as a validation error - so localmost's
own CI would fail policy approval once this branch is the running app.
Declare exactly what CI and the docker e2e do: pull alpine:3 from Docker Hub
and mount the workspace read-only. Reword the docker workflow and its
composite action, which still described the old socket grant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The job was gated on a localmost runner being online and reported a skip
otherwise, which read as covered when nothing ran. It now runs on every
push; with no runner online it queues for one rather than skipping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
The end-to-end test served its own filtering socket over the raw daemon, so
it could only run where that daemon is reachable - and inside a localmost job
the sandbox denies the raw socket by design, since reaching it is the escape
this work removed. Granting it back would reintroduce the hole; the job does
not need it. Inside a job the runner already serves a filtering socket bound
to this repository's own .localmostrc, and DOCKER_HOST names it.

So the e2e now reaches a real filter either way: outside a job it serves the
socket itself and asserts on the proxy's log; inside a job it drives the
runner-served socket - the production proxy - with the CLI's exit codes and
output as the proof, mounting a subpath of the checkout that a declared "./"
permits. It fails naming what to provision when neither is available, never
skips, and is wired into CI so it runs on every leg.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
Copilot AI review requested due to automatic review settings September 6, 2026 03:30
@bfulton bfulton changed the title CLI target management, Docker access, and App permission docs CLI target management, Docker isolation, config hardening, and App permission docs Sep 6, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The Docker request classification/serialization currently creates an unintended security surface (container enumeration without grants and YAML injection risk via unquoted scalars) that should be addressed before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 59/59 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread src/main/docker/docker-request.ts
Comment thread src/shared/docker-policy.ts
…esponse

Every answer the filtering socket gives arrives after a round trip to the
daemon, so it is written after a client that half-closes on sending - an
HTTP/1.0 client, `nc -U`, a health probe - has already sent its FIN. By
default an http.Server treats that FIN as the end of the exchange and ends
its own side, and the late response is dropped: the client reads nothing and
waits for a close that never comes. The docker CLI never noticed, being
HTTP/1.1 and reading Content-Length, which is why the real job passed while
the in-job seatbelt test's probe reported the served socket unreachable.

Keep the write side open until the response is ended. Reproduced against
the fake daemon with a raw HTTP/1.0 client that half-closes and requires
both the relayed response and a close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
Copilot AI review requested due to automatic review settings September 6, 2026 03:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The persistence guard in loadPersistedConfig() can still treat non-object YAML parses as “valid” and risk incorrect hydration/persistence behavior unless it explicitly rejects scalar/array parse results.

Review details

Suppressed comments (2)

Previously missed (2) — in code that hasn't changed since the last review.

src/main/heartbeat-manager.ts:32

  • sameTarget compares owner/repo/org case-sensitively, but GitHub repo/org identity is case-insensitive and this PR explicitly mentions older configs may already contain case-variants. With case-sensitive comparison, addTarget/removeTarget can leave duplicate entries in this.targets and cause redundant heartbeat writes/clears. Normalize case when comparing targets.
    src/main/store/middleware/persist.ts:99
  • yaml.load() can return a scalar or array for a corrupted config.yaml (e.g. [] or "oops"). In that case diskConfig is truthy and Object.keys(diskConfig) is non-empty, so the "parsed to nothing" guard won’t trip and the middleware may hydrate nonsense and later persist over the on-disk config instead of treating it as unreadable. Treat any non-plain-object parse result as a failed load and set saveBlocked.
  • Files reviewed: 59/59 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

Copilot review, thread 1. `GET /containers/json` was classified `inspect`,
and `inspect` sat in the always-on baseline, so any job - even one with no
policy bound - could enumerate every container on the operator's daemon and
inspect it. The write side was worse and unreported: start, attach, wait and
remove were permitted against ANY container id whenever the policy declared
`run`, so a job could start, attach to, or remove another repository's
running container.

The spec's baseline is reads about the job's OWN containers. The proxy now
records the id the daemon assigns to each container it creates, and every
per-container endpoint - inspect, start, attach, wait, remove - is permitted
only against those. Listing becomes its own action with no policy key at all,
since it would enumerate containers outside the job by construction.

Verified the real `docker run` sequence still works end to end against a live
daemon; the tests that addressed a container they never created now create it
first, as the CLI does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XVXbqWE6zBi1HV7b4uobKh
Copilot AI review requested due to automatic review settings September 6, 2026 04:22
@bfulton
bfulton merged commit f50cfec into main Sep 6, 2026
8 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔵 Needs a closer look

It makes broad, security-sensitive changes (sandboxing, Docker request filtering, config persistence) and still has correctness issues to address before approval.

Review details

Suppressed comments (1)

Previously missed (1) — in code that hasn't changed since the last review.

src/shared/localmostrc.ts:392

  • This leftover "Merge sockets policies" JSDoc no longer has a corresponding function (the sockets policy was removed). Leaving it in place is misleading and reads like a partially reverted change.
  • Files reviewed: 59/59 changed files
  • Comments generated: 1
  • Review effort level: Lite

Comment thread src/cli/test.ts
Comment on lines +1342 to +1345
network: {
...existing.shared?.network,
allow: [...(existing.shared?.network?.allow || []), ...newHosts],
},
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants