fix(sandbox): close the PAT exposures md-notebook's carve-out opens - #8778
fix(sandbox): close the PAT exposures md-notebook's carve-out opens#8778bolichen97 wants to merge 1 commit into
Conversation
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS Both exposure paths are closed at the enforcing layer (mount targets, mask-covered staging), fail-closed only where launch would expose, root-cause fix tracked in #8797. [DESIGN-REVIEWED] dfe4e76 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @bolichen97 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of All premises verified against the repo: First-Principles-Verdict: PASS Verify on a real What this change shipsInventory (10 items) — 10 justifiedIntent: close the two ways the md-notebook carve-out (landed on main) exposes the GitHub PAT to same-uid sandboxed agents — a FIX, with each rider closing a named adjacent exposure of the same carve-out.
More than 10 quiet differences exist; the remainder are plumbing for the above ( [FIRST-PRINCIPLES-REVIEWED] dfe4e76 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsBoth candidates are self-rated low-confidence, and each dies under falsification: Candidate 1 (sweep deletes any Candidate 2 (blocking syscalls on the spawn path): The sibling No new Step-2 finding grounds to the bar: no import cycle ( No findings. [OPUS-REVIEWED] dfe4e76 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
f77d3d0 to
210908b
Compare
210908b to
aa79d95
Compare
|
Disposition: FIXED — the BLOCKING finding on f77d3d0 (absent state files bypass the Linux masks) was verified real and is addressed at head aa79d95. (span=0a32be773082)
|
aa79d95 to
edd0b02
Compare
|
Disposition: FIXED — F1 on aa79d95 (leaf-only masks expose atomic-write temporary files) was verified real and is addressed at head edd0b02, in the direction the finding suggested (a dedicated masked staging location for the shipped backend). (span=fa7ab943e399)
|
|
Disposition: ACCEPTED-AND-APPLIED — the CONCERNS subtraction (drop the deprecated
|
edd0b02 to
c3f37c9
Compare
|
Disposition: FIXED — F1 on edd0b02 (state writes follow pre-planted parent links) was verified real — a guard regression my round-2 fix introduced — and is addressed at head c3f37c9 in exactly the suggested direction. (span=ef1d1a0f6644)
|
|
Disposition: FIXED (the Watch item) / DECLARED — item 8 (undeclared shed of the #4381 planted-link guard) was real and is restored at head c3f37c9; the PR body and commit message now declare both the original shed and the restoration.
|
c3f37c9 to
e9c7126
Compare
|
Disposition: FIXED — F1 on c3f37c9 (settings write lost its durability guard) verified real and fixed at head e9c7126, exactly as suggested. (span=ef1d1a0f6644)
|
e9c7126 to
6fe6ab8
Compare
|
Disposition: FIXED — F1 on e9c7126 (PAT clearing can reopen the mask race) verified real and fixed at head 6fe6ab8, exactly as suggested. (span=fa7ab943e399)
|
6fe6ab8 to
5c90d25
Compare
|
Disposition: FIXED — F1 on 6fe6ab8 (Python startup hooks inherit the credential carve-out) verified real and fixed at head 5c90d25 in the suggested direction (isolated startup for the module backend). (span=0a32be773082)
|
5c90d25 to
4af9df5
Compare
75db271 to
d79df2c
Compare
Correct, and the adjudication's framing is the part I have no answer to: the sibling materialiser's comment — which I wrote — states that the intermediate components are agent-writable and that a resolving link planted at one would land its writes under an attacker-chosen tree, which is exactly why it routes every chain through Fixed as suggested, with one addition. The chain goes through A root failing either guard is SKIPPED, not escalated to a spawn refusal, and that choice is deliberate rather than lenient: skipping removes the deletion hazard completely, while refusing would fail EVERY agent spawn on a host that merely symlinks its legacy Two tests pin it: a planted link at Also fixed here, found by running the full suite rather than by review: adding the staging leaf to |
I accept the characterisation, including that the excluded credential is the most sensitive one, and I am not softening it — the spec says the same thing in the PR's own words. What I decline is closing the fork inside this PR, for the mechanical reason already recorded: The "before it calcifies" half is the actionable part and I have acted on it rather than agreeing in a thread: the calcification trigger is now recorded on #8797 itself, so the cost this PR documents informs that issue's priority instead of living only here. The one guard whose loss would actually matter is not left to vigilance either — |
Agreed without qualification, and your framing is sharper than mine was: I had described the gate as making the omission loud, which is true but understates it — the gate makes the generic table stop scaling generically, and that is a real design cost rather than a neutral safeguard. No code change, because the gate is the correct stopgap and the alternative (a generic materialiser) would have to invent a per-leaf absent-equivalence argument for a leaf it has never seen. What I have added is the missing link: #8797 now carries "a second app joining |
Done, as a comment on #8797. It names both costs this PR accepted contingent on that issue staying live — the forked secret-write chokepoint and the app-literal machinery behind a generic table — with the specific mechanics of each, and states the trigger: a second app joining Worth saying why this was the right ask rather than a nicety: every other record in this thread argues why a cost is acceptable, and all of them are conditional on #8797 being decided. That condition existed only in this PR's review history, where the person prioritising #8797 would never see it. It is now on the issue. |
d79df2c to
caacc7d
Compare
Reproduced against this head: a data home whose One thing I want to correct rather than let stand, because "the sweep resolved the identical condition the other way" reads as inconsistency and the asymmetry is actually principled. The two failure modes differ in kind: if the SWEEP skips, a legacy orphan stays readable — but it was already readable before this PR, so skipping preserves the status quo and creates nothing. If the MATERIALISER proceeded, the mask would be vacuous for a leaf the carve-out has just made writable, so this PR would be creating the exposure it exists to close. Fail-closed is right there in a way it is not for the sweep. What that argument does NOT justify is the blast radius, which is your actual point and which I accept. Two candidate remedies, neither of which I am taking without your ruling: Narrow the trigger. Refuse only when there is something to protect — md-notebook state present, or the app installed — and otherwise skip materialisation. That answers "state they may never use" exactly, is a small change, and keeps fail-closed for every host that has a PAT to lose. It does not help a host that DOES use Notes under a symlinked Your degraded mode. Deny the backend its carve-out instead of refusing the spawn: the backend then cannot write, so no new PAT is created, and only the Notes app breaks. This confines the radius as you say, but it needs cross-module coordination (the materialiser runs in My own lean is the narrowing, because it is the smallest change that removes the case you named without weakening any host that has a credential at stake. I am not shipping it on my own judgement: you flagged this as needing explicit sign-off and I agree that is the right call for a trade of this shape. |
Agreed, and I am not going to argue the framing down — "institutionalizes" is fairer than my earlier description of the gate as merely making an omission loud. The gate stops a silent regression; it does not stop the pattern from spreading, and those are different properties. No code change here, because the alternative (a generic per-app materialiser) would have to invent a per-leaf absent-equivalence argument for a leaf it has never seen, which is the judgement that must not be automated. What exists instead is the accretion trigger, recorded on #8797 last round at your lane's own request: it names both costs this PR accepted contingent on that issue staying live, and states that a SECOND app joining |
Accepted, and done to the extent I can do it: I cannot set another issue's priority, but I can make sure the cost driving that priority is visible where the decision happens. #8797 now carries a comment naming what this PR spends to work around its absence — the forked secret-write chokepoint with the guard that will not reach it, and the app-literal materialise/sweep/startup apparatus — plus the trigger condition. Worth stating why that mattered more than agreeing in this thread: every "acceptable" argument in this PR's review history is conditional on #8797 remaining a live option, and that condition existed only inside this review. Whoever prioritises #8797 would never have seen it. |
|
@bolichen97 Audit note on this PR, not a request to close it. Already on Still missing on Three things block it, none a duplicate:
Please narrow the PR to that remaining scope and rebase. Also coordinate Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
caacc7d to
b974c1e
Compare
Taken as you proposed, and the maintainer signed off on the trade you escalated. I had first argued for a narrower change — refuse only on hosts that actually have state or the app installed — and dropped it after checking the premise: Implemented as one predicate with two responses rather than a special case. The safety argument is the coupling, so I want it stated rather than implied: skipping leaves a leaf unmasked, which is harmless ONLY while nothing can write it, and that is guaranteed by the carve-out being withheld under the same predicate. Separating the two would reopen exactly hole A. One correction to my earlier reasoning, since I used it to argue against your suggestion: I claimed degraded mode leaves a PAT written BEFORE the link was planted unmaskable. That was not load-bearing — with the chain unsafe the mask binds exactly as it does on main today, so this PR neither improves nor worsens that case, and the withheld carve-out means no NEW state can be written while the condition holds. The objection did not survive checking. What this costs, plainly: on a host that symlinks |
PR #8794 gave an app backend its own hidden state leaves back (_APP_BACKEND_OWNED_LEAVES + app_backend_visible_targets), which is what makes the Notes app work under the OS sandbox. That carve-out also made workspace/md-notebook/{pat,vaults.json,settings.json} writable on a sandboxed host for the first time, and two credential exposures became reachable through it. Both are still live on main. Absent leaves get no mask. mount(2) cannot target a path that does not exist and the launcher's hiding loops guard on isdir/isfile, so a leaf that has never been written is silently skipped: on a default install an agent namespace spawned before the first vault attach has no mask at the `pat` name, and the PAT the backend saves later is readable inside that still-running namespace. The comment on _CREW_PRECREATE_READONLY_DIR_LEAVES already names this as a real sibling gap and says each leaf needs its own absent-equivalence argument; _MD_NOTEBOOK_PRECREATE_CONTENT now carries that argument per leaf (pat -> "", vaults.json -> [], settings.json -> {}, each the value its reader already returns for absent), and _materialize_md_notebook_mask_targets() publishes them before launch. Staged temps sat outside the mask. The state writers published through atomic_write, whose mkstemp(dir=path.parent) puts the temp BESIDE the target -- a file holding the real PAT bytes at a name none of the three leaf masks covers. A SIGKILL between write and rename left that sibling readable by a same-uid sandboxed agent forever. All three writers now stage in a TOP-LEVEL md-notebook-staging directory, masked as a whole directory so every temp name -- present, future and orphaned -- is covered, and carved back for the backend's own spawn alongside the leaves. Top-level for the reason the sibling aws-control-staging leaf already records: a mask covers the leaf, not its ancestors, so a staging dir under the agent-writable workspace/md-notebook could be renamed out from under its own mask, and a later PAT write would publish through the replacement, unmasked, into a live agent's view. Being a direct child of the data home also means the shared _materialize_maskable_dirs creates it under its own documented rule rather than a bespoke branch. Riders on those two, each closing a hole the fix itself would open: - The materialiser runs the #4381 planted-link refusal on every chain it walks before any mkdir, because these leaves are NOT direct children of the data home the way _materialize_maskable_dirs' plain mkdir requires; their intermediate components are agent-writable, so a resolving link planted at one would land the files under an attacker-chosen tree while the launcher masks the lexical path. atomic_write.refuse_linked_parent is the existing guard made public for out-of-band stagers, not a second implementation. - A leaf is accepted only as a REGULAR file, judged by lstat, both when it already exists and when it wins the publish race. One check covers two hazards: a link (a mount resolves its target, so a resolving link would mask the referent while the lexical name stayed an agent-replaceable link -- the opposite requirement from atomic_write, which allows a leaf link because os.replace does not follow the final component), and a FIFO/socket/device node, which matches neither isdir nor isfile and whose mask the launcher would therefore skip in silence. A dangling link reaches the same refusal by the other route: exists() is False, so it gets to the publish, where os.link fails EEXIST on the link's own name. - Pre-upgrade orphans are swept, because materialising forward cannot help an artefact already on disk. An older writer staged beside the target, so a crash in that window can leave a PAT-bearing *.tmp at a name no mask covers, and exposure B would otherwise be closed only for new installs while an upgraded host kept the bytes readable forever. Every *.tmp DIRECT child of the state dir is such an orphan by construction (the writers now stage in the top-level staging dir; note temps live beside their note under vaults/<id>/), so the sweep removes them -- regular files only, judged by lstat -- refuses the spawn if one cannot be removed, and logs each removal as a token-exposure event. It runs on BOTH launch paths and across BOTH crew-home spellings, unlike materialisation, which is Linux- and live-home-only. Both asymmetries have the same cause: a Seatbelt deny is a path rule that holds for a name that does not exist yet, and a stub in a home nothing reads would be a file nobody opens -- neither reaches an orphan ALREADY on disk, whether that is on macOS or under an un-migrated ~/.kirocrew. Because the sweep DELETES, it carries both of the materialiser's link guards for a stronger reason: the chain goes through refuse_linked_parent, and the directory is opened O_NOFOLLOW | O_DIRECTORY with every lstat and unlink issued against that pinned descriptor, so neither an agent-planted link at an agent-writable intermediate component nor a swap mid-sweep can redirect a delete. A root failing either guard is SKIPPED rather than escalated to a spawn refusal: skipping removes the hazard outright, while refusing would fail every spawn on a host that merely symlinks its legacy home at the new one, and an unswept orphan under such a root is that root's pre-existing state. The sweep spares _publish_empty_ceiling's in-flight temp, which is staged in this same directory under a known prefix, so two concurrent spawns cannot unlink each other's mid-publish file. - A drift gate pins the fact that this hardening is md-notebook-literal while _APP_BACKEND_OWNED_LEAVES is generic. An app joining that table would get its leaves unmasked for its own spawn and silently reopen both holes, neither of which shows up at runtime, so a test fails until the new app either ships its own materialisation and startup decision or is added to the coverage table. - Clearing the PAT writes an empty file instead of unlinking it. Removing the inode deletes the mask's mount target, so a clear landing between the materialise and mount steps would leave that namespace maskless. - The md-notebook spawn starts with -I and runs its module through runpy with an explicit import root, so sitecustomize/usercustomize and user-site .pth files from an agent-writable directory do not execute in the one namespace where the PAT is unmasked. Scoped to that spawn; the other module builtins keep the bare python -m launch. Not included, and why: the foreign-mask shadow refusal is PR #8965's carveout_shadowed_by_foreign_mask and is reused, not reimplemented; the carve-out mechanism itself is #8794's and is reused as-is. Retiring the leaf-mask apparatus for a single masked .state/ directory is #8797, which would subsume the staging and leaf-link work here and is deliberately not attempted in this PR. Statically checkable: a mask/deny entry naming a path that some materialiser list does not cover, where the launcher's application of that entry is guarded on an existence predicate (isdir/isfile/exists) -- the guard plus the absent target is the whole bug class, and both halves are visible without running anything. Also checkable: a secret writer that opens its temp with mkstemp(dir=...) or NamedTemporaryFile(dir=...) pointed at the TARGET's parent while that parent is covered by leaf-granular rather than directory-granular masking. Deliberately not a rule: which empty document is absent-equivalent for a given leaf. That is a per-reader semantic judgement (b"" vs b"[]\n" vs b"{}\n" here), so the table carries a written argument per leaf instead of a lint. Co-authored-by: Kiro Crew <noreply@kiro.dev>
|
/ai-review override gpt dfe4e76: the md-notebook state files already sit under the agent-writable workspace/md-notebook on main today, so this hardening PR neither introduces nor worsens the ancestor exposure, relocating that live state is tracked as #8797, and blocking here would leave the three PAT paths this PR does close open on main. |
Human judgment recorded@bolichen97 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
Problem / Motivation
Giving the md-notebook backend its own hidden state leaves back made
workspace/md-notebook/{pat,vaults.json,settings.json}writable on a sandboxed host forthe first time. Two paths then expose the GitHub PAT to a same-uid sandboxed agent. Both
are reachable on
maintoday.A — an absent leaf gets no mask at all.
mount(2)cannot target a path that does notexist, and the launcher's hiding loops are guarded on existence, so a leaf that has never
been written is silently skipped:
sandbox.py:3584if os.path.isdir(target):andsandbox.py:3674if os.path.isfile(target):— an absent path matches neither loop._CREW_PRECREATE_HIDDEN_DIR_LEAVES(sandbox.py:598) is the only hidden-leaf materialiser input on main, and it holds("aws-control-staging",)— nothing for the md-notebook FILE leaves._CREW_PRECREATE_READONLY_DIR_LEAVESalready names this: "The samemount(2)limit leaves theSENSITIVE_DIRS/SENSITIVE_FILESmask loops skipping their own absent targets. That is a real sibling gap … each leaf needs its own argument."So on a default install an agent namespace spawned before the first vault attach has
no mask at the
patname. The namespace outlives that: the backend saves a PAT later, andthe still-running agent reads it at the real path.
B — the staged temp sits outside the mask. The mask covers exactly three names
(
sandbox.py:217-219), while the writers published throughatomic_write, which stagesnext to the target:
atomic_write.py:980fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")server.py:429atomic_write(target, pat, fsync=True, restrict_to_owner=True, …)That temp holds the real PAT bytes at a name no mask covers. A SIGKILL between write and
rename leaves it readable forever. Related, same mechanism:
server.py:1447cleared thePAT with
os.unlink(_pat_file()), deleting the mask's own mount target.Why it matters
The exposed value is a live GitHub token with push rights to the user's notes vaults. The
reader is any same-uid process in an agent namespace — which is exactly the population
_CREW_HIDDEN_LEAVESexists to fence, so both paths defeat the control rather thansitting outside it. Path A needs no crash and no race: a fresh install, one long-lived
agent session, then a first vault attach.
What changed (motivation → approach → change)
Hole A is an absent mount target, so the fix is to stop the target being absent.
_materialize_md_notebook_mask_targets()publishes each leaf's absent-equivalent documentbefore launch, at the same call site as
_materialize_sealable_ceilings()/_materialize_maskable_dirs(). It is the NESTED counterpart to the latter, whichdeliberately handles only direct children of the data home with a plain
mkdir; theseleaves sit under an agent-writable
workspace/md-notebook/, so every chain it walks firstgoes through the #4381 planted-link refusal, before any
mkdir(amkdirfollows aplanted link).
_MD_NOTEBOOK_PRECREATE_CONTENTcarries the per-leaf absent-equivalenceargument main's gap note asks for:
pat→ empty,vaults.json→[],settings.json→{}, each already what its reader returns for absent.Hole B is a temp outside the mask, so the fix is to move the temp inside one. All three
state writers now stage in a TOP-LEVEL
md-notebook-stagingdirectory, masked as a whole DIRECTORYso every temp name — present, future, and orphaned — is covered, and added to the app's
owned leaves so the backend's own spawn still gets it back.
atomic_write.refuse_linked_parentis the existing #4381 guard made public for a stagerthat cannot route through
atomic_writeitself; it is not a second implementation.Four riders, each closing something the above would otherwise open. A leaf is accepted only
as a REGULAR file, judged by
lstat, both when it already exists and when it wins thepublish race — one check covering a link (a mount resolves its target, so a resolving link
would mask the referent while the lexical name stayed agent-replaceable) and a
FIFO/socket/device node (matches neither
isdirnorisfile, so its mask is skipped insilence). Clearing the PAT writes an empty file instead of unlinking, keeping the mount
target. And the md-notebook spawn starts with
-I+runpyand an explicit import root, sositecustomize/usercustomize/ user-site.pthfrom an agent-writable directory cannotexecute in the one namespace where the PAT is unmasked; that is scoped to this spawn, and the
other module builtins keep the bare
python -m.The staging directory is TOP-LEVEL rather than a child of the state dir, for the reason the
sibling
aws-control-stagingleaf already records: a mask covers the leaf, not its ancestors,so under the agent-writable
workspace/md-notebookit could be renamed out from under its ownmask. Being a direct child of the data home also means the shared
_materialize_maskable_dirscreates it, under its own documented rule.
A link in the state chain degrades the app, not the host. Materialisation cannot verify
which directory a write to that name would reach, so the leaf is SKIPPED and the spawn
proceeds. Refusing it would let one optional app's on-disk layout stop every sandboxed process
on the host — an operator who symlinks
workspace/to another disk would find no agent couldstart, over a Notes file they may never have created. Skipping is safe only because it is keyed
off the SAME predicate that withholds the carve-out (
carveout_chain_has_planted_link, appliedin
app_backend_visible_targetsalongside #8965's foreign-mask filter): while a link is in thechain the backend cannot write this state at all, so an unmaterialised — and therefore unmasked
— leaf has nothing to expose. The two are never decided separately, and a test pins them
together because separating them reopens exactly the hole this PR closes.
Two further items close the same exposures on paths materialising forward cannot reach.
Pre-upgrade orphans are swept: an older writer staged beside the target, so a crash in
that window can leave a PAT-bearing
*.tmpat a name no mask covers, and exposure B wouldotherwise be closed for new installs while an upgraded host kept those bytes readable
forever. Every
*.tmpDIRECT child of the state dir is such an orphan by construction —the writers now stage in the top-level staging dir, and note temps live beside their note under
vaults/<id>/— so the sweep removes them (regular files only, judged bylstat, sparing another spawn'sin-flight ceiling temp), refuses the spawn if one cannot be removed, and logs each removal as
a token-exposure event so the operator knows to rotate. It runs on BOTH launch paths and
across BOTH crew-home spellings, unlike materialising, which is Linux- and live-home-only:
a Seatbelt deny and a live-home stub cannot reach an orphan already on disk under a legacy
home. A drift gate pins the fact that this hardening
is md-notebook-literal while
_APP_BACKEND_OWNED_LEAVESis a generic table: an app joiningit would get its leaves unmasked for its own spawn and silently reopen both holes, neither
visible at runtime, so a test fails until that app either ships its own materialisation and
startup-isolation decision or is added to the coverage table.
Tests
test/test_sandbox_md_notebook_carveout.py(new, POSIX-only by collection):TestTheStagingDirectoryIsMaskedAndCarvedBack—md-notebook-stagingis masked in all three tiers and both home spellings, is returned to the backend spawn, is lifted from the launcher's hidden list for it, has no agent-writable ancestor, is named identically by the writer and the mask, and is absent from the precreate table (the two tables must not drift). Also that the sweep runs on BOTH launch paths.TestAbsentStateFilesAreMaterializedSoTheMaskCanMount— every leaf is created with its absent-equivalent document; those documents read as absent to the backend; existing state is left byte-for-byte alone; an absent data home is not created; and the spawn is REFUSED on a failed publish, a planted intermediate symlink, a resolving leaf symlink, a dangling leaf symlink, a special file at a leaf, and one raced in during the publish window. Plus the legacy sweep: pre-upgrade sibling temps in all three shapes are removed; the state files and vault clones survive; a non-regular*.tmpis left alone; an unremovable orphan refuses the spawn; and the sweep refuses to delete through a planted parent link or a linked state directory (the chain guard and theO_NOFOLLOWopen are each mutation-pinned). Plus:namespace_argvmaterialises before the launcher script is built.TestANewOwnedLeavesAppCannotSilentlySkipMaterialization— the drift gate: an app added to_APP_BACKEND_OWNED_LEAVESwithout materialisation coverage fails, with the message naming both things it must decide.TestStateWritersStageInsideTheMask— a crashed publish leaves its temp inside the mask; each writer publishes to its target with no residue; a planted parent link and a planted staging link each refuse the write; and the PAT clear keeps the mount target, asserted both at the writer and throughapi_pat's clear branch.test/test_app_backend.py— the shipped md-notebook spawn starts isolated (-I,runpy,explicit root) while other module builtins keep
-mand get no carve-out, and athird-party app wearing the name gets neither.
docs/system-specs/modules/md-notebook.mdrecords the state layout (including the staging dirand
settings.json, both previously undocumented) and a new Sandbox section.Mutation-checked: every guard was reverted in turn and the owning test asserted to go
red — 27 mutations, 27 caught, 0 survivors, re-run in full AFTER the rebase onto
cbdd4a569rather than before, because the staging leaf moving to the data-home root and thesweep being hoisted onto both launch paths invalidated the earlier run.
Three of those mutants were only caught because the run exposed a weakness and it was fixed
rather than explained away: a dedicated leaf-link helper turned out to be unreachable
(
lstatalready refuses a link at both the existing-target and race-winner checks) so it wasdeleted as dead code; the PAT-clear test called the writer directly and so never pinned
api_pat's branch, which now has a route-level test; and the sweep's returned list wasbookkeeping no caller consumed, so it is now asserted as the operator-visible record of which
credentials were exposed. Stated precisely: the
dir_fdPINNING oflstat/unlinkisdefence-in-depth against a TOCTOU race and is NOT mutation-pinned — in any non-racing test the
chain guard fires first — while both link guards themselves are.
Manual verification
N/A — unit coverage sufficient. Both holes are on the sandbox launch path, and the tests
drive the real
_materialize_md_notebook_mask_targets(), the real writers, and the realnamespace_argv/ launcher-script builders against a temp data home; the refusals areasserted by planting the actual on-disk shapes (symlinks, FIFOs, a raced
mkfifo).Related Issues
Context, deliberately NOT closing keywords:
carveout_shadowed_by_foreign_mask; the staging spelling this PR adds is filtered by it for free..state/directory. That would subsume the staging and leaf-link work here; it needs a migration of live user state and is deliberately not attempted in this PR.Pattern harvest
Rule candidate:
semgrepPattern: a mask/deny entry naming a path that no materialiser list covers, where the
launcher applies that entry under an existence predicate (
isdir/isfile/exists).Both halves are statically visible — the guarded application and the absent target — and
together they are the whole bug class, so detecting it needs no runtime probe.
Rule candidate:
semgrepPattern: a secret writer opening its temp with
mkstemp(dir=…)/NamedTemporaryFile(dir=…)pointed at the TARGET's parent, while that parent is coveredby leaf-granular rather than directory-granular masking. The temp name is by construction
outside a leaf mask.
Not generalizable: which empty document is absent-equivalent for a given leaf. That is a
per-reader semantic judgement (
b""vsb"[]"vsb"{}"here), so the table carries awritten argument per leaf rather than a lint.
Checklist