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-09-25 — a cloned repo could aim CoalWash's reads, writes and deletes outside the project (CWK-137)
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. 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. The next release (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 the next release, do not run CoalWash where an untrusted local process can write inside the project. Report anything further through the private channel above.
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 v0.1.0-beta.1 dist (plugin/), on 2026-07-09 (launch day), with NVIDIA SkillSpector v2.3.11 (self-reported — the tool ships no tagged releases), static stage (--no-llm, the documented FP-prone baseline). Score 43/100 (MEDIUM), 8 findings — all adjudicated FALSE POSITIVE:
- 7 ×
RA1Self-Modification (commands/update.md×2 ·hooks/coalwash-conductor.js×3 ·scripts/lib/config-schema.mjs×2): every hit is the series' consent-gated kind-1 self-update — the hook only schedules a check via a local stamp (no network, no writes to skill files); the agent verifies online and offersclaude plugin update, which the user runs. Nothing modifies skill code or config at runtime. This is the family-wide FP baseline (the same pattern trips RA1 on every sibling). - 1 ×
AR2Anti-Refusal (skills/coalwash/references/method.md:34, confidence 0.24): the flagged phrase "definable without judgment" describes the mechanical Quick tier — operations deterministic enough to define without LLM judgment — not an instruction to suppress warnings or disclaimers.
Re-scan stays event-driven (a new SkillSpector version or a genuinely new attack surface), not per 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.
- 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.