Skip to content

Security: TheColliery/CoalWash

SECURITY.md

Verifying CoalWash

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.

Reporting a Vulnerability

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.

Advisories

2026-10-18 — a repository, or a project unpacked from an archive, could plant a future-dated bin item to push your real recovery copies out under the size cap, and a killed writer could leave a truncated copy that was listed as whole

What. Each recovery bin (the fat bin, store.old) has a byte budget. A bin item whose date is in the future or unreadable is never evicted (CoalWash keeps what it cannot date), but its size still counted toward that budget, so a repository, or a project unpacked from an archive, that planted one such row, or one such file in the bin-id shape (v1.12.9 lists a file with no row), could make the size cap evict genuine, older recovery copies to make room for it. Separately, a copy was written straight under its final name, so a CoalWash process killed mid-write left a truncated file under that name, which v1.12.9 listed and restore printed as complete.

Fixed in v1.12.10 (its CHANGELOG.md ### Fixed entry carries the mechanism): a future-dated or unreadable-dated item now weighs nothing toward the size cap, so it can no longer cause an eviction; and a copy is written to a temporary name, synced, and named only once whole (a hard link), with a temporary file a killed writer left removed by the next wash.

Not closed, by name. (1) On a filesystem without hard links (FAT or exFAT, some network and cloud mounts) the copy is still written directly under its final name, so a writer killed mid-write there can still leave a truncated copy that is listed. (2) A doubt item's bytes are not reported in the cap-conflict line; a bin can sit over its byte budget by such an item alone with no conflict line. (3) The provenance limit under item (1) of the 2026-10-15 record is unchanged: a repo can still plant a bin item that the bin list shows as a CoalWash cut.

Affected versions: the eviction through v1.12.9 for a project unpacked from an archive or any untracked plant; for a cloned repo whose bin files are tracked it ends at v1.12.2, because since v1.12.3 CoalWash refuses the whole run before the sweep while git tracks files in its own folder (trackedInOwnFolder, apply.mjs). The earliest affected version was not derived for this record. The truncated copy listed as whole, v1.12.9 only (earlier versions did not list a copy with no row).

Until you update to v1.12.10, do not open an untrusted clone with CoalWash installed.

2026-10-17 — a cloned repo could make CoalWash read a file elsewhere in your home folder through the project's own governance (CWK2-029 (b), the read half)

What. A project's class-B governance can name files outside the project: a CLAUDE.md holding @~/.aws/credentials or @~/.claude/.credentials.json (a credential file Claude Code keeps in ~/.claude) or a @../ escape, a CLAUDE.md that is a link to a file elsewhere, a memory-store folder that is a link or junction out. Claude Code may load such a file, so CoalWash counts its size, but discovery listed it as the project's own governance and READ its content: for the certain-fat count and to follow its own @ imports. The write and delete side was already contained (the 2026-09-25 record below); the exposure is that the gauge opened a file you never meant a cloned repo to name.

Fixed in v1.12.8 (its CHANGELOG.md ### Security entry carries the mechanism): a class-B file whose real path is inside none of the project, the project's own memory store and an ancestor governance file is sized by stat only, never read, never followed for imports, never offered for washing, and named by one flag that carries its path and no content. Your own global ~/.claude chain keeps its reach; a ~/.claude file a project names, other than its own memory store, is now outside. An ancestor CLAUDE.md above the project's repository top (the nearest folder holding a .git entry), and its own imports, are still read as before; a CLAUDE.md between the session folder and that top belongs to the clone, is read, and its imports follow the project's rule (a file it imports from elsewhere in the clone is outside, an undercount in the safe direction); a file a project names inside an ancestor folder is outside unless it is that folder's CLAUDE.md.

Not closed, by name. (1) A project file HARD-LINKED to a file in your home folder is not seen by this rule: its real path is its own project path, so its content is still read. A repository cannot carry a hard link, so a clone alone does not produce one; it takes a local action on your machine. The write half stays safe (a write replaces the project entry and leaves the home file unchanged). (2) The @import closure behind an outside file is no longer measured, so the footprint reads smaller than what Claude Code may load. (3) The two tests that make a file symlink skip where the account cannot make one; they have not been observed anywhere yet and belong to the POSIX CI legs. (4) An ancestor CLAUDE.md above the repository top has its own imports read wherever they land in its folder, ~/.claude or another ancestor folder; a folder above a clone's own .git folder is not the clone's (a submodule's .git file is item (5)). (5) Where no .git entry is found between the session folder and your home folder (a download without git history, opened in a nested folder), a CLAUDE.md above the session folder is still treated as an ancestor, and its imports keep the ancestor reach; the same holds where the nearest .git is a file git wrote for a submodule, so a session opened inside a submodule of a cloned superproject gives that superproject's CLAUDE.md the ancestor reach.

Affected versions: each through v1.12.7. The earliest affected version was not derived for this record.

Until you update to v1.12.8, do not open an untrusted clone with CoalWash installed.

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, with or without an index row (v1.12.9 added the no-row case: a file in the bin-id shape is now listed from the file itself, original path unknown), 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): from v0.1.0-beta.1, where both are present.
  • The snapshot verify copy (verifySnapshot) and the project keeps store (loadKeeps): from v0.1.0-beta.6.
  • The bin index, item and death log: from v0.1.0-beta.12 (the file was bins.mjs then).
  • The write-guard listing and snapshot blob read: from v0.1.0-beta.19; the sidecar read (origPathSidecar): from v1.7.0.
  • anchor-diff's snapshot manifest and copy reads, and RE-TIER's role-store reads (collectStores): from v0.2.0-rc.1.
  • The lock-token read (readLockToken): from v0.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: from v0.1.0-beta.1; RE-TIER's rollback manifest read (rollbackFromSnapshot): from v0.2.0-rc.1. Both read the run's own fresh snap-<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.

2026-10-04 — a cloned project's estate age limits stay clamped after you turn on deleteCold

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 .git and tracked-journal refusals: v0.1.0-beta.1 (the first tag, where recoverDangling is already present) through v1.9.1, the latest; refusedGit appears in no tag through v1.9.1. Which target classes each older tag let a journal reach was not re-run per tag.
  • estate-restore, its --to option and the unbounded inflate: v0.2.0-rc.1 through v1.9.1. v0.1.0-rc.3, the tag before, carries no estate-restore.
  • The estate.runBudget run limits and a project value above yours: v0.2.0-rc.3 through v1.9.1. v0.2.0-rc.2 has no runBudget key.
  • A project estate.compressAfterDays below your own boundary: the key arrives in v0.2.0-rc.1 (config-schema.mjs and estate-archive.mjs carry it there, v0.1.0-rc.3 carries none) and v1.9.1 still 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.purgeAfterDays above your own boundary: the key arrives in v0.2.0-rc.1 and v1.9.1 still 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 ### Security entry (the @import closure, the managedPaths match, 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.

Commit & Tag Signatures

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)"

Dist Integrity

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.

Independent Scanning—NVIDIA SkillSpector

Last scan: CoalWash v1.12.13 dist (plugin/, commit 9bef825, the v1.12.13 tag), on 2026-10-18, 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), 56 findings; every one was read at its source line, and every one is text, none program logic: 38 sit in source comments (one of them an inline comment after code, caliper.mjs:1356), 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:952, 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-sixth, BH1, is the scanner's notice that hooks/hooks.json registers lifecycle hooks. Seven of the 55 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 1007 and 1046, 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 48 are comments and documentation. The 55 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 ×3, 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, v1.12.6, v1.12.7, v1.12.8, v1.12.9, v1.12.10, v1.12.11, v1.12.12 and v1.12.13 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, v1.12.5 to v1.12.6, v1.12.6 to v1.12.7, v1.12.7 to v1.12.8, v1.12.8 to v1.12.9, v1.12.9 to v1.12.10, v1.12.10 to v1.12.11, v1.12.11 to v1.12.12 and v1.12.12 to v1.12.13 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 v1.12.6 to v1.12.7 dist change, four files (the version string, scripts/lib/caliper.mjs, class-b.mjs and repo-fs.mjs, 8 lines added and 7 removed), was read in full by hand, the scanner-limited files included: escapesRoot in repo-fs.mjs, the first-segment escape test, is now exported, and the three lexical startsWith('..') containment tests (containedNewPath in caliper.mjs, relLabel and containedIn in class-b.mjs, relLabel exported as well) call it instead (CWK2-051), so a real child folder named ..x counts as inside its root, where those three tests had read it as outside; .. alone, .. plus the separator and an absolute path on another drive still read as outside, and the checks around each test (the canonical-shape check on the root in containedIn, the physical-path walk in containedNewPath) are unchanged. It corrects an over-refusal and widens what the three tests accept by that one class, a path that is inside. class-b.mjs gains one import from repo-fs.mjs, which imports only config-load.mjs (and Node built-ins), which imports neither, so no import cycle arises. It adds no write, delete, spawn, network, eval or import site and the primitive census is identical; the hook manifest did not change. The v1.12.7 to v1.12.8 dist change, five files (the version string, scripts/lib/caliper.mjs, scripts/lib/class-b.mjs, skills/coalwash/references/method.md and skills/coalwash/references/platform-cc.md, 97 lines added and 11 removed), was read in full by hand, the scanner-limited files included (CWK2-029 (b), the read half of reach): discovery of the project's governance files no longer reads, follows or washes a file that a project reaches outside its own tree. A project-scope candidate whose real path is outside the project, outside the project's own memory store under ~/.claude and outside the ancestor governance is never read for content, so no @import is followed through it; it is listed as scope outside, sized by stat only, and named by one flag that carries its path and never its content. The room's own chain admits only the project, its memory store and an ancestor CLAUDE.md itself; an ancestor's own @import closure keeps the wider rule (the project, ~/.claude, the ancestor files and folders); global-scope candidates are unchanged. Ancestor governance begins above the repository top, found as the nearest folder holding a .git entry, read with lstat and never followed (a clone cannot commit one), and a CLAUDE.md between the session root and that top is the clone's own and keeps the room rule. In caliper.mjs a statOnly entry is never read, whatever the read budget or scanEverything says, and a statOnly index keeps its stat bytes and is not read for its line count. It narrows what discovery reads: a plain project CLAUDE.md holding an import of a file in the home folder used to have that file listed as project scope and its content parsed, and no longer does. It adds no write, delete, spawn, network, eval or import site; the primitive census differs by one lstat call (the .git check), and the two reference documents are reworded to say what the code now does. The two PE3 findings it adds are comments in class-b.mjs that name ~/.aws/credentials and .credentials.json as examples of what is refused. The v1.12.8 to v1.12.9 dist change, four files (the version string, scripts/lib/apply.mjs, scripts/lib/tailings.mjs and skills/coalwash/references/method.md, 53 lines added and 15 removed), was read in full by hand, the scanner-limited files included (P95-1, the bin catalogue under load): acquireLock returns an owns function beside release in both its acquire paths (it re-reads the lock token and answers false on any doubt); recordBinItem and the bin sweep call it before saving the index, and a holder that no longer owns the lock (a stale takeover handed it away) re-loads the index from disk and adds its own row, or removes only what it destroyed, instead of saving the copy it loaded before; loadIndex also lists a plain file in the bin whose name has the recovery-copy id shape (digits, a dash, 12 hex characters) and no row, with its size by stat and original and origin null, so a copy whose row a lost save dropped is not left unlisted; the sweep counts what it verified destroyed. A hostile clone can plant such a file in its own bin folder: the restore command only prints a copy to stdout and nothing writes through it, an id dated in the future is kept and never destroyed (the retention rule keeps what it cannot date), and the file sits in a folder the clone already controls. No write, delete, spawn, network, eval or import site was added; the primitive census differs by one readdirSync and one lstat call in loadIndex and by a test hook (beforeIndexSave, null in production). The v1.12.9 to v1.12.10 dist change, four files (the version string, scripts/lib/retention.mjs, scripts/lib/tailings.mjs and skills/coalwash/references/method.md, 75 lines added and 14 removed), was read in full by hand, the scanner-limited files included (E1 t173 and INSPECT 109, 114): a bin recovery copy is no longer written straight to its id name: writeBlobWhole creates a temp in a name shape the catalogue never lists (<id>.<12 hex>.part) with an exclusive openSync, writes and syncs it, then makes a hard link from the temp to the id (a name already there stays a failure, so nothing is written through an entry planted at the id) and removes the temp in a finally, so a writer killed mid-write leaves no torn file under an id; on a filesystem without hard links (EPERM, ENOTSUP, EOPNOTSUPP, ENOSYS or, on FAT and exFAT, EISDIR) it falls back to the earlier exclusive write at the id; the bin sweep now also removes a temp of that shape that is older than the lock's stale window (plain files only, by name shape, one by one, with a line in the bin's own log), and a bin holding only such a temp is still swept; and retentionPlan weighs a future-dated item at zero in the size cap, so a planted future-dated row or copy can no longer cause a real copy to be evicted. It adds one hard-link call, one temp-unlink path and one stale-temp unlink path in the bin folder CoalWash already owns, and no spawn, network, eval or import site; two test hooks (beforeBlobLink, linkSync) are null in production. The v1.12.10 to v1.12.11 dist change, four files (the version string, scripts/lib/apply.mjs, estate-archive.mjs and retier.mjs, 262 lines added and 45 removed), was read in full by hand, the scanner-limited files included (fires 119, 122 and 123, the lock fence): a lock another run can take over once it is stale is now re-read before each write step, and a run that finds its lock gone or foreign stops with a refusal that names what it leaves on disk and does not roll back (a rollback would overwrite the new holder's writes); the read has three outcomes (own, foreign, or unreadable after about one second of retries spaced by Atomics.wait, which blocks the thread and never spins), the release deletes a lock only when it can prove the lock is its own, and a lock it had to leave behind is named on the result (lockLeft); the estate archive and the RE-TIER run fence each write and, once robbed, remove nothing (archive copies already written stay, so no topic is left without its copy), and the delete of an original is fenced per file. It narrows what a run does after losing its lock; the one behaviour to know is that a run stopped on an unreadable lock leaves its journal for the next gauge to roll back from the snapshot, which the refusal says. No write, delete, spawn, network, eval or import site was added (the delete of a lock gains a retry loop at the same site), and the primitive census is identical to v1.12.10's. The v1.12.11 to v1.12.12 dist change, six files (the version string, scripts/lib/apply.mjs, estate-archive.mjs, keeps.mjs, retier.mjs and skills/coalwash/references/method.md, 153 lines added and 33 removed), was read in full by hand, the scanner-limited files included (fires 126 and 129, E1 t182, INSPECT 128, 131 and 135): each keeps store, a project's keeps.json and the global one in the user's own ~/.claude, is now updated under a lock of its own beside it (<store>.lock, made by the same exclusive acquireLock as the other locks, stale after five seconds, never the transaction lock), the store is read again inside the lock, and a recorder that finds the lock held, foreign or unreadable refuses and writes nothing ("record the keep again"); a lock path that is a link, a directory, a special file or a file with several hard links is never taken over and refuses at once; the store is replaced by a temp-then-rename that is retried up to nine times 50 ms apart (Windows refuses a rename over a store another handle holds open) with the lock checked again before each retry, a store that is a directory or read-only refuses at once, a temp the rename did not land is removed, and a lock that could not be released is named on the result. acquireLock now marks its refusals held or transient (a create error of EPERM, EACCES or EBUSY) so a caller retries only those, and the notes that name a lock left behind use that lock's own wait and each its own path. It narrows (writers that used to drop each other's entries are serialised, and a refusal path is added); the new primitives are one rmSync of the recorder's own temp and two lstat reads, and the lock file is made by the existing acquireLock; no spawn, network, eval or import site was added. The v1.12.12 to v1.12.13 dist change, two files (the version string and skills/coalwash/SKILL.md, 2 lines added and 2 removed), was read in full by hand: documentation only, no script, hook or manifest line other than the version moved. The write row of the Grants and denials table, shape (2), now tells the agent what to do when a keeps append returns an error string: report it to the user as given and do what it says (most say to record the keep again, a few to fix the folder or the lock file first), the wash not stopped either way, and without an error the keep was not recorded and the item re-flags next run. Those strings are built by the engine around project paths, and a path is repository-controlled text, so it stays data to the reader. The 54 findings of v1.12.3 to v1.12.6 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:952 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; v1.12.7 adds none, moves two sites by one line (class-b.mjs 410 and 495, under the new import line) and drops one, an RA2 on the comment block that opens the never-create guard in caliper.mjs (line 757, text unchanged): both dists were scanned twice with identical results (54 and 53), so the drop follows the bytes of the file, not chance, and it was a comment hit, 53 findings; v1.12.8 adds two PE3 on the comments above that name credential files as examples of what is refused, moves the other sites by line shift only (caliper.mjs down by three, class-b.mjs down by one or more), and v1.12.9, v1.12.10 and v1.12.11 add none and move the apply.mjs and tailings.mjs sites by line shift only (v1.12.11 swaps one RA2 hit in apply.mjs on one comment for a hit on another comment whose text is unchanged since v1.12.7, both comments); v1.12.12 adds one, an RA2 on a comment of keeps.mjs ("distinguish the write failed from the write succeeded but the anchor") whose text is unchanged since v1.12.7 and that earlier dists did not flag, a scanner chunking effect, and moves the apply.mjs sites by line shift, 56 findings; keeps.mjs also gains the obfuscated-instruction-text coverage exception that apply.mjs, estate-archive.mjs and fidelity-gate.mjs carry, and its hidden-character census is unchanged (one U+200B and one U+2800, both in text older than v1.12.7), the file read by hand; v1.12.13 adds none and moves none, the same 56 findings by rule, file, source text and line as v1.12.12 (documentation only). 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.13 is a 32-file dist of 16,300 lines against the 1,529 lines of the 16-file one (line counts of the extracted plugin/ at the v1.12.13 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.

Structural Safety

  • 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's rot-canary uses) · 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.mjs executes 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; a pinned: true file 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 report partial and 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 ~/.claude and fails closed to ~/.claude/coal/coalwash/ on any escape, while the fixed global stamp path is constructed directly under ~/.claude with 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-check deletes 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. Since v1.12.11 a run whose lock went stale (LOCK_STALE_MS, 30 minutes) and was taken over re-reads its lock before every write step and, if robbed, stops with a lock stolen refusal, with no rollback (the wash transaction, RE-TIER and the ULTRA estate run all do this). Residuals: a theft between the lock read and the write it guards still goes through; the new holder overwrites the robbed run's single journal, after which that run's record lives only in its refusal and its snapshot (kept for the newest 3), and the user compares by hand; a lock file another program holds open (an indexer, antivirus or sync client) is re-read for about one second; one still unreadable stops the run with a lock unreadable refusal, never a theft, and the next gauge's dangling-transaction recovery rolls the store back from the snapshot, which undoes the wash even when every step had landed; a release that cannot read or remove the lock leaves it in place and names it (lockLeft), and a later run defers on it until it goes stale (30 minutes). Since v1.12.12 the keeps stores (the project's keeps.json and the global one) are updated under a short lock of their own (<store>.lock, 5 s stale window, separate from the wash lock), so two keep records made at the same moment no longer drop one another; a recorder that cannot take it, loses it before its rename, or whose rename another program's open handle refuses for longer than about 0.45 s, writes nothing and returns a refusal (naming the lock, or for a refused rename the store and the error code) (the keep is lost until its caller records it again; a store that is read-only or a directory, and a lock error that waiting cannot pass, refuse at once and say what they found, to be fixed by a human, not by recording again; a lock create refused EPERM, EACCES or EBUSY is retried for 40 attempts and then asks the caller to record again and check the folder; a theft between the lock read and the rename still goes through). Two residuals remain: the self-ignore: check inside a project keep record is not retried, so an EPERM on its first .gitignore create, or on the rename that replaces a catch-all that cannot be read, refuses at once with self-ignore: ... and writes nothing; and because that check runs twice in one call (in the folder step, then inside the lock), a pass followed by a fail reads as a permanent lock refusal (could not be taken (self-ignore: ...), which says waiting will not change it) for a cause that can pass. Honest ceiling: fsync is not stronger than the drive's write cache; the snapshot is the last backstop.
  • House secret scan. .githooks/pre-commit and .githooks/pre-push run scripts/secret-gate.mjs first (the tracked tree on commit, the added lines of every pushed commit on push), before verify.mjs and the suite, and fail closed.
  • Untrusted config is parse-guarded. The .coalwash.json JSONC parse drops __proto__ / constructor / prototype keys; 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.

The class-A engine (not in the plugin)

The class-A engine (explode, detonate) is in the repository under scripts/lib/ and is not in the installed plugin, so nothing below runs in an installed copy today.

F4 — a recycled path can present the manifest's identity

What. A restore's ownership check confirms the declared original against the store's own manifest by path plus device/inode. On some filesystems an inode number can be reused right after a delete (measured on Linux ext4, where it is a race, not a certainty; NTFS and APFS gave a new number in the same probe), so a path that was deleted and recreated between snapshot and restore can present the same device/inode as the file the manifest describes while being a different file entirely. This needs no store access, no manifest write and no co-tenant; ordinary single-user delete-and-recreate churn at a tracked path is enough. CoalWash does not try to tell this case apart from a legitimately modified original, because filesystem metadata alone cannot separate them.

What the engine does instead. A forced restore over a populated destination hands the destination's current bytes to a bank before the overwrite, and publishes only after the bank returns an id. The bank is the caller's: restoreFromSnapshot takes it as opts.bank, and no caller supplies one yet. With no bank, a bank that throws or returns no id, or an occupant that cannot be read, the restore refuses and leaves the destination untouched.

What that bounds. The bank holds the bytes read at check time, so the recoverability claim covers a destination that no other process is writing. A writer that changes the destination after that read loses its write unbanked, and an occupant that appears after the check is overwritten unbanked. Single-user delete-and-recreate churn, which is the threat above, is covered. The bytes stay recoverable for as long as the caller's bank keeps them.

F2 — the store trusts its tenants

What. The class-A store is trusted-tenant-only. An attacker who can read a victim's blob bytes, and who can write to a directory the restoring process can reach, can forge a manifest in a directory the attacker owns and restore the victim's content through it. The victim's store does not need to be writable by the attacker. The blob bytes are content-addressed, so holding the bytes is enough to hold a valid reference.

The second route. Read access to a genuine manifest is enough on its own. The destination a restore publishes to is the caller's choice, and the ownership check does not bind it to the manifest row it confirmed. An attacker who can read the manifest and write to a directory they own can restore the true, unmodified original to a path of their choice. Nothing is written to the store.

What the operator must do. Isolation is the operator's to arrange, not the engine's to enforce. Keep one snapshot directory per trust domain, and keep other tenants from reading it, not only from writing into it. A store mounted read-only across tenants is not safe under this boundary.

What the ownership check still buys. It stops the ordinary failure: a script that restores everything it can see in a shared store, with no attacker involved, which is the failure the lab reproduced first. It also forces a determined attacker's read of the manifest into an explicit forged claim in the code, not a silent enumeration. Both layers, the check and the isolation, hold at once, and neither replaces the other.

Why there is no code fix at this layer. Six candidate code fixes were weighed and each was rejected: a disk cross-check, a content-hash check, file owner or ACL checks, a hash-chained manifest, blob metadata, and refusing rows that share a hash. Per-tenant isolation is not a code fix at all; it is the operator answer above. Per-tenant manifests in code, the other way to close F2, are not built, and a keyed signing scheme for manifest rows was weighed and rejected as over-build for a threat that isolation already answers. What is missing is authentication, which needs a secret or a trust boundary the store does not have.

Where it applies. The engine is excluded from the installed plugin today, so this boundary binds whoever runs the engine from the repository. On the day the engine is wired into the plugin, it binds every installed copy, and each user of such a copy must arrange the isolation above.

There aren't any published security advisories