You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Reads in CoalWash's working folders refuse links that leave them
A security fix. A repository that carries a link under .claude/coalwash/, or in a role store RE-TIER reads, could make a CoalWash read return content from a file outside the project. Every read in those folders now has to resolve inside the folder it starts in, and a read that does not is refused. The installed plugin moved: ten scripts/lib files, hooks/hooks.json, skills/coalwash/references/platform-cc.md and their plugin/ twins.
Security
A planted link could redirect a read of CoalWash's own files (E2-4, the read half of CWK-137). The v1.9.0 fix contained every write and delete under .claude/coalwash to that folder, but 17 reads (16 of the files below it, and the role store RE-TIER reads) passed no containment root (null) to readRepoFileBounded, readRepoBytesBounded or repoReadOutcome: they were size-bounded and kind-gated, and they followed a link out. ownSandboxDir proves the chain only down to coalwash, so the snap-<n> sub-folder and the files in it were never checked. The reviewer's witness, run through a junction (the unprivileged stand-in for a committed symlink): a junction at .claude/coalwash/snap-1 pointing at a folder outside the project, holding a manifest.json and a copy, made node scripts/lib/cli.mjs anchor-diff MEMORY.md --json exit 0 and print the links, code spans and numbers of a file outside the project. The same shape on a bin item (restore <id>) was predicted to print that file's bytes; that half was not run, because a file symlink needs a privilege this box lacks.
What is refused now. Each of the 17 reads, and two rollback reads found at INSPECT, passes the folder it lives in as its root: the transaction folder (anchor-diff's snapshot manifest and copy, verifySnapshot, the sweepSnapshots and recoverDangling journals, recoverDangling's snapshot manifest, the project keeps store), the bin folder (index, item, death log), the write-guard session folder and the write-guard root (sidecar and snapshot blob, in the airbag, the seatbelt and the restore door), the role store's own folder (RE-TIER's index and topics, and RE-TIER's rollback read of its own snapshot manifest), the lock's own folder (the lock token) and, for the in-run rollback of applyPlan, the transaction folder (the copy it restores from, read through the same bounded reader and written back with writeFileSync, where it used to be a copyFileSync with no check; a refused copy is one failed restore, rolledBack: 'partial'). Containment resolves the real path of both sides, so the boundary is the folder itself and not the project: a link that resolves to another place inside the project is refused too. A refused read is treated as unreadable and fails closed. recoverDangling reports a refused journal as unreadable, and a refused or non-list snapshot manifest returns none and keeps the journal. listWriteguard uses lstat, so a linked session folder or a link entry is not listed. restore prints its own refusal line for an item that resolves outside the bin ("NOT read, nothing was written"), where it used to say to copy the item by hand, and a second line for a bin folder (or .claude/coalwash itself) that is a link or not a plain directory, which names the folder and never suggests copying anything from it; both lines are the same for an id that exists and one that does not. Two second reads in the write-guard were removed: it now uses the bytes it already verified.
Reads that keep root null (14), each with a one-line reason at its site. Either the path is a real path already checked inside its roots (the plan targets, the surviving .md files of the dead-link advisory, the governed file the write-guard snapshots) or it is a class-B entry that discovery realpath-contains to the home and project trees, the same files the platform itself loads. The global keeps store keeps null on purpose: it is user-owned, and a link to it (a stow-style dotfile) must still protect.
Named limits. A hardlink is refused by no root, but git cannot commit a hardlink, so a clone cannot carry one. The lock-token read is contained for uniformity, but no planted entry reaches it: an existing lock makes the exclusive create fail, and the takeover refuses a link before it reads. The kept reads rely on a real path checked just before, so only a concurrent local writer could swap the path between the check and the read.
Proof. The fix is commit cd6cd4c, completed by 93dc960 (the linked bin folder, and the two rollback reads). The new scripts/lib/read-containment.test.mjs holds 39 tests, one or more per read kind (directory-link legs, simulated file-link legs, a spawned anchor-diff run, and a spawned restore run on POSIX). Against the code before cd6cd4c, 22 of its first 31 tests fail, 3 pass by design (a non-vacuity control, the kept global keeps store, the .. primitive) and 6 skip; the 8 tests 93dc960 added fail 4 against 159593f and the rest are controls or skips. After it, 31 pass and 8 skip: the 8 real file-symlink legs (tests 4, 9, 14, 17, 22, 30, 35 and 38) need a file symlink, which this Windows box refuses, so they run only on CI's POSIX legs and are pending until CI reads them. A hand-listed mutation table put the unchecked read back in 34 mutants: 30 were killed and 4 survived, each named: the lock-token read (equivalent at a clone's reach, above), a guard on the in-run rollback copy (equivalent: the write of a missing buffer throws and counts as a failed restore) and two that only the POSIX legs can fail (the lstatSync on a file entry, and the restore refusal line).
Fixed
Every hook now runs node in exec form, on every platform; the Windows case is why. It needs Claude Code 2.1.139 or later. This answers Claude Code 2.1.299, whose changelog reads "Windows: Fixed PreToolUse and PermissionRequest hooks that set no shell failing open when Git for Windows isn't installed; a guard that fails now blocks the call". On Windows without Git for Windows the old shell-form hook ran under PowerShell, where ${CLAUDE_PLUGIN_ROOT} expands to nothing, so the edit guard failed and, from 2.1.299, blocked every Edit, Write and MultiEdit. All five entries in hooks/hooks.json are now command: "node" with the conductor path in args, no shell and no onFailure. Limit: verified by a probe that hands PowerShell the old string with Git removed from PATH; Claude Code's own no-Git fallback was not run on this box. — test: scripts/hooks-exec-form.test.mjs
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Reads in CoalWash's working folders refuse links that leave them
A security fix. A repository that carries a link under
.claude/coalwash/, or in a role store RE-TIER reads, could make a CoalWash read return content from a file outside the project. Every read in those folders now has to resolve inside the folder it starts in, and a read that does not is refused. The installed plugin moved: tenscripts/libfiles,hooks/hooks.json,skills/coalwash/references/platform-cc.mdand theirplugin/twins.Security
.claude/coalwashto that folder, but 17 reads (16 of the files below it, and the role store RE-TIER reads) passed no containment root (null) toreadRepoFileBounded,readRepoBytesBoundedorrepoReadOutcome: they were size-bounded and kind-gated, and they followed a link out.ownSandboxDirproves the chain only down tocoalwash, so thesnap-<n>sub-folder and the files in it were never checked. The reviewer's witness, run through a junction (the unprivileged stand-in for a committed symlink): a junction at.claude/coalwash/snap-1pointing at a folder outside the project, holding amanifest.jsonand a copy, madenode scripts/lib/cli.mjs anchor-diff MEMORY.md --jsonexit 0 and print the links, code spans and numbers of a file outside the project. The same shape on a bin item (restore <id>) was predicted to print that file's bytes; that half was not run, because a file symlink needs a privilege this box lacks.anchor-diff's snapshot manifest and copy,verifySnapshot, thesweepSnapshotsandrecoverDanglingjournals,recoverDangling's snapshot manifest, the project keeps store), the bin folder (index, item, death log), the write-guard session folder and the write-guard root (sidecar and snapshot blob, in the airbag, the seatbelt and the restore door), the role store's own folder (RE-TIER's index and topics, and RE-TIER's rollback read of its own snapshot manifest), the lock's own folder (the lock token) and, for the in-run rollback ofapplyPlan, the transaction folder (the copy it restores from, read through the same bounded reader and written back withwriteFileSync, where it used to be acopyFileSyncwith no check; a refused copy is one failed restore,rolledBack: 'partial'). Containment resolves the real path of both sides, so the boundary is the folder itself and not the project: a link that resolves to another place inside the project is refused too. A refused read is treated as unreadable and fails closed.recoverDanglingreports a refused journal as unreadable, and a refused or non-list snapshot manifest returnsnoneand keeps the journal.listWriteguarduseslstat, so a linked session folder or a link entry is not listed.restoreprints its own refusal line for an item that resolves outside the bin ("NOT read, nothing was written"), where it used to say to copy the item by hand, and a second line for a bin folder (or.claude/coalwashitself) that is a link or not a plain directory, which names the folder and never suggests copying anything from it; both lines are the same for an id that exists and one that does not. Two second reads in the write-guard were removed: it now uses the bytes it already verified.null(14), each with a one-line reason at its site. Either the path is a real path already checked inside its roots (the plan targets, the surviving.mdfiles of the dead-link advisory, the governed file the write-guard snapshots) or it is a class-B entry that discovery realpath-contains to the home and project trees, the same files the platform itself loads. The global keeps store keepsnullon purpose: it is user-owned, and a link to it (a stow-style dotfile) must still protect.cd6cd4c, completed by93dc960(the linked bin folder, and the two rollback reads). The newscripts/lib/read-containment.test.mjsholds 39 tests, one or more per read kind (directory-link legs, simulated file-link legs, a spawnedanchor-diffrun, and a spawnedrestorerun on POSIX). Against the code beforecd6cd4c, 22 of its first 31 tests fail, 3 pass by design (a non-vacuity control, the kept global keeps store, the..primitive) and 6 skip; the 8 tests93dc960added fail 4 against159593fand the rest are controls or skips. After it, 31 pass and 8 skip: the 8 real file-symlink legs (tests 4, 9, 14, 17, 22, 30, 35 and 38) need a file symlink, which this Windows box refuses, so they run only on CI's POSIX legs and are pending until CI reads them. A hand-listed mutation table put the unchecked read back in 34 mutants: 30 were killed and 4 survived, each named: the lock-token read (equivalent at a clone's reach, above), a guard on the in-run rollback copy (equivalent: the write of a missing buffer throws and counts as a failed restore) and two that only the POSIX legs can fail (thelstatSyncon a file entry, and the restore refusal line).Fixed
nodein exec form, on every platform; the Windows case is why. It needs Claude Code 2.1.139 or later. This answers Claude Code 2.1.299, whose changelog reads "Windows: Fixed PreToolUse and PermissionRequest hooks that set no shell failing open when Git for Windows isn't installed; a guard that fails now blocks the call". On Windows without Git for Windows the old shell-form hook ran under PowerShell, where${CLAUDE_PLUGIN_ROOT}expands to nothing, so the edit guard failed and, from 2.1.299, blocked every Edit, Write and MultiEdit. All five entries inhooks/hooks.jsonare nowcommand: "node"with the conductor path inargs, no shell and noonFailure. Limit: verified by a probe that hands PowerShell the old string with Git removed from PATH; Claude Code's own no-Git fallback was not run on this box. — test:scripts/hooks-exec-form.test.mjsRelease page · CoalWash
All reactions