CLI target management, Docker isolation, config hardening, and App permission docs - #35
Conversation
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
There was a problem hiding this comment.
🟡 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 targetssubcommands (with--jsonsupport) and implements corresponding main-process handlers inCliServer. - 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.
- 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
There was a problem hiding this comment.
🔵 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
addTargetawaits a GitHub variable write with no timeout. Since the underlying GitHub API client usesfetchwithout a request timeout, a hung network call can block target adds (and thereforelocalmost 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
removeTargetawaitsclearHeartbeatwithout any timeout. BecauseclearHeartbeatultimately calls the GitHub API viafetch(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
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
# Conflicts: # CHANGELOG.md
# Conflicts: # CHANGELOG.md
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
There was a problem hiding this comment.
🟡 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
…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
There was a problem hiding this comment.
🔵 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
sameTargetcompares 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/removeTargetcan leave duplicate entries inthis.targetsand cause redundant heartbeat writes/clears. Normalize case when comparing targets.
src/main/store/middleware/persist.ts:99yaml.load()can return a scalar or array for a corruptedconfig.yaml(e.g.[]or"oops"). In that casediskConfigis truthy andObject.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 setsaveBlocked.
- 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
There was a problem hiding this comment.
🔵 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
| network: { | ||
| ...existing.shared?.network, | ||
| allow: [...(existing.shared?.network?.allow || []), ...newHosts], | ||
| }, |
Related work on the same runner-and-policy surface, stacked on one branch.
localmost targets, plus two runtime bugs it exposedContents: Read, and a check so the docs stop driftingDesign:
docs/superpowers/specs/2026-09-05-docker-isolation-design.md. Plan:docs/superpowers/plans/2026-09-05-docker-isolation-stage1.md. Supersedesdocs/roadmap/docker-access.md, which describes thedocker: socketlevel this branch replaces.CLI target management
Registering runners for a repo was UI-only, and hand-editing
config.yamldoesn't work — adding a target also has to register runner proxies with GitHub and store their credentials.<ref>isowner/repo, a bareownerfor org targets, or the 8-char id; every subcommand takes--json.removeunregisters 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.tsso the two hand-mirrored copies can't drift;sendCommandgets a per-command timeout (registering four proxies takes ~30s, well past the old hardcoded 5s); and the post-add broker wiring moves intoTargetManagerso 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:
targetsis a persisted store key; the store loads it at boot and flushes on quit, overwritingconfig.yamlwith its stale snapshot. The UI survived only because the renderer re-lists targets after an add, which happens to re-sync the store.LOCALMOST_HEARTBEATwas never written for a later target and workflows gating on it read the machine as offline.Docker isolation (stage 1)
docker: sockethanded 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, andcredentialsworked 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_HOSTat 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 swappableDockerBackend(stage 2's managed VM is seamed, not built).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 spellsPidMode,Devices,SecurityOpt,NetworkMode: host, or a daemon-socket mount — a capability that cannot be named cannot be requested.privilegedis in the grammar but rejected until a managed-VM backend exists./_ping,/version,/infoand 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, whichlocalmost test --updatercwrites back.Breaking policy change:
docker: socket | contexts | credentialsis now a validation error naming the actions that replace it. This repo's own.localmostrcis migrated. The sandbox profile no longer punches a hole for the daemon socket;~/.dockerstays 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 realdockerCLI 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-localmostlikewise now runs on every push, queueing for a runner rather than skipping.Config data-loss hardening
A stale
localmost.app.baklaunched against the shared~/.localmostand leftconfig.yamlempty; 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:saveConfigis atomic (temp file + rename), so a reader or second instance never sees a half-written file.configVersion; neither writer will overwrite a file carrying a higher one, so a stale or older app bundle cannot downgrade it.Runtime fixes
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 receivesgithub.workflow; it is now threaded through to policy binding. This was a live defect in per-workflow network policy and a blocker forworkflows.<name>.docker.spawnWorkerForJobstores it.GitHub App permission docs
The App now requests
Contents: Read, needed to fetch.localmostrcfrom private repos — without it the API answersResource not accessible by integrationand 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 documentedVariables: 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