Skip to content

feat(agent): sandboxed launches for managed programs (Windows, preview) - #203

Open
LutzLange wants to merge 19 commits into
agentdesktop-dev:mainfrom
LutzLange:containment/submission-20261020
Open

LutzLange wants to merge 19 commits into
agentdesktop-dev:mainfrom
LutzLange:containment/submission-20261020

Conversation

@LutzLange

@LutzLange LutzLange commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

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.executor startup
setting (local only, user mode) and an explicit agentdesktop run, the daemon behaves
exactly 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 start
Claude Code inside an AppContainer with the current directory as its workspace. The box sees
the workspace, ~\.claude (secrets denied, executable configuration read-only against
in-place writes), the user's git configuration read-only (HOME set 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 a
README 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 /sandbox route gated by per-session
pairings: 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-root HEAD, the
repository's config, hooks, commondir, and .git itself) and verifies it every 60
seconds and at exit. Content hash plus file identity, so a replacement with identical bytes
still registers. Alert-first: transitions are reported and GET /v1/launches carries the
drift flag; nothing is restored or blocked.

Managed locks and posture. Claude Code honours allowManagedHooksOnly and
allowManagedMcpServersOnly only from admin-writable sources, which a user-mode daemon cannot
deliver. 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_invalid on its next request, even mid-conversation.
daemon.containment.egress: deny removes 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, an Engine trait,
and a data-driven Claude Code profile, with mxc the only engine; the request bytes are pinned
byte-for-byte by a snapshot test, and the optional engine key refuses unknown values.

Git over the relay; egress denied by default. The egress default flips to deny (an explicit
egress: allow logs at every launch; a config without the key means deny here and allow to an
older daemon). So that deny does not break git, the relay gains origin-form
/git/<host[:port]>/<path> routes, session-paired: smart-HTTP only, a git.allow prefix
allowlist, pushAllow enforced with git's own pkt-line ERR, static git credentials injected
host-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
egressUpstream chains the git leg to a central L7 egress endpoint (https only). CONNECT and
absolute-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 allow too.

SSH agent broker; git over ssh. For hosts configured under git.ssh.hosts, the daemon
brokers 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_SSH launcher, since Git for Windows'
msys sh cannot start in an AppContainer), with a per-launch ssh_config and seeded
known_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 opencode and
agentdesktop run copilot box those harnesses the same way. Every box pins the union of the
three 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.programs policy block makes a
configured program launch into the box by policy: a managed launcher on the machine PATH
runs agentdesktop run --wait so claude (and the other harnesses) behave as the stock
command 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 .exe discovery candidates), and the client verifies the daemon
pipe'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 --wait returns the executor's exit code
so a wrapped command can be scripted.

Same-terminal console hosting and a windowless daemon. agentdesktop run --wait started
from 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 claude keeps its stock
contract — 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 --hidden starts the per-user daemon with
no 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-git and
containment/pr5-ssh-agent. This PR is the squashed, rebased composition of all of it onto
current 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-packaging and containment/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 intercept
change and carries no intercept code.

