A sandboxed task reads a linked sibling unseen (2026-09-24)
Status: implemented (2026-09-24). Step 1 (rule 1, the self-link) shipped as item 720, step 2 (rules 2–6) as item 726, with
CACHE_VERSIONv31. Open question 1 is decided: declaringcachemay narrow core’s own grant, never widen one (a bounded departure from the 2026-09-05 decision, which still holds for the user’s grants).Corrected by step 2’s key-level rows (R4): rule 5’s “where U declares
cache” rested on the claim below that an uncached task’s hash “folds no files”. It folds every file of its project: nocachemeans noinputs.files, and the default is**/*(cache/inputs.ts’sDEFAULT_FILE_GLOBS). An edge to an uncachedui#barewith no dependencies moved the dependent’s key on an edit toui/README.md, on both key paths. So K(T) counts every exec task the fold reaches, cached or not; R4 holds it to the key in both directions. A persistent task stays excluded: neither key path gives it a hash (the local classify pass hashed one until item 727, so its dependents had a second key).
What we’re solving
Section titled “What we’re solving”sandboxRequestFor grants every node_modules link from the project
and from the workspace root (linkedDeps). It reports denials only
inside the project (reportWithin). So a sandboxed, cached task can read
workspace package P’s files through its link with no edge to any of P’s
tasks. The read is allowed and nothing reports it. An edit in P does not
move the task’s key, and the next run replays the old result: the stale
hit the sandbox exists to rule out.
The probes found a second hole in the same line. npm and Yarn classic
link every workspace package at the root, the task’s own package
included. So linkedDeps grants the task’s own project directory in
full, whatever its allow.read says. The correctness page’s demo
(grant = inputs = src/**, then read banner.txt) gives a stale hit in
any npm or Yarn workspace.
Access pattern
Section titled “Access pattern”- Who hits it: a task with both
exec.sandboxandcache. An unsandboxed task has the same hole, but vx never claimed to prove its reads. An uncached task has no key that could be stale. - What it reads: a sibling’s
src/(a “just-in-time” package consumed as source, as every package in this repo is), a sibling’sdist/after^build, or its own files outside its grant. - How often the grant is computed: once per executed sandboxed task.
Cache hits never compute it (
armSandboxandsandboxRequestForrun only on the execution path). Measured today on this checkout:sandboxRequestForfor@vzn/vx-github, which yields 9 read entries, takes 0.57 ms min / 0.74 ms median of 30. - Who lays out the links: the installer (probe P6). npm 10 and Yarn
1.22 link every workspace package at the root. pnpm 12 and Bun 1.4.2
link only the dependant’s declared dependencies, under
<project>/node_modules. pnpm’snode_modules/.pnpm/node_moduleshoist is not scanned bylinkedDeps, which reads only depth 0 and@scope/directories. This checkout’s rootnode_modules/@vzndates from the 2026-09-08/10 installs and links seven packages, so the grants on this box differ from a fresh CI install.
Probes (fixture in the session scratchpad, bun packages/vx/src/bin.ts)
Section titled “Probes (fixture in the session scratchpad, bun packages/vx/src/bin.ts)”The fixture has packages/ui (@x/ui, src/index.js exporting
label), packages/app (depends on @x/ui, workspace:*) and
packages/secret. The root node_modules/@x/{ui,app} are symlinks,
matching npm’s layout. app#test is bun src/check.js, which imports
@x/ui, with sandbox: { allow: { read: ['.'] } } and inputs
src/**, package.json. It has no dependsOn.
P1: stale hit, nothing reported.
=== run 1 (ui v1) ui label: v1 success, 1 miss=== run 3 (ui edited to v2) ui label: v1 up-to-date--- the same command, unsandboxed: ui label: v2=== run 4 (ui v2 committed) ui label: v1 up-to-datevx why: key 211c55221d6e23e4 both runs, "cache key unchanged"No SANDBOX VIOLATIONS block on any run. Committing the edit changes
nothing either: the git blob OID of ui/src/index.js is in no key.
P2: this box does enforce read denies (a belief refuted). The brief
said a cross-project cat once succeeded on this box. It did, and the
link grant is why. SRT_DEBUG=1 shows the tmpfs over the workspace root
and then Re-allowed read access within denied region: …/packages/ui
and …/packages/app, both added by linkedDeps. Controls from the same
task (read: ['.']):
cat ../ui/README.md (linked) success: "ui readme v1"cat ../secret/token.txt (unlinked) failed: No such file or directoryecho hi > undeclared.txt failed: Read-only file systemThe unlinked sibling’s denial is enforced but not reported, because it
lies outside reportWithin, as designed.
P3: the self-link widens the task’s own grant. A task with
read: ['src/**'], inputs src/**, runs cat banner.txt:
root link node_modules/@x/app present: "banner v1" success; edit banner → up-to-date, prints v1root link removed, --force: failed, 1 sandbox violation openat(banner.txt) = -1 ENOENT [<ws>/packages/app/banner.txt]P4: an edge to a keyed task in P is the fix, and it works today. The
fixture adds ui#source (true, inputs src/**) and
app#test.dependsOn: ['^source']: ui v2 → success; edit ui to v3 →
miss, prints v3; restore v2 → up-to-date.
P5: the edge is package-level, not file-level (the residual).
app#readme has dependsOn: ['^source'] and runs
cat ../../node_modules/@x/ui/README.md. Editing ui/README.md →
up-to-date, prints the old text. ui#source’s inputs (src/**) do not
cover the file read.
P6: installer layouts. The fixture is the same three packages with no lockfile.
bun 1.4.2 packages/app/node_modules/@x/ui -> ../../../uinpm 10 node_modules/@x/{ui,app,secret} -> ../../packages/*yarn 1.22 node_modules/@x/{ui,app,secret} -> ../../packages/*pnpm 12.6 node_modules/.pnpm/node_modules/@x/{ui,app,secret}, packages/app/node_modules/@x/uiP7: option (b) on the runtime, driven through runSandboxed. This
probe drove core’s own runSandboxed with baseAllowRead narrowed by
hand:
bun src/check.js | today (ui + self granted) exit 0 "ui label: v2" violations []bun src/check.js | ui withheld, report within app exit 1 ENOENT reading ".../node_modules/@x/ui" violations []bun src/check.js | ui withheld, report within ws exit 1 violations ["openat(<ws>/node_modules/@x/ui) = -1 ENOENT [<ws>/packages/ui]"]Withholding the grant makes the task fail on both platforms by
enforcement. The report names the sibling only if reportWithin covers
it. The violation carries the real path ([<ws>/packages/ui]), so a
filter on the withheld directories needs no new path logic.
P8: this repo under the self-link rule. The probe ran through
runSandboxed, with the grant built by today’s sandboxRequestFor and
then filtered:
@vzn/vx#check.bun today: exit 0, no violations self-link withheld: exit 0, 1 violation openat(<repo>/packages/vx/bunfig.toml) = -1 ENOENT@vzn/vx#source both arms: exit 0, no violationsBun probes bunfig.toml. check.bun grants only
scripts/bun-floor.ts and src/util/**, and today it passes only
because the root link grants all of packages/vx.
Does anything in the key already cover the linked package?
Section titled “Does anything in the key already cover the linked package?”No. Each part was checked against the source.
- The project’s
package.jsonbytes (hashProjectPackageJson). These are app’s bytes, and"@x/ui": "workspace:*"does not change when ui’s source does. P1’s key did not move. - The workspace fingerprint (
workspace/fingerprint.ts) hashes the lockfiles andpnpm-workspace.yamlat the root. A source edit changes neither. - The lockfile claim (
orchestrator/lockfile-claim.ts,@vzn/vx-lockfile).digest(decode(bytes))runs over the lockfile text only. Inbun.tsaworkspace:dependency is a node in the lockfile’s graph, so app’s digest folds ui’s third-party reach (what ui’s manifest resolves to), never ui’s files. The claim keys the installed tree, not workspace package contents. - The cascade (
upstream.ts,filterUpstreamHashes) is the only path from P’s files into T’s key, and it has two gates the “anydependsOnedge” reading misses:cache.inputs.taskscan drop an upstream from the fold at any hop.- An upstream with no
cachestill gets a hash (execute-task.tscomputes it for every exec task), but that hash folds no files. So an edge to an uncachedP#buildcarries none of P’s content.
Options considered
Section titled “Options considered”(a) Report a read under an unkeyed linked package, keep the grant. Rejected because correctness would ride on the report channel, which is the one thing this design cannot lean on.
- An allowed read raises no denial. Seatbelt logs denials, and its
store is lossy under load (Decision, item 586). On Linux the strace
log does record successful
openats (the probe’s trace holdspackages/ui/src/index.jsreturning fd 7), but strace is optional.runSandboxedskips it when absent and keeps enforcing. - So (a) catches the hole on Linux with strace, and on no other configuration.
- Cost: none for setup. The report walk would parse success lines too,
which the log already holds (
--seccomp-bpfstops on everyopenat), so it adds one regex per line.
(b) Deny: grant a linked package only when the task’s key covers it. Recommended.
- Correctness: enforced by bwrap and seatbelt, independent of any report. A denied read cannot shape the output, so even a task that copes with the ENOENT caches a result that is right for its key.
- Cost: one memoized structural walk per run, and fewer binds than today.
- What breaks: a sandboxed, cached task that reads a sibling it has no
keyed edge to. That is exactly the stale-hit shape, so it should
break. Also a tool that walks the root
node_modulesinto an unrelated package; its remedy isignore.read.
(c) Fold the linked packages’ files into the key automatically. Rejected: this is the inference CLAUDE.md rejects.
- It would be correct at package level.
- But it keys each task on every linked package’s whole tree. Under npm or Yarn that is every package in the workspace (P6), so a README edit anywhere re-runs every sandboxed task.
- Cost: hashing every linked tree per task. Git OIDs make that cheap when the tree is clean, but not free.
(d) Document only. Rejected. It leaves the correctness page wrong: its demo is a stale hit under npm and Yarn (P3), and the sandbox would keep advertising a proof it does not give.
The Turbo shape, and whether “any dependsOn edge” is enough
Section titled “The Turbo shape, and whether “any dependsOn edge” is enough”- A Turbo repo with
dependsOn: ['^build'], reading siblings’dist/. Covered.P#buildis cached;turbo()gives every mapped taskcachewith**/*default inputs (turbo-map.ts). Its key determinesdist/, and T folds that key. Readingdist/after the edge is the input-key cascade working, which is legitimate. - Turbo’s own “just-in-time” packages (no
build).^buildmatches nothing in P, so T has no key into P and (b) refuses the read. Turbo has the same hole and documents the “transit node” pattern for it, which is what this repo’ssourcetasks are (item 687). - Adoption is unaffected.
turbo(),nx()andvx initemit noexec.sandbox(grep: nosandboxinvx-migrate/srcorworkspace/migration.ts), so only a task someone deliberately sandboxed changes behaviour.
“Any transitive dependsOn edge into P” is not the rule, for three
reasons:
- The edge must be folded.
cache.inputs.taskscan exclude it at T or at any hop. - It must reach a task in P that declares
cache. Otherwise P’s files are in no key (see the cascade gates above). - Even then it is package-level. It proves that some of P’s
declared inputs are in T’s key, not that the file T read is. P5 is
the counterexample: an edge to
P#source(src/**) and a read ofP/README.md.
That residual is the same class the correctness page already names for
a project’s own files (“the check is only as narrow as the grant”). It
is now stated for P too. Reading P’s outputs is covered, because
P#build’s key determines them.
Recommendation
Section titled “Recommendation”(b). Core’s implicit dependency grant is bounded by what the task’s key
answers for. The user’s own grants are untouched and still derive
nothing from cache. Relative to today, the rule only ever removes
grants.
The exact rule
Section titled “The exact rule”For a sandboxed task T in project A, with L = the canonical link
targets linkedDeps finds (unchanged):
-
Self and ancestors are never granted implicitly. A target equal to
realpath(A), or containing it, is dropped fromL. The task’s own project is governed by itsallow.readalone. This applies whether or not T declarescache. -
Targets outside the workspace root are granted as today. They lie outside the deny anchor, so the grant is a no-op either way.
-
Tasks with no
cache(persistent dev servers,check.bun) are granted the rest ofL. They have no key to be stale, and withholding would break every dev server that serves a sibling’s source. -
Tasks that declare
cacheare granted target D ∈Lonly if D is the canonical directory of a project in K(T). Every other D inside the workspace root is withheld. -
K(T), the keyed projects, is the set of projects of every task U reachable from T through the fold relation, where U declares
cache. T itself is excluded. The fold relation:- an exec task X (cached or not) folds its
dependsOntargets as selected by X’s owncache.inputs.tasks(absent means all); - a group folds every
dependsOntarget.
It is the relation
filterUpstreamHashesandcomputeGroupHashapply at hash time, computed on the graph instead of on outcomes. - an exec task X (cached or not) folds its
-
Withheld targets are reported. A denial whose real path lies under a withheld D is reported alongside those inside A, and the usual fail-on-violation policy applies. The orchestrator appends one line per withheld package hit, in the shape of
untouchedPlaceholderLine, and only when such a violation exists, so it never reddens a pass:vx:
@x/app#testreadpackages/uithroughnode_modules/@x/ui, and its key folds no cached task of@x/ui, so an edit there would not re-run it. Add adependsOnedge that reaches one (^buildwhere@x/ui#buildkeys its sources, or asourcetask), or grant and key the files yourself (allow.readpluscache.inputs.workspaceFiles).
workspaceFiles does not unlock a package grant. It is a cache
declaration, and letting it widen the sandbox is the coupling the owner
removed on 2026-09-05. The explicit route (allow.read plus
workspaceFiles, as vx-docs already does) keeps working, because
user grants are untouched.
Where it lives
Section titled “Where it lives”orchestrator/upstream.ts. Split the selection out offilterUpstreamHashesinto a node-levelselectFoldedDeps(deps: TaskNode[], filter, selfProject, selfId). The hash path maps the selection to hashes and K(T) walks it. That leaves one copy of the matcher; the CLAUDE.md rule is to suspect a second copy first.orchestrator/keyed-projects.ts(new). K(T) as a lazily memoized walk over the run’sTaskGraph, keyed by task id. Only tasks that execute ask for it, so a warm all-hit run pays nothing.orchestrator/sandbox-request.ts.sandboxRequestForgainskeyed: ReadonlySet<string> | undefined, withundefinedmeaning an uncached task.linkedDepsreturns targets split into granted / withheld, by canonical directory equality with the projects’ canonical directories. Both call sites pass it: the cached path through the executor, and the persistent path, which always passesundefined.exec/executor.ts,ExecuteSandbox. Addreadonly reportLinked: readonly string[](additive:reportWithinkeeps its type, and@vzn/vx-reapireads neither field).exec/sandbox-violations.ts’swithinReportedkeeps a violation underwithinor under anyreportLinkedentry.execute-task.tsappends the hint line. It is stale-hit-critical only in the safe direction: it touches the failure path and never a save.
Performance
Section titled “Performance”- Warm path: unchanged. A hit computes no sandbox request.
- Per executed sandboxed task:
linkedDepsdoes the same readdir and realpath calls it does today (0.57 ms min above). It adds a set lookup per link and binds fewer paths. - K(T): one walk of the reachable subgraph per run, memoized, over
sets of project directories. This is an estimate, not a measurement:
well under a millisecond on this repo’s graph. The item measures it on
vx-bench’s 1,000-package synthetic workspace, plus an A/B of the gate’s wall time (min-of-N, before-arm from a worktree). - The report walk: the strace parse is unchanged.
withinReportedcompares against 1 + |withheld| prefixes instead of 1.
macOS seatbelt and weakerWhenNested
Section titled “macOS seatbelt and weakerWhenNested”- Seatbelt. Enforcement is the profile’s own allow list, the same
mechanism that grants links today, and seatbelt matches the resolved
path, so a read through the link is judged at
packages/ui. Correctness therefore does not depend on the report. What the lossy log (item 586) can drop is the violation line and so the hint: the task still fails on its own ENOENT, and is never cached. A task that swallows the ENOENT and succeeds produced an output that cannot depend on P, which is correct for its key. - CI coverage on darwin. CI runs the sandbox e2e suite on Linux only
(
tests/helpers/sandbox-gate.ts). The seatbelt line shape is held instead by a synthetic row throughreportableViolations(R9). weakerWhenNested. On Linux it changes only how/procis mounted (SRT 0.0.76linux-sandbox-utils.js:--bind /procinstead of--proc). The filesystem deny, and so this rule, is identical in both modes. A nested vx inside a sandboxed task sees a withheld sibling as a dangling link;linkedDeps’srealpath(...).catchdrops it, so the inner run grants nothing extra. macOS cannot nest, as today.
What this repo’s configs need
Section titled “What this repo’s configs need”Item 687 already made every consumer of a sibling’s source key it:
install → ^build → <dep>#build → <dep>#source, cached on
src/**. So under rule 4:
- Covered: every plugin’s
testandlint.oxlint(@vzn/vxviasource), andvx-docsandvx-benchfor the plugins they import. All of themdependsOn: ['install']. - Explicit reads, unaffected:
vx-docs’s cross-package reads (SIM_READ,../vx/src/**,CHOOSING_PROOFS), which areallow.readplusworkspaceFiles. - Needs a change under rule 1:
@vzn/vx#check.bun(P8). Addbunfig.tomlto itsignore.read, or grant it. Any other narrow-grant task the gate turns red gets the same treatment. The item runs the gate on a checkout with a freshbun installand on this box’s stale root links, because the two lay links out differently (P6). - Unaffected:
sourcetasks (true,read: []), in both arms of P8. tests/core-source-key.unsafe.test.tsstays. It holds the edges for unsandboxed readers too, which no sandbox rule sees.
Rows (tests)
Section titled “Rows (tests)”Each row lists the mutation that must turn it red. A row without one is a control.
-
R1 (
sandbox-request.test.ts). Links to a keyed sibling, an unkeyed sibling, the task’s own project, an ancestor and a target outside the root. Asserts the exactbaseAllowReadandreportLinkedsets for a cached task. Red with self-exclusion removed (own dir appears); red with the keyed filter ignored (unkeyed sibling appears). -
R2 (same file). R1 with the root reached through a symlink (
/tmp/link -> /tmp/real), the macOS/varshape reproduced on Linux. Same exact sets. Red if either side of the directory comparison is left uncanonical. -
R3 (
keyed-projects.test.ts, new):- group chain to a cached
sourcegives{ui}; - only an uncached
P#buildreached gives{}; - T’s
inputs.tasks: ['!^build']gives{}; - a mid-chain hop with
inputs.tasks: []stops the walk there; - a transitive route through a third project.
One mutation per guard: count uncached tasks as covering, skip T’s filter, skip the hop filter, stop at groups.
- group chain to a cached
-
R4 (same file, against the source of truth). For the fixture graphs, computes real keys with
run():- editing an input file of a cached task in each P ∈ K(T) moves T’s hash;
- editing any file of a linked P ∉ K(T) does not.
This holds K(T) to the key itself rather than to its own implementation, and it goes red if
selectFoldedDepsand the hash path diverge. -
R5 (
sandbox-runtime.unsafe.test.ts). P1 as a row, in two link shapes: root (npm/Yarn) and<project>/node_modules(pnpm/Bun).app#testfails, with exactly one violation underpackages/uiand the hint line naming@x/ui.- Today it succeeds, and after an edit to ui it is a hit (red).
-
R6 (same file, control). P4: with the
^sourceedge the task passes, an edit to ui is a miss, and restoring the file restores the hit. Passes both before and after. -
R7 (same file). P3, the correctness demo:
read= inputs =src/**, with the root self-link, andcat banner.txt. Fails with the violation onbanner.txt. Today it succeeds and gives a stale hit (red). -
R8 (same file, control against over-reach). The same unkeyed read from a task with no
cachesucceeds. Red if rule 4 is applied to uncached tasks. -
R9 (
sandbox-violationsunit, both line shapes). A seatbeltdeny(1) file-read-data <ws>/packages/ui/src/index.jsrecord and a strace line are both kept when underreportLinkedand dropped when under an unrelated sibling. Red withwithinReportedignoring the list.
What’s out of scope
Section titled “What’s out of scope”-
File-level coverage (the P5 residual). There are two ways to close it, and both are rejected here:
- Derive per-prefix grants from the keyed tasks’ input globs. That
derives sandbox grants from
cachedeclarations file by file, the 2026-09-05 coupling, and refuses resolver probes (tsconfig.json,bunfig.toml) that users would then have to key. - A Linux-only allowed-read check over the strace log. That splits the platforms, and macOS has no equivalent channel.
Documented as a limit next to the existing “grant wider than inputs” bullet.
- Derive per-prefix grants from the keyed tasks’ input globs. That
derives sandbox grants from
-
Unsandboxed tasks. The same read is a stale hit there, and it always was. The contract is declared inputs; the sandbox is the opt-in proof.
-
Project constraints. “May app depend on ui” is a lint policy (capability audit § Project constraints), not a key question.
-
Third-party
node_modulescontents. These are keyed by the lockfile (the fingerprint or the claim), unchanged. -
Executors that ship the sandbox request elsewhere. They receive the narrower list and the new field; whether they enforce it is theirs.
-
A shorthand for
sourcetasks. This repo carries eight identical lines per package. Not proposed until the hint line shows real users hitting it.
Open questions
Section titled “Open questions”- Owner sign-off on the 2026-09-05 boundary. The user’s grants
still derive nothing from
cache. But core’s implicit dependency grant becomes bounded by the key, and declaringcacheon a task narrows that grant (rule 3 against rule 4). The recommendation holds that “cachemay only narrow core’s own grant, never widen anyone’s” is compatible with the decision. The owner should confirm, and the Decisions list should record it either way. - Should rule 4 cover uncached tasks through
dependsOnreach? Recommended no (R8): there is no key to be wrong, and dev servers would break. Revisit only if an uncached-but-sandboxed use shows a need.
Why this is the right move
Section titled “Why this is the right move”- Correctness comes from enforcement, not a report. It holds with seatbelt’s lossy log and without strace, where (a) would not.
- It never grants more than today. Every change is a removal, and the one it adds to the report is the removal’s own explanation.
- This repo already pays the price: item 687’s
sourcetasks are the edges the rule asks for. The one fallout,check.bun, is a narrow grant that was silently widened. - Zero warm-path cost. It touches only tasks that execute, and they bind fewer paths.
- It fixes a false claim on the site. The correctness demo is a stale hit under npm and Yarn today.
Item split
Section titled “Item split”- Withhold the self-link (rule 1). Size S, about half a day.
- Code:
linkedDepsdrops the project’s own canonical dir and any ancestor of it. - Rows: R1’s self parts, R2, R7.
- Repo: whatever the gate turns red, starting with
check.bun. - Docs:
guides/sandboxing.md(“The one grant vx makes for you”),learn/correctness.mdx(thenode_modulesbullet and the demo’s claim),schema.mdabout line 978,cli.mdabout line 988, andflows.mdabout line 218. - Can ship alone. It is independently a stale-hit fix.
- Code:
- Bound the linked grant by the key (rules 2–6). Size M, 1 to 1.5
days.
- Code:
selectFoldedDepssplit,keyed-projects.ts,sandboxRequestForsignature and both call sites,ExecuteSandbox.reportLinked(façade type; module page),withinReportedover the list, and the hint line. - Rows: R1 (keyed parts), R3, R4, R5, R6, R8, R9.
- Perf: the K(T) cost on
vx-bench’s 1,000-package workspace, and an A/B of the gate’s wall time. - Docs: the same pages, plus
caching.md§ Cross-project boundaries (the sandbox now enforces “only throughdependsOn” for sandboxed cached tasks) and the P5 residual as a stated limit. - STATUS: Next 17 → DONE, and a Decisions entry for open question 1.
- Code: