Repository navigation
v1.12.2 - reads in CoalWash's working folders refuse links that leave them
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/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. - 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, 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. - 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.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. - 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 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
- Every hook now runs
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.mjs