Repository navigation
Conversation
8a52fde to
b1de768
Compare
There was a problem hiding this comment.
🟡 Changes recommended
An unauthenticated intercept listener lets denied-egress boxes bypass network policy and session revocation, while unsafe job assignment can leave descendant processes running after cleanup.
3 open issues
What changed in this PR
Extends managed-program support with preview Windows sandbox launches, customer-built packaging, and Claude Desktop gateway interception that preserves first-party sign-in.
Changes:
- Adds engine-neutral launch plans, MXC request generation, same-terminal hosting, and SSH-agent transport.
- Adds managed Claude Code packaging, discovery, lock settings, and containment posture reporting.
- Adds Claude Desktop interception, configuration controls, status displays, and automated coverage.
| File | Description |
|---|---|
scripts/windows-package/templates/managed-settings-dropin.json.tmpl |
Disables vendor updates in managed installs. |
scripts/windows-package/templates/goldens/managed-settings-dropin.json |
Pins rendered updater settings. |
scripts/windows-package/templates/goldens/claude.cmd |
Pins the Windows command launcher. |
scripts/windows-package/templates/goldens/claude |
Pins the Git Bash launcher. |
scripts/windows-package/templates/claude.tmpl |
Defines the Git Bash sandbox launcher. |
scripts/windows-package/templates/claude.cmd.tmpl |
Defines the Windows command sandbox launcher. |
scripts/windows-package/claude-code-managed.wxs |
Defines managed MSI contents and PATH registration. |
scripts/windows-package/build-package.ps1 |
Verifies vendor binaries and builds the MSI. |
schema/daemon-config.md |
Documents containment, managed locks, and interception settings. |
frontend/desktop/src/views/StatusView.stories.tsx |
Exercises interception and containment status displays. |
frontend/desktop/src/types.ts |
Types interception status and containment posture. |
frontend/desktop/src/stories/fixtures.ts |
Adds interception and containment fixtures. |
frontend/desktop/src/components/DaemonInformation.tsx |
Displays interception health and executor availability. |
frontend/controller/src/views/ConfigurationView.tsx |
Adds Claude Desktop gateway-mode selection. |
frontend/controller/src/views/ConfigurationView.stories.tsx |
Tests interception configuration loading and generation. |
frontend/controller/src/types.ts |
Types the Claude Desktop gateway mode. |
crates/client/Cargo.toml |
Adds Windows security and testing dependencies. |
crates/agentdesktop/src/main.rs |
Dispatches sandbox Git SSH launcher invocations. |
crates/agent/tests/README.md |
Documents the interception integration test. |
crates/agent/tests/integration.rs |
Registers Claude Desktop integration coverage. |
crates/agent/tests/common/mod.rs |
Exposes gateway and identity-provider fixtures. |
crates/agent/tests/common/issuer.rs |
Adds a fake device-flow identity provider. |
crates/agent/tests/common/gateway.rs |
Records interception-related request headers. |
crates/agent/tests/common/agentgateway.rs |
Runs a real gateway for integration tests. |
crates/agent/src/reconcile/mod.rs |
Passes the intercept address into reconciliation. |
crates/agent/src/provider/vscode/tests.rs |
Updates fixtures for interception fields. |
crates/agent/src/provider/vscode/settings_tests.rs |
Updates gateway fixtures for the intercept URL. |
crates/agent/src/provider/README.md |
Adds sandboxed launch to the capability matrix. |
crates/agent/src/provider/pi/mod.rs |
Updates reconciliation test contexts. |
crates/agent/src/provider/mod.rs |
Adds the intercept address to provider context. |
crates/agent/src/provider/copilot/tests.rs |
Updates gateway and reconciliation fixtures. |
crates/agent/src/provider/claude_desktop/testdata/Dockerfile |
Adds a daemon-and-curl integration image. |
crates/agent/src/provider/claude_desktop/reconcile.rs |
Writes intercept settings and removes obsolete helpers. |
crates/agent/src/provider/claude_desktop/mod.rs |
Supplies the intercept address to reconciliation. |
crates/agent/src/provider/claude_code/reconcile.rs |
Writes typed managed hook and MCP restrictions. |
crates/agent/src/provider/claude_code/mod.rs |
Exposes managed-settings posture support. |
crates/agent/src/provider/claude_code/discovery.rs |
Discovers managed binaries and filters hidden drop-ins. |
crates/agent/src/packaging.rs |
Tests template rendering and launcher contracts. |
crates/agent/src/lib.rs |
Exposes launch and interception modules. |
crates/agent/src/launch/wrap_tests.rs |
Tests managed resolution and violation tracking. |
crates/agent/src/launch/testdata/request-opencode.json |
Pins OpenCode sandbox request output. |
crates/agent/src/launch/testdata/request-copilot.json |
Pins Copilot CLI sandbox request output. |
crates/agent/src/launch/testdata/request-claude.json |
Pins Claude Code sandbox request output. |
crates/agent/src/launch/ssh/pipe.rs |
Implements per-launch Windows SSH-agent pipes. |
crates/agent/src/launch/plan.rs |
Defines engine-neutral launch and console plans. |
crates/agent/src/launch/git_ssh.rs |
Dispatches Git SSH calls without a shell. |
crates/agent/src/launch/engine/mxc.rs |
Generates MXC sandbox requests. |
crates/agent/src/launch/engine/mod.rs |
Defines the sandbox engine interface. |
crates/agent/src/launch/console.rs |
Implements same-terminal Windows console hosting. |
crates/agent/src/intercept/store.rs |
Persists interception certificates and private keys. |
crates/agent/src/intercept/mod.rs |
Defines interception health and connection helpers. |
crates/agent/src/intercept/listener.rs |
Handles CONNECT tunnels and intercepted TLS traffic. |
crates/agent/src/intercept/ca.rs |
Generates constrained certificates and caches TLS leaves. |
crates/agent/src/bin/agentdesktop-headless.rs |
Dispatches Git SSH calls in headless mode. |
crates/agent/src/api.rs |
Reports live posture and applies containment policy precedence. |
crates/agent/Cargo.toml |
Adds interception tests and platform dependencies. |
Cargo.toml |
Declares shared networking and TLS dependencies. |
Cargo.lock |
Records updated crate dependencies. |
.github/workflows/claude-desktop-smoke.yml |
Adds a real Claude Desktop interception smoke test. |
.github/workflows/ci.yml |
Runs interception integration coverage in CI. |
Review effort: Balanced
Copilot review on agentdesktop-dev#203, two findings: - The executor was assigned to the kill-on-close job after it was already running, so a descendant spawned in that window could escape cleanup, and job-setup failures were ignored. Create the executor suspended, fail closed if SetInformationJobObject or AssignProcessToJobObject fails, and resume only after the job is in place. - FreeConsole/AttachConsole reset the console-control handler table, so the ignore-Ctrl+C handler installed at the top of run_attach was gone by the time the box ran; a CTRL_C_EVENT would reach the default handler and the kill-on-close job would take the box down. Re-register it after attach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
🔵 Needs a closer look
When interception and containment are enabled together, the unauthenticated loopback CONNECT listener lets sandboxed processes bypass egress restrictions, git allowlists, and session revocation.
2 resolved since last review
Review effort: Balanced
`agentdesktop run claude [-- args]` asks the user-mode daemon to start Claude Code inside a Windows AppContainer (Microsoft MXC base-container tier) with the current directory as its workspace. The box sees the workspace, the user's ~\.claude (secrets denied, executable configuration read-only against in-place writes), the user's git configuration read-only (with HOME set to the daemon's home, so git works in the box) and a private tmp; it gets a short-lived gateway credential in a file instead of a pipe or a sign-in, and the daemon refreshes that credential while the box runs. - New `daemon.containment.executor` startup setting (local only, user mode; schema regenerated). - `POST /v1/launch`, `GET /v1/launches` on the local API; `DaemonInfo.containment` with availability and reason; desktop panel row. - `agentdesktop run <program> -- <args>`; `agentdesktop credential` reads `AGENTDESKTOP_CREDENTIAL_FILE` inside the box. - Behaviour change (Windows, user mode): the daemon's pipe admits only its own user (SID) and SYSTEM, no longer every interactive user, and every accepted connection carries the caller's identity for the launch routes. System mode unchanged. - The daemon's standard handles are no longer inheritable, so they do not travel into the box. - Client crate: `post_json_response`, `StatusError`. - README "Sandboxed launch (Windows, preview)" with the threat model. Residual risks, named in the README: read-only grants do not stop replacement (a read-only file inside a writable folder can be replaced from the box via temp file + rename, reaching `~\.claude\settings.json`, the workspace `.claude` settings, `.mcp.json`, `.git\config` and the hook folders, which the next unboxed session reads); outbound network is open; files the agent creates in the workspace are not gated. Windows only; the MXC executor must be installed by the operator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When the daemon starts the MXC executor but then cannot record the launch pid, or loses track of the executor during the start-up watch or later in the poller, it now stops the executor instead of dropping the handle. Before this change such a box kept running, unrecorded and with a live credential file, until the next sweep. - `LaunchChild::kill` (Ok for an executor that has already exited); `ProcessChild` calls `std::process::Child::kill` (TerminateProcess on Windows). - One `stop_executor` helper at the three places that lose track of the executor. A kill error is logged as a warning and never replaces the original error or keeps the launch from being marked exited. - Boxes still survive a daemon restart: the kill is explicit, not on drop. No config, schema, core or provider change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A launcher (or `agentdesktop llm-proxy session create`) registers a session over the local API and receives a one-off pairing secret bound to a client id. Requests on the new `/sandbox` prefix are accepted only with a registered, unexpired session pairing; the proxy strips every client credential, adds the gateway identity for the session's client id and logs each request with the session id. The device-wide pairing and the existing routes are unchanged, with one named exception: the prefix-less route now refuses first path segments starting with `sandbox`. - `llm_proxy::sessions`: in-memory `SessionRegistry` (hashed pairings, constant-time compare, sliding idle timeout, bound 64, 60 s sweep); `POST/GET/DELETE /v1/llm-proxy/sessions`; creation-time policy check (`allowedClientIds` under controller JWT; subscription mode refused). An expired pairing cannot revive on a backward clock step, and expired pairings are removed on observation. - A canceled sandbox request still leaves its access line; sized and empty responses complete the request log line. - CLI `agentdesktop llm-proxy session create|list|revoke`; client crate `post_json_response`, `StatusError`, `delete`. - README "Sandbox route and per-session pairing" with the threat model. No config change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Read-only grants stop in-place writes, but on Windows a read-only file inside a writable folder can be replaced from the box (temp file + rename), a residual risk the launcher documents. Make such changes visible: before the box spawns, the daemon snapshots the launch's executable configuration (the read-only granted settings, hooks, plugins, `.mcp.json`, the workspace-root `HEAD`, and the repository's `config`, `hooks`, `commondir`, including `.git` itself); it verifies the snapshot every 60 seconds while the box runs and again when it exits, including early exits. Alert-first: nothing is restored or blocked. - Root files carry content hash and file identity, so a replacement with identical bytes still registers (`replaced`, equal hashes). In recursively hashed folders every entry carries its identity, so the same replacement of a child registers as `modified` on the folder. - Folders hash recursively with bounds: snapshot reads are bounded, the walker depth is bounded (deep trees degrade to too-large instead of overflowing the stack), and a tree over the content budget is recorded by structure (names, kinds, sizes, identities), so a changed manifest or an atomic save registers while an in-place same-size edit does not. - Every integrity transition is reported while the box runs; the periodic check has a distinct log line; `GET /v1/launches` carries the drift flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code honours `allowManagedHooksOnly` and `allowManagedMcpServersOnly` only from managed (admin-writable) sources, which the user-mode daemon cannot deliver. Deliver the locks through the system-mode daemon's existing managed-settings drop-in and make the managed state visible: daemon-info reports which managed source Claude Code uses and whether the locks are on, read fresh on every request; each sandboxed launch records the snapshot it started under. - `programs.claudeCode` gains three typed keys (`allowManagedHooksOnly`, `allowManagedMcpServersOnly`, `allowedMcpServers` with typed entries), written by the system daemon into its drop-in exactly as Claude Code reads them and validated at parse time: the controller refuses an invalid configuration before devices see it. A device whose cached configuration fails the new validation does not start (the README has the recovery step). - Posture in daemon-info (Windows only, read per request, off the runtime): the selected source follows Claude Code's documented precedence; lock flags are counted from any admin source. A posture read error is logged as a warning at launch, and the launch records a null source with the lock flags false. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e box Until now a sandboxed launch received a short-lived gateway credential in a file inside the box. Remove that credential from the box entirely: the launch registers a relay session on the daemon's local LLM proxy, and the box talks to `http://127.0.0.1:<port>/sandbox` with a per-session pairing header. The daemon attaches the real gateway credential host-side; the box never sees it. Revocation is immediate: a revoked or exited session gets `403 pairing_invalid` on the next request, even mid-conversation. - Box env: `ANTHROPIC_BASE_URL` points at the relay, `ANTHROPIC_AUTH_TOKEN` is a placeholder, the pairing travels in `ANTHROPIC_CUSTOM_HEADERS`, and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1` keeps a settings.json base URL from overriding the relay. No credential file, no `AGENTDESKTOP_CREDENTIAL_FILE`, and the launch-time credential refresh task is gone. - `daemon.containment.egress` (`allow` | `deny`): with `deny`, the MXC request removes outbound network from the box except host loopback, so the relay is the only way out. Opt-in here; flipping the default is a follow-up. - Session lifecycle: registered in `start_launch` after a capacity pre-check, revoked when the launch fails, when the box exits, and by the poller when it loses the box. `Launch` gains `sessionId`; `GET /v1/launches` carries it. - The sandbox route never starts an interactive sign-in; its 401 tells the user to sign in on the host. The session id is logged once at creation; the pairing value is never logged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The agent crate now builds sandboxed launches from a neutral `LaunchPlan` through an `Engine` trait, with `mxc` as the only and default engine. Nothing observable changes: every config file accepted today stays accepted (the new `engine` key is optional), the request bytes are identical (pinned byte-for-byte by a snapshot test against the pre-move pipeline), and the API is unchanged except that daemon-info's containment block now names the engine. - `launch/plan.rs`: `LaunchPlan` holds the resolved launch (program, args, window title, cwd, env, path lists, egress); `plan()` carries all request building except the MXC envelope. - `launch/engine/`: `trait Engine` (`name`, `container_id`, `request`); `MxcEngine` owns the MXC request types and the envelope. - `launch/profile.rs`: the Claude Code launch profile as data; the hard-coded lists leave `launch/mod.rs`, pinned by profile-equivalence tests. - `daemon.containment.engine: mxc` (optional; absent means `mxc`, an unknown value refuses startup, and an older daemon refuses a config that sets it). The single-variant key lands now so configs written today stay valid when a second engine arrives. Schema regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…efault The egress default flips to deny (ContainmentEgress::default = Deny); an explicit `egress: allow` logs "egress allow: the box has unrestricted outbound" at every launch. Rollback skew: a config without the key means deny here and allow to an older daemon. - Origin-form `/git/<host[:port]>/<path>` routes on the loopback listener, session paired: smart-HTTP only (dumb protocol and /objects refused), git.allow prefix match (host case-insensitive, default port, per path element, case-sensitive path), pushAllow with git's pkt-line ERR on both legs, static credentials injected host-side (longest prefix, allowed hosts only), redirects followed by the daemon (3 hops, same allow entry), and revocation cancels in-flight requests and streaming bodies (outcome="canceled"). - egressUpstream chains the git leg to a central L7 egress endpoint (path case preserved, Host = egress host, Authorization = git credential only, a 503 retried once); https only, no gateway-hop auth in v1. - CONNECT and absolute-form requests are always refused after the pairing check (403 egress_not_supported). This reverses the listener's old "not tunnels" stance in wording only: nothing is tunnelled. A stolen session pairing now grants that session's git egress scope, not just the model relay. - Per-launch gitconfig (GIT_CONFIG_GLOBAL, GIT_TERMINAL_PROMPT=0): insteadOf rewrites, the pairing as a loopback-scoped extraHeader, loopback proxy bypass, emptied credential.helper, identity allowlist, ssh-form refusal mapping; granted read-only, swept secrets-first. The real ~/.gitconfig is denied, and ~/.config/git/config is denied only when its parent directory exists at plan time (the sandbox engine refuses a denied path under a missing folder, and that home path is unreachable in the box by default, so the deny is belt-and-braces). - Request log lines gain host and decision (allow/refuse/upstream). All new tests live in the Linux-gated llm_proxy test module (cfg(all(test, target_os = "linux"))); Windows runs none of them. The request-bytes snapshot and integrity-root tests pin the gitconfig grants and denials. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Git over ssh from the box, without any key material inside it: the daemon brokers the user's ssh agent through a per-launch channel, and only explicitly granted keys are usable. Broker core (launch/ssh/mod.rs, OS-neutral): ssh-agent framing with a 256 KiB cap (zero/oversize close with one log line, truncated = silent EOF); op policy forwards 11/13, answers 17-27 and unknown ops SSH_AGENT_FAILURE locally; deny-by-default key filter (IDENTITIES_ANSWER re-encoded with the allowed keys, SIGN_REQUEST for other keys refused before upstream); upstream failure or a bad reply frame answers FAILURE with decision=upstream_unreachable; per-request session check plus a 50 ms revocation poll (an in-flight request gets EOF, outcome=canceled); one decision log line per request. key_fingerprint = SHA256:<base64-nopad>. A connection budget caps the channel at 8; serve_leg binds one loopback leg per configured host and dials host:22 with a 10 s timeout. Launch wiring (only with git.ssh.hosts non-empty; without it the request bytes are unchanged): legs bound before spawn; ssh_config + known_hosts (0600, seeded host-side) written, ssh_config granted read-only and known_hosts read-write; SSH_AUTH_SOCK set; the gitconfig drops the /git-ssh insteadOf for ssh hosts. Post-spawn pipe setup fails the launch closed (executor stopped, session revoked, legs closed). Git runs GIT_SSH_COMMAND through sh, and Git for Windows' msys sh cannot start in the AppContainer, so the box gets GIT_SSH=<launch-dir>\git_ssh and GIT_SSH_VARIANT=ssh instead. git_ssh is a one-line "#!/<client exe stem>" script written beside ssh_config and granted read-only; the executable (hooked first in main) runs %SystemRoot%\System32\OpenSSH\ssh.exe -F <launch-dir>\ssh_config with git's arguments unchanged. A client stem with whitespace fails the launch, because git cuts the #! line at the first space. %SystemRoot%\System32\OpenSSH joins the box PATH when ssh hosts are configured. Windows (cfg(windows)): AppContainer-descendant SID scan and TokenAppContainerSid reader; per-launch pipe with SDDL D:(A;;GA;;;<daemon>)(A;;GRGW;;;<box AC SID>); first-pipe-instance squat check (a squatter fails the launch closed); client SID verification; per-request upstream to \\.\pipe\openssh-ssh-agent; create/teardown log lines. Non-Windows stub bails. README documents the posture: signatures are not destination-bound; the key filter is the blast-radius control; legs are machine-wide; ssh hosts are host-granular. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
baseline: failures expected on todo paths. Measured (umask 022, cargo test --no-fail-fast -p agentdesktop-core -p agentdesktop-agent --lib): before (8a52fde) 982 = 982 pass (agent 898, core 84); after 1058 = 981 pass / 77 fail (agent 897/77, core 84/0). 76 new test attributes (2 pass as guards, 74 fail); 3 existing tests changed by the contract and now fail (unknown-program text x2, G1 containment_info flip). 64 failures are todo!() stubs, 13 are assertions against the current claude-code-only handler / validate_cwd / containment_info. Scope (frozen spec v3, interface contract): ProgramProfile table rows; resolve_program_for table cases (Claude cases equal to resolve_program); denied args (--sandbox prefix, G9 bare tokens serve/web fail-closed, --port/--hostname/--mdns prefix); preconditions matrix (AC4, D1b egress allow 409, wireModel 409, Claude texts unchanged); Copilot wire-model chain (D2b); OPENCODE_CONFIG_CONTENT snapshot with placeholder pairing, model and enabled_providers; Copilot require-sandbox pre-flight text (D7); pkg-cache probe (version match on three parts, missing, mismatch, empty dir, one-dir/two-dir fallback); validate_cwd harness-root guard (AC7); pre-create list + "the plan never denies a path whose parent is absent"; per-profile plan: D9 union pins incl. .github parent rule, per-profile env, AC6 absences, D5 harness pins, config.json denial, read-only pkg cache, shared git surface, ssh delta profile-independent, window titles; golden scaffolds request-opencode.json / request-copilot.json (todo content; request-claude.json untouched); integrity_paths_for roots per profile (AC9, D12) and the per-profile reverse classification (N4); handler: 201 rows with program id and no Claude posture (AC8, G2), client id = program id (D11), egress-allow 409, --sandbox and listener 400s, allowedClientIds 409, pkg-cache 409, wireModel 409, G1 Claude-only override refusal. Production scaffolding (todo!() bodies): profile::{InjectionStrategy, HarnessHome, ProgramProfile, ProgramPreconditions, program_profile}; launch::{resolve_program_for, check_denied_args, program_preconditions, copilot_wire_model, opencode_config_content, copilot_sandbox_preflight, probe_copilot_pkg_cache, harness_pre_create, plan_program}; integrity::integrity_paths_for. Plumbing: LaunchState gains claude_settings_overridden (set from the daemon args; not yet read). Ledger: specs/PR-6a-test-changes.md (prospects repo). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add program profiles for OpenCode and Copilot CLI alongside Claude Code, and pin the union of the harnesses' workspace configuration in every box so a boxed session cannot be steered by unmanaged workspace files. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Managed packaging and wrap-mode transparency for Claude Code on Windows: - Wrap mode: a containment policy block makes a configured program launch into the AppContainer box by policy; the daemon reports or terminates a native (unwrapped) run per the configured violation posture, canonicalizing the configured executable and considering only .exe discovery candidates. - `agentdesktop run --wait` polls the launch and returns the executor's exit code so a wrapped command can be scripted. - The client verifies the daemon pipe's owner before any request. - Packaging: a customer-built WiX recipe renders PATH-first launcher scripts and the managed-install candidate, with a pinned WiX v4 toolchain and a Windows managed-package deployment guide. No vendor binary is redistributed and a launch never falls back to native. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same-terminal hosting for `run --wait`: a waiting caller that owns a console hands its pid to the daemon, which validates it (own user, own session, not a higher integrity level) and runs the boxed program on that console through a hidden console-attach helper, so `claude` stays in the caller's terminal and returns the program's own exit code. Callers without a console, and clients the daemon may not attach to (an elevated terminal), keep the windowed launch. Ctrl+C reaches the boxed program; the waiter stays, as stock `claude` does. `daemon --user --hidden` starts the per-user daemon with no console window and no taskbar entry (a windowless console object for stdio only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot review on agentdesktop-dev#203, two findings: - The executor was assigned to the kill-on-close job after it was already running, so a descendant spawned in that window could escape cleanup, and job-setup failures were ignored. Create the executor suspended, fail closed if SetInformationJobObject or AssignProcessToJobObject fails, and resume only after the job is in place. - FreeConsole/AttachConsole reset the console-control handler table, so the ignore-Ctrl+C handler installed at the top of run_attach was gone by the time the box ran; a CTRL_C_EVENT would reach the default handler and the kill-on-close job would take the box down. Re-register it after attach. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… main rebase The containment slices carried `intercept: None` in a few App test initializers from when they were based on the Claude Desktop intercept change (agentdesktop-dev#194). This PR is now rebased onto main, which has no intercept module, so those lines are removed. No production behaviour changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fc6a308 to
4e20feb
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical issues leave Git worktree configuration writable without integrity detection and break the packaged Git Bash launcher on CRLF checkouts.
5 open issues
Protect worktree config in sandbox pins and integrity checks · New Normalize launcher line endings to LF before packaging · New Prevent unauthenticated loopback CONNECT proxy bypass Separate attach setup failures from program exit codes · New Document attached console launches and windowed fallback · New
Review effort: Balanced
With extensions.worktreeConfig a repo keeps settings in .git/config.worktree; it was writable and outside the integrity roots, so a boxed process could set core.sshCommand or credential.helper there for a later unboxed git. Add it to the shared git executable-config list, so it is read-only when present, denied when absent (no creation), and integrity-covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t review) On a Windows checkout with core.autocrlf=true the extensionless claude launcher was packaged with CRLF, so its shebang and shell lines broke under Git Bash though the MSI built. Normalize the payload to LF before writing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lot review) early_exit_is_program_result treated exit 125 as an attach-setup failure, but 125 is a valid program result: an attached program exiting 125 in the startup watch failed the launch. The helper now writes an attach-started marker once the executor is spawned; the daemon keys on the marker, so any exit after it is the program's own and only a pre-spawn exit is a setup failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ot review) The deployment guide still called every launch windowed with the executor's exit code and Ctrl+C stopping only the waiter. Document the same-terminal path (program's own exit code, Ctrl+C reaches the program) and when the windowed fallback applies (no console, elevated client, or git.ssh.hosts set). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>



Builds on #171 (Windows credential-store envelope, merged): without it, Windows cannot store
the gateway token pair, so a sandboxed launch would have nothing to relay.
Nothing changes for any existing fleet: without the new
daemon.containment.executorstartupsetting (local only, user mode) and an explicit
agentdesktop run, the daemon behavesexactly as today, and every config file accepted today stays accepted: no migration, no schema
break; the new config keys are optional. One narrow behaviour change ships regardless: on
Windows in user mode, the daemon's pipe now admits only its own user (SID) and SYSTEM instead
of every interactive user; system mode is unchanged.
The feature is preview and Windows-first, but engine-abstracted: launches are built from a
neutral plan through an engine interface, and
engine: mxc(Microsoft MXC AppContainer,base-container tier) is the only and default engine today.
Threat model. The managed program inside the box is the untrusted party. The series drives
at five properties: the box never holds a credential (gateway, git, or ssh key material),
egress is deny-by-default and everything that leaves does so through a brokered, revocable
channel, revocation is immediate, config tampering is detected, and the containment locks
arrive through an admin-writable channel.
What it does, commit by commit
Sandboxed launch.
agentdesktop run claude [-- args]asks the user-mode daemon to startClaude Code inside an AppContainer with the current directory as its workspace. The box sees
the workspace,
~\.claude(secrets denied, executable configuration read-only againstin-place writes), the user's git configuration read-only (
HOMEset so git works in the box;a later slice replaces this with a generated per-launch gitconfig), and a private tmp. New:
POST /v1/launch,GET /v1/launches,DaemonInfo.containment, a desktop panel row, and aREADME threat-model section.
Lost-box supervision. If the daemon starts the executor but cannot record the launch, or
loses track of it during the start-up watch or in the poller, it stops the executor instead
of dropping the handle; a kill error never masks the original error. Boxes still survive a
daemon restart: the kill is explicit, not on drop.
Relay sessions. The local LLM proxy gains a
/sandboxroute gated by per-sessionpairings: a launcher registers a session and receives a one-off pairing secret (hashed at
rest, constant-time compare, sliding idle timeout, bounded registry). The proxy strips every
client credential, attaches the gateway identity host-side, and logs each request with its
session id; existing routes and the device-wide pairing are unchanged.
Integrity watch. Before the box spawns, the daemon snapshots the launch's executable
configuration (granted settings, hooks, plugins,
.mcp.json, the workspace-rootHEAD, therepository's
config,hooks,commondir, and.gititself) and verifies it every 60seconds and at exit. Content hash plus file identity, so a replacement with identical bytes
still registers. Alert-first: transitions are reported and
GET /v1/launchescarries thedrift flag; nothing is restored or blocked.
Managed locks and posture. Claude Code honours
allowManagedHooksOnlyandallowManagedMcpServersOnlyonly from admin-writable sources, which a user-mode daemon cannotdeliver. The system-mode daemon's managed-settings drop-in now carries them (typed, validated
at parse time: the controller refuses an invalid configuration before devices see it), and
daemon-info reports the managed source and lock state; each launch records its posture.
Relay-run: no credential in the box. Earlier in the series the box received a short-lived
credential file; this commit removes it entirely. The box talks to the relay with a per-session
pairing header, the daemon attaches the real gateway credential host-side, and a revoked or
exited session gets
403 pairing_invalidon its next request, even mid-conversation.daemon.containment.egress: denyremoves the box's outbound network except host loopback,so the relay is the only way out.
Engine structure. The launch pipeline becomes a neutral
LaunchPlan, anEnginetrait,and a data-driven Claude Code profile, with
mxcthe only engine; the request bytes are pinnedbyte-for-byte by a snapshot test, and the optional
enginekey refuses unknown values.Git over the relay; egress denied by default. The egress default flips to deny (an explicit
egress: allowlogs at every launch; a config without the key means deny here and allow to anolder daemon). So that deny does not break git, the relay gains origin-form
/git/<host[:port]>/<path>routes, session-paired: smart-HTTP only, agit.allowprefixallowlist,
pushAllowenforced with git's own pkt-line ERR, static git credentials injectedhost-side only for allowed hosts, redirects followed by the daemon (3 hops, same allow entry),
and revocation that cancels in-flight requests and streaming bodies. An optional
egressUpstreamchains the git leg to a central L7 egress endpoint (https only). CONNECT andabsolute-form requests are always refused. Each launch gets a generated gitconfig (insteadOf
rewrites, the pairing as a loopback-scoped extraHeader, emptied credential helpers, an identity
allowlist), granted read-only; the user's real git configuration, granted read-only earlier in
the series, is now denied, so host credentials are never usable from the box, under
allowtoo.SSH agent broker; git over ssh. For hosts configured under
git.ssh.hosts, the daemonbrokers the user's ssh agent into the box through a per-launch channel (on Windows, a named
pipe whose DACL admits exactly the daemon and that launch's AppContainer SID, with a
first-instance squat check that fails the launch closed). The broker enforces an op policy
(sign/list forwarded, mutating ops answered failure locally) and a deny-by-default key filter:
only explicitly granted keys are listed or usable for signing; key material never enters the
box. git reaches ssh without a shell (a generated
GIT_SSHlauncher, since Git for Windows'msys sh cannot start in an AppContainer), with a per-launch
ssh_configand seededknown_hosts. Without ssh hosts configured, the launch request bytes are unchanged.Program profiles: OpenCode and Copilot CLI. Alongside Claude Code, the daemon gains
data-driven profiles for OpenCode and Copilot CLI, so
agentdesktop run opencodeandagentdesktop run copilotbox those harnesses the same way. Every box pins the union of thethree harnesses' workspace configuration read-only, so a boxed session cannot be steered by an
unmanaged workspace config file a harness would otherwise pick up.
Managed packaging and wrap-mode transparency. A
containment.programspolicy block makes aconfigured program launch into the box by policy: a managed launcher on the machine
PATHruns
agentdesktop run --waitsoclaude(and the other harnesses) behave as the stockcommand while running boxed. A native, unwrapped run of the managed binary is reported or
terminated per the configured violation posture (the scan canonicalizes the configured
executable and considers only
.exediscovery candidates), and the client verifies the daemonpipe's owner before every request. Packaging is a customer-built WiX recipe that renders the
PATH-first launcher scripts and the managed-install candidate, pinned to a WiX v4 toolchain,
with a Windows managed-package deployment guide; no vendor binary is redistributed and a launch
never silently falls back to native.
agentdesktop run --waitreturns the executor's exit codeso a wrapped command can be scripted.
Same-terminal console hosting and a windowless daemon.
agentdesktop run --waitstartedfrom a console (Windows Terminal, the classic console host, PowerShell, Git Bash) runs the
boxed program in that same terminal: a waiting caller that owns a console hands its pid to the
daemon, which validates it (same user, same session, not a higher integrity level) and hosts
the box on that console through a hidden console-attach helper, so
claudekeeps its stockcontract — same window, same input, the program's own exit code, Ctrl+C reaching the program.
A caller without a console, or a client the daemon may not attach to (an elevated terminal),
keeps the windowed launch. Separately,
daemon --user --hiddenstarts the per-user daemon withno console window and no taskbar entry.
Review record
The series was developed as six reviewed PRs on my fork — LutzLange#37, #38, #39,
#40, #41, #42, one per slice — each through multiple automated review rounds with all findings
fixed or answered, and each tested on Windows before integration; the engine refactor followed
the same process. The git-egress and ssh-agent slices went through the same process offline
(spec review, adversarial and maintainer review rounds, and a Windows lab each); their full
commit history is on the fork branches
containment/pr4-relay-gitandcontainment/pr5-ssh-agent. This PR is the squashed, rebased composition of all of it ontocurrent main.
The four trailing slices (program profiles, managed packaging + wrap-mode, same-terminal
hosting + windowless daemon) were developed the same way on the fork branches
containment/pr6a-profiles,containment/pr6-packagingandcontainment/pr7-same-terminal(spec freeze, automated review rounds with findings fixed or answered, and a Windows lab each),
then squashed on top here. Thirteen slices in all, one per slice (plus a console-attach fix
from review and a small rebase-hygiene commit).
This PR is rebased onto current
main; it has no dependency on the Claude Desktop interceptchange and carries no intercept code.
Testing
core lib 104, 0 failed;
x86_64-pc-windows-gnulib tests compile (no-run) and clippy clean;schema up to date; frontend build/lint and Storybook (72 + 71) clean.
OpenCode and Copilot CLI launches end to end, answer through the
/sandboxrelay, and arevoked session gets
403; managed packaging + wrap-mode — the managed MSI installs thePATH-first launchers, a wrapped
clauderuns boxed with the correct posture(
violations: 0), and a native unwrapped run is reported per policy (L6, 13 checks);same-terminal hosting —
claudein a Windows Terminal tab renders the TUI in that tab,answers a prompt through the relay (
/v1/messages200), and exits with the program's owncode, Ctrl+C reaches the box and the waiter survives, the windowless daemon starts with no
window or taskbar entry.
x86_64-pc-windows-gnu, binaries hash-verified):the containment crates' Windows lib tests passed on an earlier head of this series; the
relay's git tests are Linux-gated and run only there. 11 agent integration tests require a
Linux-built daemon binary by design, and 11 controller tests fail on a
sqlite://Windowspath in the test fixture (pre-existing on main); this PR does not touch
crates/controller.engine:"mxc"; a boxwith the default egress deny answered prompts through the
/sandboxrelay while a curl frominside the box got no connection (the same curl outside the box reached its target); the
gateway saw only the relay's authenticated 200s; revoking the session turned the very next
request into
403 pairing_invalid, which never reached the gateway; and the launch directorycarries the generated per-launch gitconfig.
replacing a watched file from inside the box with identical bytes registered as
replaced(equal hashes, new identity) and set
integrityDrift: true; the admin drop-in flipped therunning daemon's reported posture; and across the engine refactor the normalized launch
request hashed identical before and after, per egress value.
worked in the box for allowlisted entries, including through the upstream leg (verified at
box, daemon and gateway); a non-allowlisted host was refused; a push against an empty
pushAllowwas refused with git's own pkt-line ERR and zero bytes reached the remote(verified unchanged); a pushAllow-matched push went upstream and stopped at the remote's 401,
since no git credential was configured. One lab find is folded in: the XDG git config path is
denied only when its parent directory exists, because the engine refuses a denied path under
a missing folder.
remote, with desktop and headless daemons; a revoke during a push cut the session
mid-transfer, though the pack the server already held still landed (the residual-risk item
below); only granted keys were listed or signed (deny-by-default filter verified in-product).
Boundary checks on the real build: a raw
CreateFileWon the user'sagent pipe from inside the box is refused (access denied), the live launch-pipe DACL carries
exactly the user and that launch's AppContainer SID, a concurrent second box and a relaunch
under the same container id are both refused, and a squatter winning the pipe-name race
fails the launch closed with the session revoked.
Residual risks and known limits
replaced from the box via temp file + rename, reaching settings,
.mcp.json,.git\configand hook folders the next unboxed session reads. The integrity watch makes this visible but
is alert-first: nothing is restored, and the drift flag never clears for a launch.
.gitfile (worktree or submodule) is box-writable; agitdir:rewrite is detected, not prevented.egress: allow, the relay is a credential boundary, not a network one, anddirect and relay traffic are indistinguishable at the gateway by address.
revoked session's box keeps running — revocation cuts model, git and ssh access, not the
process. Revocation cancels in-flight transfers, but it cannot undo a push the server has
already completed: a ref updated before the cancel stays updated.
while it lasts. The deny-by-default key filter is the blast-radius control; the broker's
loopback legs are machine-wide like the relay, and ssh host granularity is per-host, not
per-repository.
wxc-exec.exe) must be installed by the operator; no installer yet.the agent creates in the workspace are not gated.
full vendor path bypasses the launcher, and the violation posture (report or terminate) is the
alert-first backstop, not prevention. A tool that bundles its own CLI (e.g. a VS Code
extension) is a residual. Packaging is customer-built; no vendor binary is redistributed.
executor's exit code, not the program's. Window-close into an attached box is not fully
characterized yet, and the
--hiddendaemon detaches from Task Scheduler supervision (stop itby pipe or pid).
Follow-ups
here (Claude Code, OpenCode, Copilot CLI).
fetch with a real credential, and revocation canceling an in-flight git transfer over the
relay (the ssh form is lab-proven); blocked-egress observability.
sqlite://-path-on-Windows test fixture issue (independentof this PR; found while running the suite on Windows).
🤖 Generated with Claude Code