Skip to content

Protect host-trusted files from the sandbox - #4

Merged
jdoss merged 17 commits into
bridge-authfrom
host-state
Oct 9, 2026
Merged

jdoss merged 17 commits into
bridge-authfrom
host-state

Conversation

@jdoss

@jdoss jdoss commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3: merge that first. Once its branch is deleted, GitHub retargets this pull request to master.

Keeps the sandboxed agent from changing files the host later runs, or reports when it does.

What it does

  • Repo git state: each git dir's config, the repo's and every submodule's, is mounted read-only over itself, so the VM can't set core.fsmonitor, core.sshCommand, an alias or the like that the host's next git command would run (a shell prompt's git status included, even while the VM runs). hooks/ becomes a throwaway copy refreshed from the host at each launch, with links copied as links: the host's hooks still run in the VM and prek install works there. krun refuses a rename onto a file mount, so git commands that save config can't save it: git push -u still pushes but can't record the upstream, branch -u can't set it (both print an error and exit 0), and git remote add fails. The sandbox briefing tells the agent.
  • Settings copies: the profile's settings.json is now always a per-launch copy in the VM, not only when a sudo deny needs stripping. A missing one is created as {} so there is a file to mount over, and a symlinked one is never read through. Existing .claude/settings.local.json files at the work-tree root and the launch dir get per-launch copies too. claude falls back to an in-place write when its rename fails, so settings changes still work in the VM.
  • Statusline check: host launches of a profile that is marked for the sandbox, or has been launched in one, warn when statusline.sh is not the link to ~/.claude/statusline.sh.
  • Post-session report: supervised launches list the MCP servers, .mcp.json approvals and new .claude/settings.local.json files a session added or changed. Names only, never commands or env; names from the VM have non-printable characters replaced and rich markup escaped, as does the link target _linked_mounts prints.
  • README: the security section lists what is protected and what is still yours to review.

Test plan

  • prek run --all-files passes (378 tests)
  • Mutation checks: 26 breaks across the pins, copies, link handling, submodule discovery, statusline check, report and its sanitising each fail a test
  • Real krun VM: git config writes to the repo and a submodule fail and leave the host's files untouched; a hook written in the VM never reaches the host, while the host's hooks run there; settings written in the VM stay in the copies; branches and objects created in the VM reach the host
  • CI passes on this pull request

jdoss added 14 commits October 9, 2026 09:33
claude on the host runs the hooks and statusline command in the
profile's settings.json, and the VM sees the profile dir read-write. The
VM got a copy mounted over the file only when there was a sudo deny to
strip; otherwise it wrote the real file. It now always gets a per-launch
copy.

A missing settings.json is created as {} so there is a file to mount
over, and a symlinked one is never read through: the VM could have aimed
it at another profile's credentials, so the VM gets {} and a warning
instead. Failing to write the copy now stops the launch.
The work tree and its git dir are mounted read-write, so the VM could
set core.fsmonitor or an alias in .git/config, or plant a hook, and the
host's next git command in the repo would run it, a shell prompt's git
status included. Each git dir's config, the repo's and its submodules',
is now mounted read-only over itself, and hooks/ is replaced by a
throwaway copy refreshed from the host at each launch, with links
copied as links.

krun refuses a rename onto a file mount, so git commands that save
config (push -u, branch -u, remote add) cannot save it in the VM. The
push itself still works; the sandbox briefing tells the agent.
claude loads hooks from .claude/settings.local.json at the work tree
root and at the launch dir, and git ignores the file, so a change made
in the VM would run on the host unnoticed. Where the file exists, the VM
now gets a per-launch copy, never read through a link.
The VM gets the global statusline script read-only but can replace the
profile's link to it, and claude runs the statusline on every host
launch. A host launch of a profile that is marked for the sandbox, or
has been launched in one, now warns unless statusline.sh is absent or
is the link to ~/.claude/statusline.sh that add creates.
The profile's .claude.json stays writable from the VM, and claude on the
host starts the MCP servers it lists. Supervised launches now record the
MCP servers, the .mcp.json approvals and which .claude/settings.local.json
files exist before the VM starts, and list what the session added or
changed once it ends.