Testing

  • Linux, at this PR's head (rebased onto current main): the gate is green — agent lib 1053,
    core lib 104, 0 failed; x86_64-pc-windows-gnu lib tests compile (no-run) and clippy clean;
    schema up to date; frontend build/lint and Storybook (72 + 71) clean.
  • Added slices, Windows VM (cross-compiled, hash-verified): program profiles — boxed
    OpenCode and Copilot CLI launches end to end, answer through the /sandbox relay, and a
    revoked session gets 403; managed packaging + wrap-mode — the managed MSI installs the
    PATH-first launchers, a wrapped claude runs boxed with the correct posture
    (violations: 0), and a native unwrapped run is reported per policy (L6, 13 checks);
    same-terminal hosting — claude in a Windows Terminal tab renders the TUI in that tab,
    answers a prompt through the relay (/v1/messages 200), and exits with the program's own
    code, Ctrl+C reaches the box and the waiter survives, the windowless daemon starts with no
    window or taskbar entry.
  • Windows (Windows 11 VM, cross-compiled 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:// Windows
    path in the test fixture (pre-existing on main); this PR does not touch crates/controller.
  • End-to-end smoke on the VM: the daemon reports engine:"mxc"; a box
    with the default egress deny answered prompts through the /sandbox relay while a curl from
    inside 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 directory
    carries the generated per-launch gitconfig.
  • Earlier series validation on the VM: a live canceled relay request logged as canceled;
    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 the
    running daemon's reported posture; and across the engine refactor the normalized launch
    request hashed identical before and after, per egress value.
  • Git over the relay on the VM: with egress deny, clone and fetch of a public repository
    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
    pushAllow was 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.
  • SSH agent broker on the VM: git fetch and push over ssh worked in the box against a lab
    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 CreateFileW on the user's
    agent 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

  • 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 settings, .mcp.json, .git\config
    and 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.
  • A .git file (worktree or submodule) is box-writable; a gitdir: rewrite is detected, not prevented.
  • With an explicit egress: allow, the relay is a credential boundary, not a network one, and
    direct and relay traffic are indistinguishable at the gateway by address.
  • A session pairing is a bearer secret on the machine (host loopback is machine-wide); a
    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.
  • An ssh signature is not destination-bound: a granted key signs whatever the session asks
    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.
  • The MXC executor (wxc-exec.exe) must be installed by the operator; no installer yet.
  • Windows only today (the engine interface is the seam for macOS/Linux engines later); files
    the agent creates in the workspace are not gated.
  • Wrap mode routes through PATH-first launchers, not a binary shim: a program invoked by its
    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.
  • Same-terminal hosting is faithful only on the attached path: the windowed fallback keeps the
    executor's exit code, not the program's. Window-close into an attached box is not fully
    characterized yet, and the --hidden daemon detaches from Task Scheduler supervision (stop it
    by pipe or pid).

Follow-ups

  • A prerequisites installer for the MXC executor, and launch profiles beyond the three shipped
    here (Claude Code, OpenCode, Copilot CLI).
  • Deferred to first real deployment: a relay push that lands on a live remote, private-repo
    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.
  • Broker lifecycle polish: revoke on pipe-server death, idle TTL for loopback legs.
  • File the pre-existing controller sqlite://-path-on-Windows test fixture issue (independent
    of this PR; found while running the suite on Windows).

🤖 Generated with Claude Code

@LutzLange
LutzLange force-pushed the containment/submission-20261020 branch from 8a52fde to b1de768 Compare October 28, 2026 20:41
@LutzLange
LutzLange requested a balanced review from Copilot October 30, 2026 12:34

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

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

Comment thread crates/agent/src/intercept/listener.rs Resolved
Comment thread crates/agent/src/launch/console.rs Resolved
Comment thread crates/agent/src/launch/console.rs Resolved
LutzLange added a commit to LutzLange/agentdesktop that referenced this pull request Oct 30, 2026
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>
@LutzLange
LutzLange requested a balanced review from Copilot October 30, 2026 16:35

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

When interception and containment are enabled together, the unauthenticated loopback CONNECT listener lets sandboxed processes bypass egress restrictions, git allowlists, and session revocation.

1 open issue
2 resolved since last review

Review effort: Balanced

LutzLange and others added 15 commits October 30, 2026 18:08
`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>
@LutzLange
LutzLange force-pushed the containment/submission-20261020 branch from fc6a308 to 4e20feb Compare October 30, 2026 17:30
@LutzLange
LutzLange requested a balanced review from Copilot October 30, 2026 17: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.

🟡 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

Review effort: Balanced

Comment thread crates/agent/src/launch/profile.rs Resolved
Comment thread crates/agent/src/launch/console.rs Resolved
Comment thread docs/windows-managed-package.md Resolved
LutzLange and others added 3 commits October 30, 2026 18:54
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>

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

Unpublished findings involving deny-mode LAN access and repository Git credential exposure to untrusted processes require human security assessment.

1 open issue
4 resolved since last review

Review effort: Balanced

This branch has not been deployed

No deployments
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