CoalWash is verified under the same framework as its TheColliery siblings — Phoenix-13 hooks, reproducible builds, and event-driven independent scans. It is high-privilege by nature (it rewrites/deletes memory+governance files and archives/deletes transcript files), so the load-bearing safety gates live in CODE, not prose — see Structural Safety below.
Report a security issue in this repo through GitHub's private vulnerability reporting — Security → Report a vulnerability — never a public issue; enabled and verified live at press. In scope: anything that aims a write or delete outside the memory sandbox (a containment or anchor-gate bypass), anything that defeats the undo net (snapshot, recovery bins, rollback) or the estate lane's copy-verify-then-delete before an original is removed so a delete becomes unrecoverable, a pinned: true file getting rewritten or deleted anyway, a fidelity-gate bypass that lets a structured token drop silently, and prompt-injection through poisoned memory content that makes the tool act on it as an instruction rather than data. This is a one-person-maintained project: expect the report to be read and acknowledged, triaged against the scope above, and disclosed once a fix ships, with no fixed response-time SLA. A public GitHub issue remains the right channel for an ordinary, non-security bug.
2026-10-15 — a cloned repo could plant CoalWash's own .gitignore or snapshot folder and leave memory copies committable or misdirected (PR #18, apply.mjs:786)
What. CoalWash keeps copies of your memory files (snapshots, the journal, bin items, the keeps store, write-guard snapshots) under <project>/.claude/coalwash, inside the repository, with a catch-all .gitignore so git cannot track them (git ignore rules never apply to a path the repository already tracks, so a tracked file there is a third case, below). Two names in that folder were trusted because they existed. The catch-all was created with an exclusive create and "already exists" was taken as success, so a cloned repo that committed an empty .gitignore, one reading !* or a comment, or * with a CRLF line ending (git reads that as the pattern *\r, which is no catch-all), or a link or a directory at that name, left every copy CoalWash then wrote visible to git add. And the snapshot folder snap-<now> was created with a recursive mkdir, which accepts an entry already there, so a repo that committed that name as a link or a folder for the exact millisecond of a run had the memory copies written through it. Both are the write half of the 2026-10-09 record below; the exposure is confidentiality of your memory content (a commit and push of the copies, or a copy written outside the project).
Reach. The .gitignore shapes need only a committed file, link or directory at one name. The snapshot folder needs a committed link or directory named for the run's Date.now() to the millisecond, which a repo author cannot predict, so that half is low.
Fixed in v1.12.3 (commits 1baca4f, b8976ec and c5e743a; its CHANGELOG.md ### Security entry carries the mechanism): anything at the .gitignore name that is not a plain file reading exactly * and a newline is replaced and read back, and where it cannot be replaced every writer of a memory copy refuses before it writes (self-ignore: <reason> — refusing before any journal or snapshot is written, so no copy of memory content lands where git can track it; the bin, the keeps store, a lock takeover and the write-guard airbag each refuse in their own way). A snapshot folder is created without recursive, so an existing entry at its name is refused (snapshot folder: <path> already exists or cannot be created (EEXIST) — refusing before the first snapshot byte; …). The global lock no longer creates a .gitignore in your own ~/.claude. (From v1.12.4, recordKeep also returns the refusal reason in an error field; until then the refusal was real but its reason did not reach the caller.) And a write into CoalWash's folder is refused while git tracks any file there: a repository that committed .claude/coalwash/keeps.json, a bin index.json, a journal.json or a .gitignore there had your keep record (which quotes your memory file) or a replaced catch-all written over a tracked path, which git commit -a would stage. applyPlan asks git once per transaction, before the lock and the first byte, and recordKeep and a standalone recordBinItem ask for their own folder; each refuses with tracked: git tracks N file(s) in <dir> — CoalWash writes nothing into its own folder while the repository tracks files there, since a write over a tracked file would reach the next commit (see them with: git ls-files -- .claude/coalwash) (or, when git errors inside a repository, cannot tell whether git tracks files in <dir> (git ls-files: <how>) — refusing, since a write over a tracked file would reach the next commit). With no repository above the folder, or git absent, nothing is asked.
Verified on, and not. Windows 11 on NTFS: the junction and directory legs run; the file-symlink leg and the POSIX directory-symlink branch (expected: replaced, not refused) are proven only by CI's ubuntu and macOS legs.
Affected versions, derived with git log --reverse -S<identifier> then git tag --contains on the commit that introduced the function: the .gitignore creation (ensureSelfIgnore) and the snap-<now> folder, from v0.1.0-beta.1; the keeps store write, from v0.1.0-beta.6; the write-guard airbag snapshot, from v0.1.0-beta.19; the overwrite of a tracked keeps file and of a tracked bin index (the third case), from v0.1.0-beta.6 and v0.1.0-beta.12; each through v1.12.2, the latest at the fix. These are where each function first appears, not a per-tag re-run.
Not closed, by name. (1) A repo can ship a fake bin item and an index row naming a memory file: the bin list then shows it as a CoalWash cut, and restore prints its bytes for you to paste back. Nothing is written; it is a provenance limit. (2) An untracked planted journal.json (a project unpacked from an archive, with no git history) still replays its snapshot over the memory files; that limit is the one stated under the 2026-10-03 record below and is unchanged. (3) A tracked permissive .gitignore in CoalWash's folder or in writeguard/ is replaced with the catch-all * by the write-guard airbag, so git shows that file modified (the catch-all bytes only, no memory content). (4) A tracked file a repository placed under writeguard/<x>/ is deleted by the SessionStart sweep of old session folders, so git shows it deleted (nothing is written, no memory content is involved). (3) and (4) are accepted as named: the write-guard runs in a hook, and a hook may not spawn git to ask first. (5) configure.mjs's project-config write (writeRepoFile) checks the target's nearest existing folder, then walks every folder from the project root down, refusing a link or junction at any of them wherever it points, before it makes the missing folders one at a time and writes, so a hostile local process that swaps a just-checked folder for a junction to somewhere outside between the check and the write can land the config file outside the project; measured on Windows with an instrumented swap, and the per-component walk that now ships narrows the window but does not close it. Nothing a cloned repository plants ahead of time reaches outside: a link planted ahead of time, wherever it points, is refused before anything is made.
Until you update to v1.12.3, do not open an untrusted clone with CoalWash installed.
2026-10-09 — a cloned repo could make CoalWash read a file outside the project through a link in its own folders (CWK-137, the read half, E2-4)
What. The 2026-09-25 record below closed the write and delete side, and made reads of repo-derived files bounded and kind-gated. It did not contain the reads of the files CoalWash keeps under <project>/.claude/coalwash, nor of a role store RE-TIER reads: 17 of them passed no containment root (two more, the rollback reads of a run's own snapshot, were found at INSPECT and are contained too), so a link planted at one of those names was followed out of the folder. ownSandboxDir proves the chain only down to coalwash, so the snap-<n> sub-folder and the files in it were never checked. Witnessed through a junction: a link at .claude/coalwash/snap-1 to a folder outside the project made 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>) is predicted to print that file's bytes; that half was not run on the box that found it. Nothing is written or deleted through this path. The exposure is confidentiality: the content reaches the terminal, or the agent's context, of a user who runs anchor-diff or restore in a malicious clone, and an agent can be prompted to run them. RE-TIER runs anchor-diff without a user action, but prints only a count line.
Reach. The file kinds (a snapshot manifest or copy, a journal, the keeps file, a bin item, the bin index and death log, a write-guard sidecar or blob, a role-store file) need a committed file symlink: Git checks one out natively on Linux and macOS, and on Windows only where core.symlinks is enabled. The directory kinds (a snapshot folder, a write-guard session folder) need a committed directory symlink. A junction cannot be committed; it is the local stand-in the witness used.
Fixed in v1.12.2 (commits cd6cd4c and 93dc960; its CHANGELOG.md ### Security entry carries the mechanism): each of the 17 reads, and the two rollback reads, passes the folder it lives in as its containment root, resolved by real path on both sides, so the boundary is that folder and not the project; a read that does not resolve inside it is refused and fails closed. A listing of the write-guard folder no longer follows a linked session folder, and restore names the refusal instead of telling the user to copy the item by hand, for a linked item and for a linked bin folder. The 14 other reads that keep no root are paths already checked inside their roots or class-B entries that discovery contains to the home and project trees, each with its reason at the site. Verified on Windows 11 Pro 10.0.26300, NTFS, Node v24.19.0: the directory-link legs through junctions, the simulated file-link legs, a spawned anchor-diff run (no outside content in stdout or stderr) and the whole suite. Not yet verified: eight tests need a real file symlink, which that box refuses; they run on CI's POSIX legs and are pending until CI reads them. Not closed, by name: a hardlink is refused by no root, and git cannot commit one; a concurrent local writer could swap a path between a real-path check and the read it guards.
Affected versions, derived per read surface by the first tag that contains the commit introducing the function that performs the read (git log --reverse -S<identifier> -- <file>, then git tag --contains <commit> --sort=creatordate), each through v1.12.1, the latest at the fix:
- The crash-recovery journal and snapshot manifest (
recoverDangling) and the snapshot-retention journal (sweepSnapshots): fromv0.1.0-beta.1, where both are present. - The snapshot verify copy (
verifySnapshot) and the project keeps store (loadKeeps): fromv0.1.0-beta.6. - The bin index, item and death log: from
v0.1.0-beta.12(the file wasbins.mjsthen). - The write-guard listing and snapshot blob read: from
v0.1.0-beta.19; the sidecar read (origPathSidecar): fromv1.7.0. anchor-diff's snapshot manifest and copy reads, and RE-TIER's role-store reads (collectStores): fromv0.2.0-rc.1.- The lock-token read (
readLockToken): fromv0.1.0-beta.2, but no planted entry reaches it, so it is contained for uniformity and is not counted as exposed. - The two rollback reads: the in-run rollback copy of
applyPlan: fromv0.1.0-beta.1; RE-TIER's rollback manifest read (rollbackFromSnapshot): fromv0.2.0-rc.1. Both read the run's own freshsnap-<now>folder, so they are reachable only by a concurrent local writer; they are contained for uniformity and are not counted as exposed.
These dates are where each function first appears, not a per-tag re-run. Before v1.9.0 the reads of repo-derived files were unbounded and link-following (the 2026-09-25 record below states it); v1.9.0 is the first tag with the bounded reader (readRepoFileBounded), so from v1.9.0 through v1.12.1 they were bounded and kind-gated but not contained. Which older tag let a planted link reach which surface was not re-run per tag.
Until you update to v1.12.2, do not open an untrusted clone with CoalWash installed, and do not run anchor-diff or restore in one.
What. v1.10.0 bounded a cloned project's estate.purgeAfterDays and estate.compressAfterDays (a project may only lower the first and only raise the second) but only while your own estate.deleteCold was not true. If you had turned deleteCold on, a project value stood: a cloned project could lower compressAfterDays and bring sessions into the archive-then-remove band sooner than your own number, and CoalWash then archived them and removed the originals once the archive verified.
What v1.11.0 refuses. Both bounds hold whether or not deleteCold is true. A project value outside the allowed direction is ignored and named on stderr by estate-scan and estate-run and by configure.mjs. With deleteCold true, compressAfterDays is the edge that decides which sessions are removed; a lower project purgeAfterDays only relabels warm as cold.
Affected versions. v1.10.0 and v1.10.1, and only for a user whose own deleteCold is true. Earlier versions carry no such bound at all, as the 2026-10-03 advisory below states. The measurement behind "which edge removes" is the coder's reading of estate-archive.mjs at the commit that closed this; the tags before v1.10.0 were not re-run for this advisory.
Until you update to v1.11.0, if your own config sets estate.deleteCold: true, do not open an untrusted clone with CoalWash installed.
2026-10-03 — a cloned repo could steer crash recovery, the estate restore and the config merge (CWK-162)
What. An AI deep scan of the repository (CWK-162) found paths where repository-controlled content steered CoalWash's own work. The ones that matter for safety: crash recovery replays a journal that lives inside the project, and treated every file inside the project as a valid target, so a forged journal could rewrite or delete git's control files (.git/config, .git/hooks/*); estate-restore --to wrote through a link planted under the destination and inflated an archive with no limit; and a cloned project config could raise the estate run limits above yours or set estate.purgeAfterDays: 0, which sends every aged session to the archive-then-remove band past your own deleteCold: false. Smaller ones (the @import closure, the managedPaths match, the write-guard advisory's restore line, the link checker) are in the CHANGELOG.md ### Security entry.
What v1.10.0 refuses. Recovery refuses any target with a .git path segment (any case, the GIT~1 short name, trailing dots and spaces, NTFS stream suffixes), keeps the journal for a human and reports the recovery as partial (refusedGit). Where a git repository is present it also refuses to replay a journal or snapshot that git tracks in the project (refusedTracked): a real crash journal is never committed and a clone delivers only the files git tracks, so both are left in place and nothing is replayed. Each tracked name is matched by the path the filesystem resolves it to, never by its spelling, so a case variant or a short name does not slip past. In a repository, a git query that errors, runs past 5 seconds, exits non-zero or overflows its listing buffer counts as tracked, never as nothing tracked. A plan that names one target more than once, or stages more than 64 MiB in total, is refused before anything is read or snapshotted. The restore bounds what it inflates, and estate-restore --to refuses a link under its destination. A project config's estate.runBudget field is honored only when the schema accepts it and it is lower than your own value (or than the default, where you set none). While your own estate.deleteCold is not true, a project estate.purgeAfterDays is honored only when the schema accepts it and it is at or below your own value (your global value, else 180; 0, "never cold", counts as the highest), so a project value can only make sessions cold sooner, which stays report-only. In the same case a project estate.compressAfterDays is honored only when the schema accepts it and it is at or above your own value (your global value, else 14), so a project value can only keep sessions active longer, never move them into the archive-then-remove band sooner. Each project value that was dropped is named on stderr by estate-scan and estate-run.
Not closed, by name. A forged journal that git does not track, for example one inside a zip or a tarball download with no git history, is still replayed over files inside the project's trusted roots, which include your Claude memory store for the project. The measured case rewrote a memory file with an injected instruction and reported the recovery as rolled back. Where git is not installed, or there is no repository above the transaction directory (a stray .git folder that git itself would not accept, such as the lone .git/config of an archive, is no repository), the tracked-journal check cannot tell and behaviour is unchanged: the journal is replayed. Closing the remainder needs the journal to carry a proof of where it came from (a per-install key kept outside the repository), a new on-disk contract that is a future unit. The claim here is exactly this: git's control directory is refused, and a git-tracked journal is refused wherever a git repository answers; a journal that arrives any other way, or on a machine where git cannot be asked, is not. The class-A engine (explode, detonate) has findings of the same family and is not shipped in the plugin.
Affected versions, derived per surface from the tags (git tag --contains on the commit that introduced the primitive, with the tag before it checked to carry none of it):
- Crash recovery replaying a project-resident journal without the
.gitand tracked-journal refusals:v0.1.0-beta.1(the first tag, whererecoverDanglingis already present) throughv1.9.1, the latest;refusedGitappears in no tag throughv1.9.1. Which target classes each older tag let a journal reach was not re-run per tag. estate-restore, its--tooption and the unbounded inflate:v0.2.0-rc.1throughv1.9.1.v0.1.0-rc.3, the tag before, carries noestate-restore.- The
estate.runBudgetrun limits and a project value above yours:v0.2.0-rc.3throughv1.9.1.v0.2.0-rc.2has norunBudgetkey. - A project
estate.compressAfterDaysbelow your own boundary: the key arrives inv0.2.0-rc.1(config-schema.mjsandestate-archive.mjscarry it there,v0.1.0-rc.3carries none) andv1.9.1still lists it among keys the merge does not clamp. How the warm band behaved at each tag between them was not re-run. - A project
estate.purgeAfterDaysabove your own boundary: the key arrives inv0.2.0-rc.1andv1.9.1still lists it as deliberately not clamped. How the cold gate behaved at each tag between them was not re-run. - Underived, and stated so rather than guessed: the smaller findings in the
CHANGELOG.md### Securityentry (the@importclosure, themanagedPathsmatch, the write-guard advisory's restore line, the link checker), whose introducing commits were not traced for this advisory.
Until you update to v1.10.0, do not open an untrusted clone with CoalWash installed. After you update, the remainder above stands: do not run /coalwash in a project you received as a zip, a tarball or any other copy without git history, and do not run it in an untrusted project on a machine without git, until the journal carries provenance.
2026-09-25 — a cloned repo could aim CoalWash's reads, writes and deletes outside the project (CWK-137; writes and deletes closed in v1.9.0, the read half in v1.12.2)
What. CoalWash's own working directory, <project>/.claude/coalwash, lives inside the repository, and a cloned repo controls what is at that path. The SessionStart write-guard sweep (sweepWriteguard, on by default, no consent step) recursively deleted the entries of <project>/.claude/coalwash/writeguard and followed a symlink there, so opening a malicious clone in Claude Code with CoalWash installed emptied the link's target. The write-guard snapshot, the stale-lock takeover, the bin death-log append and the configure.mjs project write followed links the same way, and several reads of repo-derived files (the project config, the @import closure, journals) were unbounded and link-following.
Reach. Git checks symlinks out natively on Linux and macOS, and a relative link is portable inside a repo; on Windows a symlink checks out only where core.symlinks is enabled, and a junction cannot be committed. Reproduced through a junction, the unprivileged Windows shim for the same mechanism: one SessionStart left the target directory empty.
Affected versions, derived from the commit that introduced each primitive (plugin.json version at that commit), not from tag names. Stated per surface, each through 1.8.0: the transaction directory, the @import closure read and the project-config read (the last two also run at SessionStart) from 0.1.0-beta.1; the lock takeover from 0.1.0-beta.2; the keeps store directory from 0.1.0-beta.6; the bin directory and the death-log append from 0.1.0-beta.12; the write-guard snapshot and its SessionStart sweep, the destructive vector that needs no user action, from 0.1.0-beta.19; the configure.mjs project write from 1.6.1. v1.8.0 is the last release carrying all of them.
Fixed in v1.9.0 (its CHANGELOG.md ### Security entry carries the mechanism): every directory CoalWash writes or deletes in under .claude/coalwash is reached only through a chain with no link between the project root and it; writes and deletes refuse a link, a special file or a multi-linked file; repo-derived reads are bounded and kind-gated, but the reads of CoalWash's own folders were not yet contained to them: that read half is closed in v1.12.2 (the record above). A related channel, a project-layer estate.archiveDir, is closed in the same release by reading that key from the global config only.
What 1.9.0 did not close, on Windows. For the stale-lock takeover, the bin death-log append and the configure.mjs in-place write fallback, 1.9.0 refuses a link committed at those names on every platform, and a link swapped in after the check on Linux and macOS (O_NOFOLLOW). Windows has no O_NOFOLLOW: a link swapped in between the check and the open was followed, the plain-file check on the handle passed on the link's target, and that target was then overwritten (the takeover), appended to (the death log) or written in place (the fallback). It needs a concurrent local writer, a stale lock or a held file, and a symlink privilege (a file symlink is refused with EPERM without developer mode). Affected: Windows, 1.9.0 only for a swapped-in link; through v1.8.0 a link at those names was followed unconditionally, as stated above. v1.9.1 (CodeQL #43/#44, js/file-system-race) closes the swap: each site opens first, then proves the path and the handle are both a plain single-link file and the same file by dev and ino. Still open after that, by name: a directory component swapped for a junction after the project-sandbox check (a directory-chain race; Node has no openat, and it needs a concurrent local writer); the read side's link-following window (it can redirect a bounded read, never a write or a delete); and the class-A engine (explode, detonate), which has the same check-then-open shape but is not shipped in the plugin.
Until you update to v1.9.0, do not open an untrusted clone with CoalWash installed; on Windows, until you update to v1.9.1, do not run CoalWash where an untrusted local process can write inside the project. Report anything further through the private channel above. For the read half, see the first record: update to v1.12.2.
Release tags and maintainer commits are SSH-signed (gpg.format=ssh); GitHub shows the Verified badge on them. Automated Dependabot / CI commits are unsigned by design (they carry no maintainer key), so verify a signed release tag — the artifact a release consumer trusts:
echo "* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEtqTWGKhX1Dk9nZP8ns13Wl5zsO1Cz3VlTS6m1p2fP9" > coalwash_signers
git config gpg.ssh.allowedSignersFile ./coalwash_signers
git tag -v "$(git describe --tags --abbrev=0)"plugin/ is generated, never hand-edited. node scripts/build-plugin.mjs reproduces it from source; node scripts/verify.mjs byte-checks dist-sync in BOTH directions (stale file and source-less orphan both fail) plus manifests, factory-config-vs-schema, and version pins; node scripts/test.mjs runs the zero-dependency suite with an explicit file list. Zero dependencies — no lockfile, nothing to npm audit.
Last scan: CoalWash v1.12.6 dist (plugin/, commit cf3d3e1, the v1.12.6 tag), on 2026-10-17, with NVIDIA SkillSpector v2.12.0 (self-reported version string; scan pinned to commit 2226747, upstream's untagged HEAD of 2026-09-30, 58 commits after upstream's tag v2.12.0), static stage (--no-llm, the documented FP-prone baseline). Score 100/100 (CRITICAL), 54 findings; every one was read at its source line, and every one is text, none program logic: 36 sit in source comments (one of them an inline comment after code, caliper.mjs:1353), 10 in reference documentation, 3 in refusal error messages (two containment refusals and, new at v1.12.3, the cannot-tell message of the tracked-folder check), 3 in user-facing strings (the updateMode and updateCheckDays help texts in config-schema.mjs and the update-offer line the conductor prints) and 1 on a constant's name (OH3, caliper.mjs:949, AUTO_COMPACT_WINDOW_MAX_TOKENS, the clamp ceiling of a window setting the gauge reads, which bounds an input and generates no output); the fifty-fourth, BH1, is the scanner's notice that hooks/hooks.json registers lifecycle hooks. Seven of the 53 therefore sit on a line of code: six match words inside a string literal there (two return { ok: false, error: ... } statements in apply.mjs, lines 924 and 963, one return of a template string there, line 250, the two schema rows and one out.push(...) call) and one matches a constant's name; the other 46 are comments and documentation. The 53 are RA1 ×9 on "self-update" (the consent-gated kind-1 self-update: the hook only SCHEDULES a check via a local stamp, the agent verifies online and OFFERS claude plugin update; sites are comments, the updateMode/updateCheckDays config-schema rows, the update command and the offer-directive string), RA2 ×20, TM1 ×7, AS1 ×7, PE3 ×1, P2 ×1, OH3 ×2, AS3 ×1 and AR2 ×2, all matching words inside source comments, refusal error messages (the text of what the engine REFUSES to touch, e.g. ~/.ssh and ~/.claude/settings.json, and of the tracked-folder refusal), user-facing strings (the three above), reference documentation and the one constant name, plus EA2 ×3. Two of the EA2 hits correctly describe behaviour the code has, and a user should know it: a delete or merge that is IN the plan is its own authorization (applyPlan takes no separate approval flag; safety is the pre-mutation snapshot and UNDO), and the warm-tier estate archive deletes the original after a copy-verify-then-delete with no consent gate, by design (as stated by the source comment and SKILL.md; the behaviour was not run). Static coverage was only PARTIAL, 13 of 32 files fully inspected, and the files that perform the writes and deletes are among those the scanner did NOT fully inspect (scripts/lib/apply.mjs, writeguard.mjs, fidelity-gate.mjs, estate-archive.mjs, keeps.mjs, tailings.mjs, retier.mjs, class-b.mjs, caliper.mjs, config-load.mjs, repo-fs.mjs, hooks/coalwash-conductor.js and more reached the parser span limit; hooks/hooks.json is opaque; a directory of the dist also hit a runtime limit at v1.11.0, v1.11.2, v1.12.0, v1.12.1, v1.12.2, v1.12.3, v1.12.5 and v1.12.6 but not at v1.11.1 or v1.12.4, a different directory each time, a timing artifact). The hand review of this dist was a sweep of the v1.8.0 to v1.11.0 diff plus a read of the whole v1.11.0 to v1.11.1, v1.11.1 to v1.11.2, v1.11.2 to v1.12.0, v1.12.0 to v1.12.1, v1.12.1 to v1.12.2, v1.12.2 to v1.12.3, v1.12.3 to v1.12.4, v1.12.4 to v1.12.5 and v1.12.5 to v1.12.6 diffs, not a line-by-line audit of those files: the dist now holds exactly ONE child-process site, new since v1.8.0, scripts/lib/apply.mjs running git ls-files -z (argv array, no shell, the GIT_* environment stripped by git-env.mjs, core.fsmonitor off, a 5-second timeout) to refuse replaying a journal that git tracks and, since v1.12.3, to ask whether git tracks any file in CoalWash's own working folder before anything is written there (one spawnSync call, in the helper gitLsFiles, serves both questions; the second adds --literal-pathspecs and the folder after --, and is skipped where no repository marker sits above the folder); there is no eval, new Function or network primitive; one guarded delete, caliper.mjs removing a per-project state-*.json of the room's own whose project root is gone (regular file only, realpath-contained, at most once a day); the hidden-Unicode census is unchanged from v1.8.0. One read is new since v1.12.0, and it is what the documentation hits on ~/.claude/settings.json in skills/coalwash/references/platform-cc.md describe: scripts/lib/caliper.mjs (probeCompactWindow) reads the session's auto-compact window, from CLAUDE_CODE_AUTO_COMPACT_WINDOW in the hook's environment, else from the top-level autoCompactWindow of ~/.claude/settings.json and of the project's .claude/settings.json and .claude/settings.local.json, and from the modelSettings.<model>.autoCompactWindow entries of the user's file only; every settings file is read through one bounded reader (1 MiB), only an integer from 100,000 to 1,000,000 counts, nothing else is kept, returned or sent, and the smaller window wins, so a project value above the user's changes nothing. The v1.11.1 to v1.11.2 dist changes are the version string, a rewritten comment block in scripts/lib/caliper.mjs and one reference paragraph in skills/coalwash/references/method.md; the v1.12.0 dist adds the read above and its documentation; the v1.12.1 dist narrows it (the user file is read bounded, a project file keeps only its top-level key) and changes one comment in scripts/lib/git-env.mjs; the v1.12.2 dist (13 files, 141 lines added and 84 removed) contains the reads of CoalWash's own working folders (the transaction, snapshot, bin, keeps and write-guard folders and the retier stores) to the folder they sit in, through the one bounded reader (a link that resolves anywhere else is refused and reads as unreadable), the rollback restore reading its snapshot copy through that reader and writing the bytes it read, the bin-restore command naming a linked bin folder and refusing it, and hooks/hooks.json moving its five entries (the same four events, the same matchers, the same single script) to the exec form, node as the command and the script path in args, which the scan cannot show the host resolving; no write, delete, spawn, network, eval or import site was added (the single write change is fs.copyFileSync replaced by fs.writeFileSync of the bytes just read, to the same destination), and the hidden-Unicode census is identical; the plugin tree of the v1.12.1 tag is identical to that of the main commit 4c6928b scanned on 2026-10-09 (tree 927283e). The v1.12.2 to v1.12.3 dist change, six files (the version string, scripts/lib/apply.mjs, keeps.mjs, repo-fs.mjs, tailings.mjs and writeguard.mjs, 120 lines added and 38 removed), was read in full by hand, the scanner-limited files included: a new ensureCatchAllIgnore in repo-fs.mjs keeps the self-ignore of CoalWash's working folders exact, a .gitignore that is not a plain file reading exactly * and a newline being replaced through the existing rename-based replaceFile (never written through) and a name that cannot be replaced, or does not read back as the catch-all, being a refusal; the transaction, the bins, the keeps store, the tailings index and the write-guard airbag now refuse, writing no journal, snapshot, copy or record, when it cannot be put in place; a new trackedInOwnFolder makes the transaction, a bin item and a keep record refuse while git tracks any file in the folder, or when that query fails inside a repository, before anything is written there; the snap-<now> folder is created non-recursively, so an entry already at that name refuses the run; and only the project locks self-ignore (the global lock writes no .gitignore). All of it narrows: the two exclusive creates of the catch-all became one (writeFileSync 16 to 15 across the dist), the single child-process call is unchanged, and no delete, network, eval or import site was added; the hook manifest did not change. The v1.12.3 to v1.12.4 dist change, five files (the version string, scripts/lib/apply.mjs, keeps.mjs, repo-fs.mjs and skills/coalwash/references/method.md, 16 lines added and 10 removed), was read in full by hand, the scanner-limited files included: applyPlan now releases the global lock and returns an error when creating the transaction folder throws (it used to leave the lock held until its stale window); the message of a failed bin stash and the recovery-incomplete message are reworded to name their causes (a busy bin lock, the bin's catch-all, the bin folder or an item or index write; a create whose copy could not be binned); recordKeepAt and recordKeep now return the reason beside the status fields, so a tracked: or self-ignore: refusal reaches the caller; a comment in repo-fs.mjs states that a hostile local writer who can already write the folder can swap the catch-all entry after the read-back (the read-back narrows that window to the caller's next write, it does not close it); and one sentence of method.md is reworded. No write, delete, spawn, network, eval or import site was added, the primitive census is identical, and the hook manifest did not change. The v1.12.4 to v1.12.5 dist change, three files (the version string, scripts/lib/estate-archive.mjs and skills/coalwash/references/method.md, 8 lines added and 2 removed), was read in full by hand, the scanner-limited file included: restoreSession (the estate-restore command) now also checks the final path of each restored file before it writes, with one lstat of the destination (an absent entry behaves as before and any other error propagates); a destination that is a link (a symlink, dangling or not, or a junction), is not a regular file or has a second hard link refuses the restore, returning the reason and the files already written, and is left in place. The check sits after the directory-chain check it complements and before the existing durable write; it narrows what a restore may overwrite, adds no write, delete, spawn, network, eval or import site, and one sentence of method.md is reworded to say so. The hook manifest did not change. The v1.12.5 to v1.12.6 dist change, two files (the version string and scripts/lib/repo-fs.mjs, 93 lines added and 19 removed), was read in full by hand, the scanner-limited file included: the writer's folder walk now refuses a link or junction at any component below the project root, wherever it points, a link that stays inside the project included (CWK2-048, ported from CoalLedger's exemplar); ownSandboxDir and the new mkdirRepoParents share one check, plainDirOrMissing (a real directory passes, a missing one ends the walk as plain, a link, a non-directory or an lstat error is the refusal), and mkdirRepoParents makes each missing folder with a plain non-recursive mkdirSync and checks it again after it is made, so nothing is made past a folder that was not checked, where writeRepoFile used one recursive mkdirSync; a file the user owns (no root) keeps the recursive make; the refusal error now also carries the folder to fix and why (link, not-dir, outside, io, vanished, escape) beside its unchanged message, and the escape test looks at the first path segment, so a real child folder named ..cfg counts as inside. It narrows what the writer will cross; the code names the residual that the walk and the write are two steps, so a hostile local writer that swaps a checked folder for a link before the write still redirects it. It adds no delete, spawn, network, eval or import site, and the primitive census is unchanged apart from one more mkdirSync call (the non-recursive make) and one more lstat call (the shared check). The 54 findings are the 50 of v1.11.2 (the same rule-and-file set, on the same source text, as v1.11.1's; those in caliper.mjs moved down with the added code) plus the three above: OH3 at caliper.mjs:949 and two AS1 mentions at platform-cc.md:27; v1.12.2 adds none (the same rule-and-file set on the same source text, ten sites moved by two or three lines in apply.mjs, keeps.mjs and tailings.mjs); v1.12.3 adds one, an RA2 at apply.mjs:250, which matches the words "next commit" in the cannot-tell message of the tracked-folder check ("write over a tracked file would reach the next commit": the text of why the engine REFUSES, which persists nothing), and moves eleven sites by line shift, the rest being the same rule-and-file set on the same source text; v1.12.4, v1.12.5 and v1.12.6 add none and move none, the same 54 findings by rule, file, source text and line. The score is not comparable to the 43 recorded at v2.3.11 on the 16-file v0.1.0-beta.1 dist: that dist re-scanned with v2.12.0 scores 84 (11 findings), and v1.12.6 is a 32-file dist of 15,777 lines against the 1,529 lines of the 16-file one (line counts of the extracted plugin/ at the v1.12.6 tag and of git show at v0.1.0-beta.1).
A re-scan is run when this repo's own version bump calls for it (the automatic baseline-diff trigger is planned and not yet wired) or on a genuinely new attack surface, not on every release—this pins the last version actually verified.
- Phoenix-13 hook. One hook file (
hooks/coalwash-conductor.js) branches on four registered events — SessionStart (the gauge; plain context-injection) · Stop (every FULL crossing force-runs the free mechanical Quick — non-optional by design, no forceMode knob; the sole ask is the once-per-crossing wizard escalation; a structured{decision:'block', reason}JSON write, the same mechanism CoalMine'srot-canaryuses) · PostToolUse (the 0o spawn meter, write-only; and the 0p write-path seatbelt — one plain advisory context-injection line when an edit to a class-B file drops a structured token, never a{decision:'block'}, never a nonzero exit) · PreToolUse (the 0p airbag, write-only snapshot-on-first-write into the sandbox). All fail-silent, zero-dependency, no network, no child processes, and silent outside those sanctioned channels (the advisory line is the same class as the SessionStart context injection — informational, never enforcing). A headless start is safe by construction — it only writes to stdout. - Delete/merge authorization is plan-sourced; safety is UNDO.
apply.mjsexecutes a delete/merge only because it is present in the adjudicated plan — there is no separate approval flag to bypass. Every apply snapshots (verified at creation) before the first mutation and a whole-run rollback restores the snapshot on any failure; apinned: truefile is refused outright — the gates hold even against a misbehaving orchestrating agent. Named limit: a rollback whose own restore fails, and a crash-recovery replay that cannot bank a file into the recovery bin before removing it, both reportpartialand KEEP the journal and snapshot rather than claim a clean state — a mixed state a human can still fix is preferred to an unrecoverable delete. - Path containment. Every touched path is realpath-resolved and contained on BOTH sides (declared roots too), fail-closed — a poisoned config or a symlink cannot aim a write/delete outside the memory sandbox. Discovery is read-only and contained the same way. Containment is only as strong as the ROOT it measures against, so the two functions that mutate the memory store — the apply, and the crash-recovery replay that opens every run and every
/coalwash:stats— derive their trusted roots through one shared anchor gate rather than two copies of the same checks: it refuses an anchor that swallows the home directory, and one that touches the Claude configuration directory in either direction (the second binds even a caller that supplied its own anchor; only the home-swallow leg is skipped there, and only then). The recovery replay takes the fail-closed reading of both legs, since its anchor comes from the working directory rather than a caller that vouched for one, and the gate runs above the first filesystem touch — so a refused anchor reads nothing and writes nothing. The hook's OWN per-project state (the session gauge) rides the platform's project directory (~/.claude/projects/<slug>/coalwash/) and the global update stamp lives at~/.claude/coal/coalwash/; the per-project path (whose<slug>is platform-derived) is realpath-contained to~/.claudeand fails closed to~/.claude/coal/coalwash/on any escape, while the fixed global stamp path is constructed directly under~/.claudewith no untrusted input in it — a hook write never lands outside the sandbox (Phoenix #10). The one-time migration off the pre-relocation~/.claude/.coalwash-state.json/.coalwash-update-checkdeletes only CoalWash's OWN prior state files, never a wildcard sweep. - Transactional apply. Exclusive lock (atomic-create + stale-timeout + defer-on-doubt), marked snapshot before the first mutation, fsync'd WAL, atomic tmp-then-rename writes, deletes ordered last, wholesale rollback on any failure. Honest ceiling: fsync is not stronger than the drive's write cache; the snapshot is the last backstop.
- House secret scan.
.githooks/pre-commitand.githooks/pre-pushrunscripts/secret-gate.mjsfirst (the tracked tree on commit, the added lines of every pushed commit on push), beforeverify.mjsand the suite, and fail closed. - Untrusted config is parse-guarded. The
.coalwash.jsonJSONC parse drops__proto__/constructor/prototypekeys; every numeric read is range-clamped to the schema default. - Memory content is data, never instructions — the skill contract binds every sub to judge content, not obey it (prompt-injection via poisoned memory is the named threat model).
Honest scope: these measures are the series' data-safety discipline — injection-safe, path-safe, snapshot-reversible deletes, scrubbed output, offline code, opt-in zero-transmission (localOnly). No formal verification, no crypto-at-rest, no "military-grade" claim.