Only names are printed, never commands or env, which carry tokens. The
names come from the VM, so non-printable characters are replaced and
rich markup is escaped before printing, as is the link target that
_linked_mounts prints.
The security section said the agent could change git config, hooks,
settings.json and .claude/ settings that the host later runs. It now
lists what is protected (read-only git config, throwaway copies of the
hooks and settings files, the statusline check, the post-session MCP
report) and what still needs reviewing.
The override decides every launch, but `sandbox <name>` and `list` only
read the .sandbox marker, so with it set they could report a microVM
for a profile that launches on the host. Both now say when the
override is set and what it does, and `sandbox --on/--off` says so when
the override contradicts the new setting.

They also read the marker the way the launch does: a dangling marker
link counts as sandboxed. `sandbox --on` replaces a linked marker
instead of touching, and so creating, the file the link points at.
The sandbox's copy of the profile's settings.json drops deny rules such
as Bash(sudo *), which would otherwise block the VM's own scoped sudo
(deny wins even under --dangerously-skip-permissions). Shared settings
from shared-settings.json reach claude through --settings and kept
them, so a shared sudo deny still blocked sudo dnf and sudo podman in
the VM. Sandbox launches now drop the same denies from the shared
settings; host launches keep them.
Both were mounted straight from the host with :z, which relabels the
user's own files for containers (known_hosts loses its ssh_home_t
label) out from under the host's tools and restorecon. The VM now gets
copies in the profile's state dir, refreshed every launch, and :z
relabels those, as the launcher already does for the claude binary.
When the host has custom CA anchors, the entrypoint ran update-ca-trust
extract on every boot, which takes about 7 s in the VM. The host's own
update-ca-trust has already merged those anchors with its system roots
into /etc/pki/ca-trust/extracted, so the launcher now mounts that
directory read-only at the same path, alongside the anchors, and the
entrypoint no longer extracts. Boot to exit fell from 13.7 s to 4.6 s on
a host with six anchors, with every anchor still trusted.

Anchors without an extracted directory warn and mount neither.
Each host update of claude copied the 244 MiB native binary into the
data dir for the sandbox. On Python 3.13, shutil.copy writes every
block of it, and snapshots then keep each old version pinned. The copy
now asks the kernel to share the binary's blocks (FICLONE), which is
instant and takes no space on btrfs or XFS, and falls back to a plain
copy elsewhere or across filesystems.
podman run passes the host's http_proxy, https_proxy, no_proxy and the
like into the container by default, so a proxy URL's credentials
reached the VM although the launcher forwards nothing else from the
host environment. Sandbox launches now pass --http-proxy=false; a proxy
the VM needs can be forwarded with CLAUDE_PROFILE_SANDBOX_FORWARD_ENV.
The digest-pinned FROM line and the printf that writes storage.conf ran
past 100 characters. The digest moves into an ARG ahead of FROM, and
printf takes one argument per line as the sudoers step does. The build
resolves the same base digest and writes a byte-identical storage.conf.
The security section described what the VM can change but not which
credentials it can read or use. It now lists what every launch hands
the VM (the profile's Claude login, .env values, MCP tokens in
.claude.json and ancestor .mcp.json files, a copy of ~/.gitconfig),
what each opt-in setting adds, and what is not mounted.
jdoss and others added 3 commits October 9, 2026 09:57
Three tests looked for a word that the warning only prints inside a
long temp path. rich wraps such a path mid-word at its 80-column width,
so the word was split whenever the temp dir's length put a line break
inside it: CI's /tmp/pytest-of-runner paths did, locally they missed.
The assertions now ignore whitespace, as the other path checks do, and
pass at each of 41 temp-dir lengths.
Sandbox fixes: launch status, shared denies, CA trust, copies
@jdoss
jdoss merged commit 4d73ec1 into bridge-auth Oct 9, 2026
1 check passed
@jdoss
jdoss deleted the host-state branch October 9, 2026 15:41
@jdoss jdoss mentioned this pull request Oct 9, 2026
3 of 5 tasks
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.

1 participant