Repository navigation
(sandbox): opt-in writable skills and agents - #483
Conversation
d5352f2 to
d4188a8
Compare
devsuitup
left a comment
There was a problem hiding this comment.
Review at d4188a8. This weakens the sandbox on request, so I read it as a security change. The default is off and the opt-in stays confined to skills and agents, but one hole blocks.
Blocking
- A symlinked
skillsoragentsmakes an external repository writable without its protections (scripts/claude-sandbox.sh, theUSER_STATE_ENTRIES/PROJECT_STATE_ENTRIESadditions and the symlink branch of the mount loop). When~/.claude/skills(or a project.claude/skills, oragents) is a symlink to another directory, the opt-in adds a writable mount of the target. The existing protection for a linked target (its Git config and hooks, settings linked into it) is not applied to it, so the sandbox can write.git/hooksor a settings file there, and that runs outside the sandbox later. That goes beyond the documented exception ("what is written in skills and agents is loaded by an unsandboxed claude"). Fix: treat the opted-in targets like the other writable targets, protecting Git config/hooks and linked settings inside them. Tests: recording-only tests for an external linked target, and for the enabled case thatsettingsand hooks stay read-only.
Non-blocking
- With either flag on, the Sandbox tooltip (
public/index.html) still promises protection of later executable content and does not state the exception. - The new explanatory comments in
claude-sandbox.shexceed the one-line pointer rule; the rationale belongs indocs/sandbox.md.
12 UI tests pass; the 46 wrapper tests are skipped on Windows, so the mount arguments were reasoned from the code, not run. Not verified: a real sandbox, Node 20/22 with c8.
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
|
Thanks. Rebased on current main (d165362) and addressed in 376ada7. Blocking — linked target writable without its protections
Tests (recording-only): an external linked target that is a repository with a Non-blocking
Also: |
d4188a8 to
376ada7
Compare
devsuitup
left a comment
There was a problem hiding this comment.
Review at 376ada7. This weakens the sandbox on request, so I read it as a security change. The default is off, the branch is rebased on main, and the commit "protect git and .claude inside opted-in skills and agents" closes the blocker of d4188a8: an opted-in skills or agents that is a symlink to an external repository now gets its Git config and hooks and its .claude protected (protect_tree "$STATE_LINK_TARGET" nocreate). One bypass remains, found by tracing the argument vector and checked in the mount ordering.
Blocking
- A shared or nested target lets the writable bind override a read-only protection (
scripts/claude-sandbox.sh,bind_user_claude_dir~487 and the ordering inbuild_bwrap_args~759). The mounts are sorted by the depth of the destination and then by insertion order. If~/.claude/hooksand the opted-in~/.claude/skillsare symlinks to the same directory (orskillspoints to a directory that contains, or sits under, a protected path such as Git hooks), the read-only mount made forhooksand the writable bind made forskillsland on the same destination or on nested ones at the same depth. The loop visitshooksbeforeskills, so the writable bind comes later and wins: the hook scripts become writable and run outside the sandbox on the next session, which is exactly what the read-only.claudeis there to prevent. The same happens for a target beneath protected Git hooks. Fix: resolve every opted-in target and refuse (or leave read-only) a target equal to, an ancestor of, or under any protected path, and apply the protections after all the writable binds; add the overlap check to the argument builder rather than relying on the loop order. Tests: recording-only fixtures for an equal target and for an ancestor/descendant overlap, asserting that the protected path ends up read-only in the final vector.
Non-blocking
test/sandbox-wrapper.test.js(~517): the new fixtures cover an external linked repository but not equal-target or ancestor overlaps, so they would pass with the bypass present.
Four existing test files pass (37 passed, 49 skipped, 0 failed) on Node 24; all wrapper tests are skipped on Windows and WSL was denied, so Linux mount enforcement is verified only by reading the argument vectors. Not run: Node 20/22 with c8, fresh CI. CI on this head is not approved yet.
|
Tested on Linux (Ubuntu, GNOME on Wayland), PR head 376ada7. How it was run. Flags off and on: works
The remaining blocking point (a shared or nested target) reproduces live
Not run: a session started from the Switchboard UI, and the PR's own test file. |
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
|
Thanks, and thanks @jbr-sekoia for the live reproduction. Rebased on current main (eaf59ff) and addressed in 55ad25a. Blocking — a shared or nested target overrides a read-only protection The finished mount list is now checked before launch (
Since the check runs on the whole list, the order the entries are bound in no longer matters. A read-only path inside the writable directory is still allowed: the deeper mount wins, as @jbr-sekoia observed for I chose to refuse rather than silently leave the directory read-only, so that the flag never quietly does nothing. One consequence: Non-blocking — tests for overlaps Two new recording-only tests:
Each of the three rules was removed in turn, and each removal turns a case red. Applying the covering-mount rule to project entries as well turns the existing opt-in test red.
|
376ada7 to
55ad25a
Compare
devsuitup
left a comment
There was a problem hiding this comment.
Review at 55ad25a. This weakens the sandbox on request, so I read it as a security change. The default is off. The equal-target and descendant collisions of my last review are fixed with their fixtures (check_opt_in_binds refuses a target equal to, or under, a protected path), the tooltip now states the exception, and the shell comments are fixed. One bypass remains, found by tracing the final mount vector.
Blocking
- The same repository can be mounted at two destinations, and only the first is protected (
scripts/claude-sandbox.sh,check_opt_in_binds~703 and the protection pass). The check and the Git config/hooks protection work on destination paths. A symlinked Additional Directory and an opted-inskillstarget that resolve to the same repository are mounted at two different destinations; the protections (read-only.git/hooks,config, linked settings) are applied to the first one only, and the second stays writable, so the sandbox can write a hook there and it runs outside the sandbox later. Protection across aliases and writable ancestors is still incomplete. Fix: resolve every writable target withrealpath, key the protections by the resolved path, and apply them to every destination that maps to a protected real path (or refuse the opt-in when two destinations resolve to the same real path). Tests: recording-only fixtures for an alias of the same repository through an Additional Directory, and through a writable ancestor, asserting that the final vector has the protected paths read-only at every destination.
Non-blocking
test/sandbox-wrapper.test.js(~22): the new rationale comment goes beyond the one-line pointer allowed in code.
Four related test files give 46 passed and 51 skipped; every wrapper test is skipped on Windows and WSL was denied, so the mount behaviour is verified only by reading the argument vectors. Not run: a real sandbox, Node 20/22 with c8, fresh CI. CI on this head is not approved yet.
jbr-sekoia
left a comment
There was a problem hiding this comment.
Review at 55ad25a. This weakens the sandbox on request, so I read it as a security change. The default is off, and check_opt_in_binds closes the blocker of 376ada7 for ~/.claude. I ran scripts/claude-sandbox.sh at this head under env -i, with a throwaway HOME and a stand-in claude that tries the writes. Results for the cases in the Linux comment, all with RW_SKILLS=1:
hooksandskillslinked to the sameX/skills: refused.agentsandcommands, oragentsalone, linked to the skills target: refused.hooks→X/h,skills→X/h/skills: refused.skills→X/skills,hooks→X/skills/hk: launches, andhooks/h.shstays read-only.- A project's
core.hooksPathat the skills target, linked or a real~/.claude/skills: refused.
The project-level rule still has a bypass.
Blocking
- The project-level overlap check compares two spellings of a path (
scripts/claude-sandbox.sh,check_opt_in_binds~715). For a project's.claude/skills, only thePROTECTED_SEENrule applies.PROTECTED_SEENholds resolved host paths.tis the mount destination:$PWD/.claude/skills, or the Additional Directory, spelled as it was reached.- How it fails. When the project is reached through a symbolic link,
tnever matches. The read-only bind made for.claude/hooks -> skillsand the writable bind ofskillsthen share a destination, and the later one,skills, wins. - When. Switchboard runs
cd <spawnCwd> && … claude-sandbox.sh, so$PWDkeeps the stored spelling. The same happens with a symlinked$HOME, or an Additional Directory typed through a link. - Reproduced. Project
.claude/hooks,agentsorcommands→skills, withRW_SKILLS=1:- working directory as the real path: refused;
- the same project reached through a symlinked parent: launches, and
echo >> .claude/hooks/h.shfrom inside changed the file on the host; - an Additional Directory through a link, and a
$HOMEthat is itself a link: same result.
- Fix. Compare in one space, e.g.
tr="$(readlink -f -- "${M_SRC[k]}")"andcase "$tr/" in "$r"/*). With that change, all four reproductions are refused andtest/sandbox-wrapper.test.jsstays green (50 passed). - Test. A recording-only fixture whose working directory goes through a symlink.
- How it fails. When the project is reached through a symbolic link,
Non-blocking
- An opted-in link is resolved without
check_chain(bind_user_claude_dir~486,bind_state_link_target). If~/.claude/skillsresolves through a link inside the writable project (skills→proj/lnk/skills), the session can re-point that link. The next launch then binds another directory namedskillsread-write. Reproduced: launch 1 re-pointedproj/lnkto another, unbound project's.claude, and launch 2 wrote that project's.claude/skills/SKILL.md. The docs promise only~/.claudeand bound directories. The read-only links already runcheck_chainon the link text (protect_resolved_link); the opted-in link could do the same. The precondition is uncommon, and the same gap exists for the other state links, where it is harmless. - A mutant survives.
-ge→-gtin the covering-mount tie-break (~710) leaves the suite green. I found no layout wherePROTECTED_SEENdoes not also catch it, so this is coverage only.
Tests: test/sandbox-wrapper.test.js at this head, Node with SWITCHBOARD_TEST_CONCURRENCY=1, GIT_/HUSKY unset: 50 passed, 1 skipped, 0 failed.
Mutations, one at a time; each turned at least one test red:
- dropping
check_opt_in_binds, either of its two rules, or the project registration; - not skipping the bind's own index;
t/prefix → exact match;- dropping either
protect_tree, or the writable-root registration; nocreate→create;- the covering rule applied to projects;
- dropping the pre-creation or the project state entry;
- ignoring
RW_AGENTS.
Not run: a session started from the Switchboard UI, Node 20/22 with c8, CI on this head.
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
55ad25a to
aef998e
Compare
|
Thanks to both of you. The fixes are in aef998e. The branch is still on eaf59ff: the token I push with lacks the Blocking
Non-blocking
Mutations, one at a time. Each turned at least one test red:
Tests: Not run:
|
devsuitup
left a comment
There was a problem hiding this comment.
Review at aef998e. This weakens the sandbox on request, so I read it as a security change. Thanks for the careful iterations: the previous blockers are closed in the code, and CI (run 37940930328, which tests the merge with current main) is green, including the Linux wrapper tests and the real-bwrap persistence test.
Previous findings
- d4188a8, blocking (symlinked target left unprotected): resolved,
protect_tree "$STATE_LINK_TARGET" nocreate/protect_tree "$e" nocreate(scripts/claude-sandbox.sh:504-516), testtest/sandbox-wrapper.test.js:515. - d4188a8, non-blocking: both resolved (tooltip
public/index.html:109; one-line pointers:107,:715). - 376ada7, blocking (shared or nested target): resolved,
check_opt_in_binds(:716-739) runs on the finished list afterpin_protected_paths(:817-818), fixturestest:587. - 55ad25a, blocking (one repository seen at two paths): resolved,
protect_resolvedmounts at every writable root containing the path and pins each view (:387-405), testtest:695. - 55ad25a, blocking (two spellings in the project-level check): resolved, sources compared after
readlink -m(:720-737), testtest:646. - 55ad25a, non-blocking (
check_chainon the opted-in link): resolved (:498-502), testtest:757. Test comment: resolved.-ge→-gtmutant: a fixture now targets it (test:775); not re-run here.
Also tried, with no way out found: a target equal to, containing or under the project; aliases through a symlinked Additional Directory or $HOME; a trailing slash in an Additional Directory (// spellings from find "$d/" stay consistent and fail safe); .claude / .git seen from two roots; dangling and file-typed skills entries; glob characters in paths (every case pattern is quoted).
Major
- After merge, the changelog entry lands in a released section. In GitHub's test merge (a098511) and a local merge with 75ab0ad, the
(#483)line ends up under## v0.0.90 — 2026-10-09; the changelog check passes anyway and What's new would credit the feature to v0.0.90. It belongs under## Unreleased → ### New. Since rebasing onto the newbuild.ymlneeds the workflow scope, we can rebase this branch ourselves (maintainer edits are allowed) or move the entry at merge — tell us which you prefer. - This PR also fixes a security bug on main, with no CI run showing it failing first (
scripts/claude-sandbox.sh:656,find "$d/"). On main,find "$d"does not descend into a starting point that is a symbolic link, so a repository or.claudebelow a symlinked Additional Directory gets no protection (GNU find:find link -mindepth 1prints nothing,find link/lists the contents). The fix and its test (test:734) arrive in one commit, and no earlier commit of the branch has a CI run. Please split it into its own small PR — test pushed alone so CI shows it red, then the one-character fix, with a Fixed changelog entry — or add a test-only commit before the fix here. - The opt-in has no real-sandbox test, although CI can run one (
test/sandbox-wrapper.test.js:1569). The new tests check the recorded bwrap argument vector through the test's ownaccessAtmodel (test:136); the bypasses of the last two rounds were only proven by writing from inside a live sandbox, and the real-bwrap persistence test runs on ubuntu-latest (650 ms). Please add an opted-in variant (SWITCHBOARD_SANDBOX_RW_SKILLS=1) asserting that a skill write reaches the host; that inside askillslinked to a repository.git/hooks/pre-commit,.git/configand.claude/settings.jsonare unchanged; and that writes tohooks,commandsandagents(flag off) are still refused.
Minor
:615-637(nocreate): a repository at the root of, or inside, an opted-in target can be given a new.claude. With~/.claude/skills -> ~/src/skills(a git repository without.claude) andRW_SKILLS=1, the session can create~/src/skills/.claude/settings.jsonwith aSessionStarthook that the next unsandboxedclaudestarted there runs. List this in the "Writable skills and agents" exception, or create the placeholder at repository roots inside the target.- The repair of main's symlinked-Additional-Directory protection is not in the changelog, although it changes behaviour for every sandboxed user (see Major 2).
Suggestions
check_opt_in_binds(:720-724) forks onereadlink -mper mount on each of up to four passes; onereadlink -m -z -- "${srcs[@]}"would do, asprotect_links_belowdoes withrealpath -z.docs/sandbox.md: say that asettings.jsonhook pointing at a script underskills/oragents/runs whatever the session wrote there.ro-bindentries not tracked inPROTECTED_SEEN(RO_DIRS, plain read-only~/.claudeentries) are protected only at their own destination; if one lies inside an opted-in target reached under another spelling it is writable there. A one-line refusal when aRO_DIRSentry or the real~/.claudelies under an opted-in target would close it.
Not run: a live bwrap launch or write-from-inside reproduction (Windows host); mount reasoning is from reading the script and the recorded argument vectors.
Changes requested: 0 blocking, 3 major, 2 minor, 3 suggestions. Two related issues that already exist on main are filed separately.
aef998e to
93672b5
Compare
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
|
Thanks for the rebase. One thing the rebase did not fix: the |
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
93672b5 to
85fe1a3
Compare
|
Thanks. Rebased on current main (a02f96c, v0.0.92). The changes of this round are in 85fe1a3; the branch now sits on #538. Major
Minor
Suggestions
Checks
Not run: a session started from the Switchboard UI. |
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 85fe1a3 — request changes (1 major, 2 suggestions). Every previous finding is closed.
Previous findings
| Finding | Status |
|---|---|
| Major 1, changelog under v0.0.90 | closed — (#483) under Unreleased / New |
Major 2, find "$d/" main fix without red CI |
closed — split into #538: run 38039845996 on 81cf86b fails both ubuntu test jobs on /link/sub/.git/config must stay read-only, run 38040407529 on 4fc768f green |
| Major 3, no real-sandbox test | closed — test/sandbox-wrapper.test.js:1696 runs (not skipped) in job 114179410612 of run 38040430867 |
Minor 1, repo inside an opted-in dir gets a .claude |
closed — repo mode at claude-sandbox.sh:624-627; vendored/.claude/settings.json read-only at 85fe1a3, writable at 93672b5 |
| Minor 2, changelog for the main fix | closed — #538 has its Fixed entry |
Suggestions (single readlink -m -z, hook-under-skills doc, refuse read-only binds under the target) |
done; the last one does not cover the case below |
Earlier blockers (d4188a8, 376ada7, 55ad25a) unchanged; hooks and skills linked to the same directory is still refused (exit 125).
Major — two protections without a source escape check_opt_in_binds (scripts/claude-sandbox.sh:719-749, with :491-492 and :827)
The check only compares the sources of --bind/--ro-bind mounts (:723-727). The private tmpfs entries (shell-snapshots, session-env, backups, state, :491-492) and the private copy of ~/.claude.json (--file 9, :827) have none, so they are never compared with the opted-in directory. If one of them is a symlink into the opted-in skills/agents, the launch proceeds and the host file is writable from inside; unsandboxed sessions later source snapshot/session-env scripts and load MCP commands from ~/.claude.json, i.e. code that runs outside the sandbox. Emulated with the PR's script under Git Bash and a recording fake bwrap (no real bwrap here): shell-snapshots -> skills/snaps with RW_SKILLS=1 exits 0 and skills/snaps/snap.sh is writable (read-only with the flag off); skills -> ../dot/skills, session-env -> ../dot/skills/se, ~/.claude.json -> dot/skills/claude.json exits 0 with both targets writable. docs/sandbox.md:213-217 says the launch is refused for "the target of another ~/.claude entry", which does not hold here. Uncommon layout and an explicit opt-in, hence major rather than blocking. Fix: resolve each private entry that is a link, and $CLAUDE_JSON when it is a link, and refuse when the result equals, lies under or contains the opted-in directory; add recorded fixtures and a line to the real-sandbox test.
Suggestions
- Merge #538 first: this branch carries 81cf86b and 4fc768f, and with squash merges #538 would otherwise land empty, detached from its red-then-green record.
- The real-sandbox test covers only the user-level
skillslink; a project.claude/skillsopt-in and the refusal paths are checked only through recorded argument vectors.
CI green on 85fe1a3 (runs 38040430867 / 38040430889), mergeable on a02f96c, comments within the rule. Not run here: real bwrap (Windows), any live write inside a sandbox.
Review of devsuitup#483: with SWITCHBOARD_SANDBOX_RW_SKILLS/AGENTS, a skills or agents directory linked to a repository elsewhere was bound writable without the protections other writable roots get, so the session could write that repository's hooks or a settings file linked into it. - An opted-in link target is a writable root, so files linked into it from ~/.claude are mounted read-only. - The tree under an opted-in entry, real or linked, gets the same .git and .claude protection as a bound directory, without creating .claude. - The Sandbox tooltip names the opt-in among what is not isolated. - The test rig drops inherited SWITCHBOARD_SANDBOX_* variables, which leaked into the wrapper when the suite ran from a sandboxed session.
85fe1a3 to
8aa60be
Compare
|
Addressed at 8aa60be, rebased on #538's new head 5680fee. This should merge after #538. Major (private entries without a source).
Suggestion 1. #538 first, as above. Suggestion 2. The real-sandbox opt-in test now also has a project
|
devsuitup
left a comment
There was a problem hiding this comment.
Re-review at 8aa60be — approve (1 minor, 1 nit).
| Previous point | Status |
|---|---|
Major: private entries and the ~/.claude.json copy not compared in check_opt_in_binds |
closed — scripts/claude-sandbox.sh:493, :841 record each such entry that is a link, :729-730 resolve it, :744-748 refuse both directions. Replayed under Git Bash with a recording fake bwrap: shell-snapshots -> skills/snaps with RW_SKILLS=1 exits 125 (0 with the flag off); session-env -> ../dot/skills/se and ~/.claude.json -> dot/skills/claude.json, alone and together, exit 125. Same cases against 85fe1a3 exit 0 |
docs/sandbox.md:213-217 overclaimed |
closed — docs/sandbox.md:228-235 |
| Merge #538 first | still applies (81cf86b, 4fc768f, 5680fee are in this branch with the same SHAs) |
| Real-sandbox coverage | partly — the real test now writes a project skill, checks project settings.json stays read-only and the shell-snapshots refusal; ran as ok 3125 in job 114260269785 |
Variants refused (125): relative links, chains of 2–3 links, dangling target, trailing slash, x/../x/./, agents with RW_AGENTS=1, ~/.claude.json as a relative link or a chain into agents, a private target containing skills, project .claude/skills as the target of shell-snapshots or ~/.claude.json, ~/.claude itself inside the project's opted-in .claude/skills (via the read-only ide entry). A sibling skills2 is correctly not refused.
Minor — scripts/claude-sandbox.sh:491-493, docs/sandbox.md:228-231: only private entries that are themselves links are checked. ~/.claude/skills -> session-env/skills with session-env a plain directory launches (0): bwrap gets --tmpfs ~/.claude/session-env then a read-write --bind ~/.claude/session-env/skills, so part of the host's session-env is writable. Low impact (the CLI most likely reads only session-env/<session-id>, not verified), but the doc's "a target that contains it is refused" does not hold. Resolve every private entry and $CLAUDE_JSON whether or not it is a link, or narrow the doc.
Nit — a link inside a plain private directory is not checked (shell-snapshots/snapshot-bash-1.sh -> skills/s.sh launches); needs a pre-planted link.
The fix and its tests are one commit (no red CI run); it fixes this PR's own unmerged code, so I am not holding it on that. CI green on 8aa60be (runs 38068273353 / 38068273355), mergeable on a02f96c, changelog under Unreleased, no comments added. Not run: real bwrap, a write from inside a real sandbox.
…, a linked dir, or a repository's .claude Tests for the last review round of devsuitup#483, which devsuitup#548 did not merge: a repository inside an opted-in directory keeps a read-only .claude, a read-only directory bound under another spelling is refused, a private ~/.claude entry or ~/.claude.json linked into an opted-in directory is refused, and the real-sandbox test covers a project .claude/skills opt-in.
…try, and protect a repository's .claude inside it A private ~/.claude entry (shell-snapshots, session-env, backups, state) or ~/.claude.json that links into an opted-in skills or agents would be writable from inside the sandbox; the launch is refused instead. A repository inside an opted-in directory gets a read-only .claude, and a read-only directory bound under another spelling is compared by its resolved path. The changelog entry moves to Unreleased.
8aa60be to
cdc61d0
Compare
|
Review of 060d0a0: approved.
Merging into v0.0.93 at 060d0a0. |
Superseded: its three items were verified closed at 060d0a0. The blocking one, compare resolved paths rather than the mount destination, was replayed at 55ad25a then at 060d0a0 through a symlinked cwd, a symlinked Additional Directory and a symlinked HOME: exit 0 then 125. It is covered by test/sandbox-wrapper.test.js:695, which ran in a required job of run 38143391048. The other two items, check_chain on an opted-in link and the tie-break mutant, are closed too: test:803 and test:821.
|
#548 merged 93672b5, an older head of this PR, not the approved 8aa60be. The branch is now main plus that missing last round only (private-entry refusal, read-only |
Now: the last review round only
#548 merged an older head of this PR (93672b5), not the approved 8aa60be. This branch is now main plus what 93672b5 lacked:
~/.claudeentry (shell-snapshots,session-env,backups,state) or~/.claude.jsonthat is a link into, or contains, an opted-in skills or agents directory refuses the launch (status 125,opt_in_refuse_private)..claude; a read-only directory bound under another spelling is compared by its resolved path..claude/skillsopt-in and the private-entry refusal.docs/sandbox.mdlists the refusal.Tests first: cdc61d0 is red on the three ubuntu test jobs, on exactly the four new or extended sandbox-wrapper tests (run 38142875733; the Windows job's one failure,
setScheduleEnabled: replaces the file rather than writing into it, is unrelated). The fix 060d0a0 is green on every job (run 38143391048).Based on #538, which fixes on its own the protection below a symlinked Additional Directory that this PR first carried; its two commits are at the bottom of this branch until #538 is merged.
What
Two opt-in flags for the Linux sandbox, set in the session's Pre-launch Command:
SWITCHBOARD_SANDBOX_RW_SKILLS=1—skillsbecomes read-write session state in~/.claudeand in every bound directory's.claude.SWITCHBOARD_SANDBOX_RW_AGENTS=1— the same foragents.Both are off by default; without them nothing changes.
Why
#372 made skills and agents read-only inside the sandbox, because an unsandboxed
claudestarted later loads them. That blocks workflows where a sandboxed session is meant to author or refine skills and agents. This keeps #372's default and lets a user give up that part of the protection knowingly, the same waySWITCHBOARD_SANDBOX_PODMAN=1does for the podman socket.Behaviour with a flag set
.claude/ the~/.claudetmpfs.~/.claude/skills/~/.claude/agentsis created before launch when missing, so a first skill is not dropped with the tmpfs. A missing one in a project's.claudecannot be created from inside (that.claudestays read-only) — documented.~/.claude/skills/agentsfollows the existing state-link rule (same-name directory target, never$HOMEor a parent).Docs
docs/sandbox.md: table row, new section "Writable skills and agents" stating what protection is given up, and an entry under "What is not isolated". Changelog entry under Unreleased.Tests
New test in
test/sandbox-wrapper.test.js: read-only by default, each flag covers only its own directory, both together, missing~/.claude/agentscreated. Red without the script change, green with it.🤖 Generated with Claude Code