CoalHearth is a zero-dependency, hook-pair-keyed plugin — validated on Claude Code and (plugin path, headless) on Antigravity, works with 5 other platforms (Gemini CLI, Copilot CLI, Devin CLI, Kiro, Augment). Its security posture:
Report a security issue in this repo through GitHub's private vulnerability reporting — Security → Report a vulnerability — never a public issue. In scope: the shipped hooks (bin/session-start.js, bin/post-tool-use.js, bin/user-prompt-submit.js, and the Antigravity/config-only adapters), the lib/ journal + resume core, the installer and other scripts/, and the plugin/ dist built from them. Out of scope: a vulnerability in a project's own rules/config file that CoalHearth merely reads as untrusted data (a hostile AGENTS.md or .coalhearth.json) — that is the project's own security question, not this tool's. The boundary is what CoalHearth does with such a file, not what the file says: a hostile file must not make a hook hang, allocate without bound, or write or delete outside the project, or delete project files it does not own, and one that does is in scope (see the advisory below). 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.
An advisory is published only for a finding an outsider can exploit through input they control, whatever its level: a repository the user cloned, a file sent to them, a reply from the network. A path only the user feeds (their own config, a command they type) does not count. An unsure finding counts and goes to CoalBoard to decide.
CWK-137: a cloned repository could hang CoalHearth or make it write or delete outside the project, or delete project files, through a planted link (2026-10-03)
What an attacker needs. A repository you clone that carries a planted symbolic link (on Windows, a junction or symlink), FIFO or device file at a path CoalHearth reads, writes or deletes. That is the whole precondition: no other access. The hooks fire on ordinary session events in that clone; scripts/configure.mjs acts only when you run it there. No CVE id is claimed; none exists. Found by the sibling CoalMine's security review (2026-09-24) and swept into this repo.
Affected versions. Classes 1 to 4: every release from v0.1.0-beta.1 (the first tag) through v2.6.0. The plugin.json versions, the CHANGELOG headings and the tags agree (31 versions through v2.6.0, none untagged), and the per-defect first versions below come from git log -S on the unsafe call sites and the first tag that contains each commit. Fixed in: the repository, commits f678ca3, 7963da5, 3abeff9 and 27a8aae; they ship in v2.6.1, the release after v2.6.0. Class 5 (added 2026-10-04, found when the fixes after v2.6.1 were reviewed): every release from v0.1.0-beta.1 through v2.6.1, which still carries it (a check of the tag's lib/resume-engine.js in each of the 32 tags found the same root-only containment test in all of them). Fixed in: the repository, commit c6dbcf8; it ships in v2.6.2, the release after v2.6.1. Class 6 (added 2026-10-05, found when the 2.6.3 unit was reviewed): every release from v0.1.0-beta.1 through v2.6.2, 33 tags: the 28 from v0.1.0-beta.6 and the five betas before them. For the 28, the range comes from git tag --contains 51c9a04c850c (the commit that added lib/contained-dir.js with root-only containment: 28 of the 33 tags, earliest v0.1.0-beta.6, latest v2.6.2) and from reading lib/contained-dir.js and lib/handoff-journal.js in every tag: none holds the owned-folder check (isOwnedDefaultDir). v0.1.0-beta.1 to v0.1.0-beta.5 have no lib/contained-dir.js and carry the same class: each tag's own lib/handoff-journal.js, run in a fixture with a Windows junction planted at <project>/.claude/coalhearth (aimed once outside the project and once at another folder of it), wrote the journal through the link in all 10 runs (5 tags, 2 targets). In those tags the folder is path.resolve(config.outputDirectory || '.claude/coalhearth') from the raw working directory with no pin, and the project config could choose it (read in the code, not run); the disk-full prune is read in the code, not run (beta.1 to beta.3 delete every entry in that folder but the journal, beta.4 and beta.5 only error.log and *.tmp, both through the link). Fixed in: the repository, commit 3e48f0c; it ships in v2.6.3, the release after v2.6.2. The remaining gap of this class, a link swapped in during a single hook run, is narrowed by commit a58aa59 (the pin is checked again before each write, see class 6); it ships in v2.8.1, the release after v2.8.0. The read half of class 6 (added 2026-10-14, found by the class sweep after 2.8.2): every release from v0.1.0-beta.1 through v2.8.2, 38 tags, in three shapes. Measured by the code-reviewer running the shipped session-start.js of v0.1.0-beta.1, v0.1.0-beta.5, v0.1.0-beta.6, v1.0.0, v2.0.0, v2.6.0, v2.6.1, v2.6.2, v2.6.3 and v2.8.2 (each shape's two boundary tags among them), from an archive of each tag, with a journal whose goal was a marker and a link planted at <project>/.claude/coalhearth; the swap in shape (3) by running lib/read-pin.test.js against v2.6.3's and v2.8.2's code: (1) v0.1.0-beta.1 to v0.1.0-beta.5, a link aimed anywhere, outside the project included, was read (the marker reached the output); (2) v0.1.0-beta.6 to v2.6.2, a link aimed outside the project was refused by lib/contained-dir.js's real-path check, but a link aimed at another directory of the project (such as src/) was read; (3) v2.6.3 to v2.8.2, a link planted before the hook ran is refused at construction (isOwnedDefaultDir), and only a link swapped in AFTER the hook built the journal is read, because lib/handoff-journal.js and lib/resume-engine.js read with no pin (git show <tag>:lib/handoff-journal.js holds _pinned() 0 times in v2.6.3, v2.7.0 and v2.8.0, and 4 and 5 times in v2.8.1 and v2.8.2, all on writes and deletes). The earlier ranges are the same class 6 as the write half above. No tag contains the fix (git for-each-ref --contains ea447d2 refs/tags lists none of the 38 tags). Fixed in: the repository, commit ea447d2; it ships in v2.8.3, the release after v2.8.2. Class 7 (added 2026-10-16): every release from v2.2.0 through v2.8.4 for the config read, and from v2.5.0 for scripts/configure.mjs. The range is read in the code of the tags, not run: v2.1.2 read the project config only from a file at the project root, with no folder above it; v2.2.0, the first tag whose lib/load-config.js holds the agent-folder candidates, reads it with a plain fs.readFileSync, as v2.6.0 does; scripts/configure.mjs first exists at v2.5.0 and reads and writes plainly there. Through v2.6.0 a link at those folders was followed wherever it pointed, outside the project included (classes 1 and 4 cover the link that leads out of it). From v2.6.1 a link out of the project is refused and a link that stays inside it is honoured: git for-each-ref --contains f678ca3 refs/tags lists exactly nine tags, v2.6.1 to v2.8.4 (f678ca3 added repoEntryInfo, the contained reader in lib/repo-fs.js, which is the first code here that honours a directory link staying inside the project), and in each of the nine lib/repo-fs.js holds no folderChain and the write guard scripts/lib/repo-fs.mjs has only a real-path containment. The behaviour itself was run on one version: the fix red run on the v2.8.4 line (commit c7085c2), on Windows 11 with Node 24.19, with a junction at .claude and one at .claude/coal, each aimed at another folder of the project. Fixed in: the repository, commit e418b42; it ships in v2.8.5, the release after v2.8.4. Until you have updated, do not run a CoalHearth hook or scripts/configure.mjs in a clone you do not trust.
| What happens | The SessionStart and PostToolUse hooks read the project config, the global config, the journal, AGENTS.md, task.md and the update stamp with a plain read. A file over any size was read whole, and a link at the project config that led out of the project was followed. A link to /dev/zero at one of these paths allocates until the process dies, and a FIFO blocks the hook. Measured on Windows (the fix's red runs): a config one byte over 1 MiB was read whole, a junction out of the project was followed, and an over-size journal, AGENTS.md, task.md and update stamp were read whole. Not measured by us: the FIFO and /dev/zero cases. Their tests skip on Windows and run on the Linux and macOS CI legs; on the old code they follow from the plain read. Nothing is disclosed and no file is changed: the hook dies, hangs or exhausts memory. |
| First affected | The project config, AGENTS.md, task.md and the journal read at resume: v0.1.0-beta.1. The update stamp: v0.1.0-beta.2. The journal read in lib/handoff-journal.js: v0.1.0-beta.6. The global config also had no kind check at all. |
| What happens | The journal is saved by writing a temp file next to it and renaming it into place. A link planted at the temp name was followed: the journal's bytes landed in the link target, outside the project. The bytes are the journal's own JSON (goal, checklist and constraint text, modified-file paths, a transcript path), not attacker-chosen text. The same write was used for the corrupt-journal quarantine and the resumed marker. Measured on Windows with a hard link at the temp name (the fix's red run). |
| First affected | v0.1.0-beta.1, where the temp name was the fixed session_handoff.json.tmp. From v2.0.0 the name carries the writer's process id, so a planter has to guess it. |
| What happens | When a session starts in a subdirectory of a project, CoalHearth removes a stray .claude/coalhearth directory its older versions may have left under that subdirectory. A junction or symlink at that path, or at .claude above it, made the removal land in the link target: it deleted files named session_handoff* and .gitignore there, then tried to remove the directory (which fails if anything else remains). It never deleted a file with any other name. Measured on Windows with a junction at the leaf and with .claude itself linked. |
| First affected | v2.1.1. |
| What happens | With a link at the project config path, scripts/configure.mjs treated what it found as an existing config, backed it up to <file>.bak and overwrote it. A hard link at the config path or at the .bak path, or a directory link at .claude that led out of the project, made those writes land outside the project. It also read an existing config of any size. Measured on Windows (the fix's red runs). |
| First affected | v2.5.0, the first tag that contains the script. It is a repo script, not part of the plugin dist, and acts only when you run it. |
| What happens | On every resume, CoalHearth removes its own stale scratch files and worker worktrees: files named probe_* or __probe_* (.mjs, .js, .cjs) in .claude/coalhearth/scratch, and directories named ch-worker-*, with their contents, in .claude/coalhearth/worktrees (and the .agents/coalhearth twins). The sweep checked only that the directory lay under the project root. A link planted at one of those directories and aimed at another directory of the project passed that check, so every resume deleted matching files and directories there: for example a probe_*.mjs script of your own. The name patterns still held, so no file with another name was touched. From v0.1.0-beta.3 the check resolved real paths, so a link aimed outside the project was already refused and only a link aimed inside it got through; in v0.1.0-beta.1 and v0.1.0-beta.2 the check was lexical, so a link aimed outside the project was followed as well. Measured on Windows with a junction at the scratch and worktrees directories and at the .claude/coalhearth parent (the fix's red runs). |
| First affected | v0.1.0-beta.1, the first tag that contains the sweep. |
| What happens | The journal lives in <project root>/.claude/coalhearth/. The directory check only asked that its real path lie inside the project root. A link planted at .claude/coalhearth (or at .claude above it) and aimed at another directory of the project passed that check, so the journal's session state (goal, checklist and constraint text, modified-file paths, a transcript path: the journal's own JSON) landed in that directory, without the .gitignore the real folder gets, and, when the disk was full, the prune of old logs deleted the files named error.log or ending in .tmp there: for example a build.tmp of your own. From v0.1.0-beta.6 a link aimed outside the project was already refused by the helper's real-path check; in v0.1.0-beta.1 to v0.1.0-beta.5, which have no helper, it was followed (probed, 10 of 10 runs, see the affected versions above). Measured on Windows with a junction at the default folder (the reviewer's witnesses and the fix's red runs). |
| First affected | v0.1.0-beta.1, the first tag (probed). From v0.1.0-beta.6, the first tag that contains lib/contained-dir.js, a link aimed outside the project is refused and only a link aimed inside it gets through. |
7. A link at a folder above the project config redirected the config read and the configure.mjs write, inside the project
| What happens | The project config is read from <project>/.claude/coal/coalhearth.json (then the .agents and .gemini twins). The reader asked only that the file's real path lie inside the project, and looked at the file itself, never at the folders above it. A directory link (a Windows junction or a POSIX symlink) planted at .claude, at .claude/coal, or at the same folders under .agents or .gemini, and aimed at another folder of the project, passed that check: loadConfig read the coalhearth.json it found there, whoever wrote it, and scripts/configure.mjs wrote the config and its .bak into that folder (exit 0). A link aimed outside the project was already refused (classes 1 and 4). Measured on Windows (the fix's red run on c7085c2): a marker language value placed in the other folder arrived through both link placements, and configure.mjs --language th landed its bytes in the other folder. |
| First affected | v2.2.0 for the read, the first tag that looks for the project config in an agent folder (.claude/coal/, .agents/coal/, .gemini/coal/; v2.1.2 read only a file at the project root, with no folder above it), and v2.5.0 for configure.mjs, the first tag that contains it: every tag from those through v2.8.4 follows a link at a folder above the project config. Through v2.6.0 the read and the write followed any link (classes 1 and 4 cover the link that leads out of the project); from v2.6.1 the contained reader refuses a link out of the project but still honours a directory link that stays inside it (read in each tag's code). The script is a repo script, not part of the plugin dist, and acts only when you run it; the read runs in the hooks. |
What the fix does. Every read of a repo-derived path now goes through one reader (lib/repo-fs.js). It checks the kind first: a regular file proceeds, and a symlink proceeds only when its target is a regular file in the entry's OWN folder inside the project (since v2.8.6; from v2.6.1 to v2.8.5 any target inside the project was accepted). A FIFO, device, socket, directory, or a link that escapes or dangles is skipped before it is opened. The open is non-blocking, the descriptor is re-checked, and a file over its bound is skipped, never truncated: configs 1 MiB, the journal and AGENTS.md/task.md 4 MiB, the update stamp 1 KiB. The global config and the stamp, which dotfile managers legitimately link, get the kind check and the bound but no containment. The journal temp is created exclusive (wx), so a link at its name is replaced, never written through. The clean-up deletes only when the directory's real path is the path it built, so a link anywhere in it leaves everything untouched. The orphan sweep applies the same pin: it deletes in an owned directory only when that directory's real path equals its literal location under the project root, and otherwise sweeps nothing there. For class 6, each time a hook builds the journal (every tool call) the default journal folder is refused unless its real path is exactly <project root>/.claude/coalhearth (a link at it, or at .claude above it, leaves the journal with no directory that session), and the disk-full prune deletes only inside the project root's own .claude/coalhearth, never in a same-named folder elsewhere and whatever journal.outputDirectory says. Not covered, named: a journal.outputDirectory set in the global config (the only place that reads it since 2.7.0, so a cloned repository can no longer choose it) is still contained to the project root only and the journal still writes there; and a link swapped in at the folder in the instant between the check and the write itself can still redirect that one write, because a check followed by a write is not one atomic step; the next hook run refuses the link, and the prune stays safe. A write that lands in that instant, between a pin answer and the temp write, leaves its pid-named temp file in the link's target: the next pin answer refuses the rename, and the temp is not removed (for save() it holds the journal's own JSON, and the folder has no .gitignore). Two removals ask nothing before they act: the pid-named temp file removed in the failure path of the atomic write in lib/handoff-journal.js, and the one removed in lib/repo-fs.js after an exclusive temp create. Each removes only the temp this process just wrote, by its pid-unique name, never recursively and never a name it did not create; a link swapped in at the folder would aim that removal at a same-named file in the link's target, which exists only if this process wrote it there. The mkdir at construction runs right after its own check and cannot ask a folder that does not exist yet. Since v2.8.1 the folder is checked again before each write, not only when the journal is built: before every attempt to save the journal, before creating (at every poll of a held lock), stealing or removing its lock file, before the quarantined copy of a corrupt journal, and before the resume engine's mark-resumed. Since v2.8.2 it is asked again before every delete and every second write: before the quarantine removes the original journal (and only after a copy that succeeded: a copy that fails for another reason, such as a full disk, now leaves the corrupt journal where it is, and v2.8.1 removed it even then), before each unlink and the rmdir of the legacy-phantom clean-up, before each scratch file and each worktree the orphan sweep deletes, before the rename into the journal name (in save() and in the shared atomic writer), and, at construction, the folder is resolved again after it is made and before the self-ignore .gitignore is written. A changed answer stops that act and fails closed: nothing is written or deleted through the swapped folder. In v2.8.1 a link swapped in between the quarantine's copy and its delete could still remove the journal in the link's target, because one check covered both acts. Since v2.8.3 it is asked again before each of the journal's three reads (HandoffJournal.load, HandoffJournal._loadOrQuarantine and ResumeEngine.detectAbortedSession): until then those reads took the folder itself as their containment root, so a link swapped in at .claude/coalhearth (or at .claude above it) after the hook built the journal, aimed anywhere, made that root the link's target and the file behind the link was read, and detectAbortedSession handed what it read to the block the SessionStart hook prints into the model's context. A refused read returns nothing, keeps the caller's state, and quarantines or marks nothing, so nothing from behind the swapped link reaches that block. Not covered, named: the instant between a read's pin answer and the open of the file, the same window the writes name. Measured on Windows 11 with Node 24.19, with a junction swapped in after the journal was built (aimed outside the project, at another directory of it, at a custom output folder, and inside a spawned SessionStart hook): 4 of the 15 tests in lib/read-pin.test.js failed before the fix, and after it 11 pass and 4 skip. The four skipped legs use file symlinks, which this volume does not permit without a privilege, so they and the POSIX runs are owed to the CI legs. The default folder is checked against its literal location, and a custom folder from the global config against the physical path it had when built, so a link swapped in at any earlier point of a hook run is refused. Measured on Windows with a junction swapped in after the journal was built, for each act named above (the fixes' red runs; a volume that refuses directory links skips those legs). For class 7, since v2.8.5, every folder between the project root and a project config file (.claude, .claude/coal, and the .agents and .gemini twins) is examined with lstat, and a link at any of them is refused wherever it points, a target inside the project included: the read (loadConfig and the notices) and the write (scripts/configure.mjs, which until then wrote the config and its .bak through such a link) both ask it, a folder that does not exist yet ends the check as plain, and a folder that cannot be examined is refused. The pin is by lstat per folder, never a comparison of a real path with a hand-joined string. Measured on Windows 11 with Node 24.19 with a junction at .claude and at .claude/coal: 6 of the 13 tests in lib/folder-pin.test.js and 6 of the 8 in scripts/folder-pin.test.mjs failed before the fix. The POSIX symlink legs are owed to the CI legs (a capability-probed skip here). Since v2.8.6 the entry itself is held to the same literal-location idea (CWK2-026, the entry-level half of the same class): under a project root, a file symlink at a project entry (the project config, the journal file, AGENTS.md, task.md) is honoured only when its target's real path lies in the entry's own folder, with both sides resolved through fs.realpathSync.native; a link to a file in another folder of the project is refused wherever it points, and a chain is judged by its final target. Measured on Windows 11 with Node 24.19 with an injected link: the two cases that point at another folder of the project failed before the fix (the link read as a file) and pass after it; the real file-symlink twins skip on that volume and are owed to the CI legs. configure.mjs refuses (exit 1, nothing written) when the existing config is over 1 MiB, outside the project through a link, behind a link at a folder above it, or a special file, and writes the config and its .bak through an exclusive temp renamed into place. The journal this tool writes stays under its 4 MiB bound: past about 4 MiB the writer drops the oldest recorded paths, counts what it dropped, and the recovery block says how many earlier paths are not listed (if the goal and checklist alone exceed the bound, nothing is written and the last good journal stays on disk). A journal over the bound that this tool did not write is not read, and the next recorded step replaces it with a fresh one. Not covered, named: a regular file swapped in between the check and the open may lie outside the project (the descriptor re-check still holds the read to a bounded regular file); a file link at an entry inside the project is honored since v2.8.6 only when its target is in the entry's own folder (AGENTS.md to CLAUDE.md at the root, coalhearth.json to other.json beside it); from v2.6.1 to v2.8.5 any target inside the project was honored, and scripts/configure.mjs replaces such a link when it writes, never writing through it, and the project root itself is not walked, so a project opened through a linked path still reads its own files; a link at a FOLDER between the project root and a project config file is not honored since v2.8.5, and the journal folder is pinned on its own since v2.8.3 (see above), so a link to another directory inside the project is honored for neither; the config write of scripts/configure.mjs is a check followed by a write, not one atomic step: writeRepoFile (scripts/lib/repo-fs.mjs) runs checkRepoWriteTarget (the folder walk with lstat and the real-path containment) and only afterwards makes the missing folders and writes a temp that it renames into place, so a local process that swaps a just-checked folder for a link (a junction on Windows) in between can land the config file, or its .bak, outside the project; the pre-check narrows that window and does not close it, and a link planted ahead of time is refused (class 7); the config READ has the same two steps (repoEntryInfo in lib/repo-fs.js walks the folders and refuses a link, and readRepoBytes opens the file afterwards), so a link swapped in between can be read, and the descriptor re-check only holds that read to a bounded regular file; both are reasoning from the code, not a measured result in this room; configure.mjs --global keeps its follow-through write to ~/.claude/.coalhearth.json, because dotfile managers link that file, and its read is bounded. The maintainer and CI scripts (scripts/verify.mjs, scripts/build-plugin.mjs, scripts/lib/link-check.mjs, scripts/test.mjs) read this repository's own tracked tree without that bound; no hook and no end-user path runs them.
What to check if you ran CoalHearth in a clone you did not write.
- A stray
.bakbeside a config:<project>/.claude/coal/coalhearth.json.bak,<project>/.claude/.coalhearth.json.bakor<project>/.coalhearth.json.bak. Its contents are the file the config path was linked to, not a config. Delete it and do not commit it. - A file that is not yours with the journal's JSON in it (goal and checklist text, file paths). The journal lives at
<project>/.claude/coalhearth/session_handoff.json; look forsession_handoff.json.tmporsession_handoff.json.<number>.tmplinks there, and check any path they point to. - Missing
session_handoff*or.gitignorefiles in a directory outside the project that a link in the clone pointed at. - Missing
probe_*scripts, or missingch-worker-*directories, in a project directory that a link at.claude/coalhearth/scratchor.claude/coalhearth/worktrees(or the.agents/coalhearthtwins) pointed at. Look for such a link with the command below; restore what is gone from version control. - A
session_handoff.jsonorsession_handoff.json.*.tmpthat is not yours in a project directory such assrc/that a link at.claude/coalhearthpointed at, and missing*.tmporerror.logfiles there. Look for the link with the command below. - A
coalhearth.jsonor.bakin a project folder other than the one you expect, which a link at.claudeor.claude/coal(or the.agentsand.geminitwins) pointed at, and a config value you did not set that came from there. The command below finds the link. - Links in a clone you already ran a tool in:
find . -not -path './.git/*' \( -type l -o -type p \)(POSIX) orGet-ChildItem -Recurse -Force | Where-Object { $_.Attributes -band 'ReparsePoint' }(PowerShell). A link at a path CoalHearth reads or writes is the tell.
Every release tag and maintainer commit is SSH-signed (gpg.format=ssh); GitHub shows the Verified badge on them. Automated Dependabot / CI commits are not signed with the maintainer key (GitHub signs these with its own), so verify a signed release tag — the artifact a release consumer trusts:
echo "* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEtqTWGKhX1Dk9nZP8ns13Wl5zsO1Cz3VlTS6m1p2fP9" > coalhearth_signers
git config gpg.ssh.allowedSignersFile ./coalhearth_signers
git tag -v "$(git describe --tags --abbrev=0)"plugin/is generated, never hand-edited — it's the Claude Code distribution the marketplace serves.scripts/verify.mjscompares dist against source in both directions — byte-exact, with a CRLF/LF-normalized fallback on text files only (so a checkout's line-ending doesn't false-flag byte-identical content) — a stale dist and a dist-only orphan both fail the gate.scripts/build-plugin.mjsreproducesplugin/from source;scripts/test.mjsruns the zero-dependency suite — a cloner reproduces the exact shipped bytes withnode scripts/build-plugin.mjs && node scripts/verify.mjs.
Last scan: CoalHearth v2.8.7 dist (plugin/, commit ba1d387, the v2.8.7 tag), on 2026-10-29, with NVIDIA SkillSpector v2.12.0 (self-reported version string; scan pinned to commit d501c0d, upstream's v2.13.2 per the deputy's courier), static stage (--no-llm). Score 100/100 (CRITICAL), 11 findings, all false positives on adjudication. The one finding v2.8.1 added, AS1 (Agent Config Directory Access) at commands/update.md:11, is gone: it matched a past-tense sentence in the Antigravity paragraph of commands/update.md that named the folder agy plugin install writes the plugin to (read true-reading-benign, no instruction, nothing for the agent to read), and v2.8.2 re-spelled it to "agy's plugins folder", a non-config spelling. The CRITICAL rating rests on a single TM1 match of the string git clean -fdX—which appears only inside a source comment recording a path-containment bug that was found and fixed on that file; the plugin contains no command-execution primitive of any kind, so no such command can run from it. The rest: RA1 ×7 on the word "self-update" (comments, the /coalhearth:update OFFER directive, and a note that the nudge is deliberately NOT ported to the Antigravity path), EA2 ×2 matching "no consent" inside a sentence stating that no consent-persistence call exists, and BH1 ×1, the scanner's notice that hooks/hooks.json registers lifecycle hooks. Static coverage was partial (15 of 21 files fully inspected): five JavaScript files (bin/ag-pre-invocation.js, bin/user-prompt-submit.js, lib/handoff-journal.js, lib/journal-step.js, lib/resume-engine.js) reached the scanner's parser span limit and hooks/hooks.json (Claude Code's) is opaque to it; lib/contained-dir.js was fully inspected, and so were the two files v2.8.0 added, the dist-root plugin.json and hooks.json (Antigravity's). Those six were read by hand at v2.5.0 (including the journal's contained-sweep logic); the v2.5.0 to v2.6.0 change was read in full, and so was the v2.6.0 to v2.6.2 change: the sweepOrphans delete is pinned to the owned directory by realpath (a link planted at scratch or worktrees makes it sweep nothing), the legacy-phantom clean-up leaves a link alone, and file reads go through a bounded reader (lib/repo-fs.js, new, and lib/journal-cap.js, new, both fully inspected by the scanner). The v2.6.2 to v2.6.3 change, a security fix (class 6 above), was read in full by hand, including the scanner-limited lib/handoff-journal.js: the default journal folder is pinned to its literal realpath (a link planted at .claude/coalhearth, or at .claude above it, yields no journal), and the out-of-space prune deletes only inside that owned folder, anchored to the project root, so a custom journal.outputDirectory prunes nothing; it adds no file, no export that writes or deletes, and no fs call (the dist's write and delete primitives are the same set as v2.6.2's), and the dist still holds no process-spawn, network or eval primitive. The v2.6.3 to v2.7.0 change was read in full by hand, lib/handoff-journal.js included: journal.outputDirectory is now read from the GLOBAL config only (lib/load-config.js drops a project value and keeps the global value or the default; the one new notice names the project file and the global path, never the ignored value), which narrows what a cloned repository can choose; the rest is comments in contained-dir.js and handoff-journal.js. The v2.7.0 to v2.8.0 change was read in full: the version string and two new files at the dist root, plugin.json (the plugin name) and hooks.json, which wires Antigravity's PreInvocation to node bin/ag-pre-invocation.js PreInvocation and PostToolUse to node bin/ag-post-tool-use.js PostToolUse with a 10-second timeout each. Both adapters are unchanged since v2.6.2 (read by hand at v2.5.0), the two new files are byte-equal to the repository-root copies, and the command path is relative, so it resolves against the working directory Antigravity gives a hook; the room measured both hooks firing from a plugin folder copied to .agents/plugins/coalhearth (one headless run), which is consistent with that directory being the plugin folder, and this scan does not establish it. The dist's write, delete, spawn, network and eval primitive census is identical between v2.6.3, v2.7.0 and v2.8.0. The v2.8.0 to v2.8.1 change, five dist files (the version string, commands/update.md, lib/contained-dir.js, lib/handoff-journal.js, lib/resume-engine.js), was read in full by hand: it adds a pin check asked before every journal write, lock create, steal, release and quarantine copy, and no write, delete, spawn, network, eval or import site; the primitive census is identical to v2.8.0's except one more https:// string, the install URL in commands/update.md. The v2.8.1 to v2.8.2 change, five dist files (the version string, commands/update.md, lib/contained-dir.js, lib/handoff-journal.js, lib/resume-engine.js), was read in full by hand: the one-sentence re-spelling above, and the pin asked again at the act before each legacy-phantom unlink and rmdir, each journal rename, the quarantine delete (only after the copy succeeded) and each scratch-file and worktree delete, all narrowing; no write, delete, spawn, network, eval or import site was added, and the primitive census is identical except one more occurrence of the word rmSync, in a new comment. The v2.8.2 to v2.8.3 change, three dist files (the version string, lib/handoff-journal.js and lib/resume-engine.js, 8 lines added and 1 changed), was read in full by hand: the pin check that every write and delete already asked is now asked before each of the three journal reads (HandoffJournal.load, its second read in _loadOrQuarantine, and ResumeEngine.detectAbortedSession), and a refused read returns nothing and quarantines nothing; no write, delete, spawn, network, eval or import site was added, and the primitive census is identical (the pin-check call count rises from 6 to 9). The check runs before the read, so a short window between the check and the read remains, as for the writes. The v2.8.3 to v2.8.4 change, two dist files (the version string and hooks/hooks.json, 4 lines added and 4 changed), was read in full by hand: the three Claude Code hook entries (SessionStart, PostToolUse and UserPromptSubmit, the same three scripts bin/session-start.js, bin/post-tool-use.js and bin/user-prompt-submit.js) moved from one shell-string command that quoted the script path to the exec form, node as the command and the script path in args; whether the host accepts args on a command hook, and how it resolves the plugin-root variable there, this scan does not establish. The Antigravity manifest at the dist root, hooks.json, did not change, no script or other file changed, no write, delete, spawn, network, eval or import site was added, and the primitive census is identical. The v2.8.4 to v2.8.5 change, two dist files (the version string and lib/repo-fs.js, 25 lines added and 2 removed), was read in full by hand: a new folderChain(p, root) walks every component between the project root and the entry's own folder with lstat and refuses the read when any of them is a link, a Windows junction included (Node reports one as a symbolic link), wherever it points, a target inside the project too; the root itself is not walked, a path that is not under the root or a component that cannot be examined is refused, and a missing component ends the walk as plain; repoEntryInfo asks it before it judges the entry's real path, so a link planted at a folder above a plain config file no longer lets that file be read; the entry-level rule at that step (a file symlink is honoured when its real path stays inside the root) was unchanged, and v2.8.6 tightens it, below; the code names the residual that the check and the later open are two steps. It adds one export, no write, delete, spawn, network, eval or import site, the primitive census is identical except one more link-check call, and the five scanner-limited files did not change. The v2.8.5 to v2.8.6 change, two dist files (the version string and lib/repo-fs.js, 15 lines added and 7 removed), was read in full by hand: it tightens the entry-level link rule. repoEntryInfo, under a project root, now honours a file symlink at the entry only when its target's real path lies in the entry's OWN folder (the folder of the real path equals the real path of the entry's folder, both through realpathSync.native); every other link is refused wherever it points, a file in another folder of the same project included, where v2.8.5 allowed it. The common same-folder link (a root AGENTS.md pointing at CLAUDE.md) is kept, a plain file is unaffected, a null root (the user's own home files) follows links as before, and the write still replaces a link and never writes through it. It narrows (one more refusal and one more realpath call; no write, delete, spawn, network, eval or import word moved outside comments). The v2.8.6 to v2.8.7 change was not read by hand in this record; the 2.8.7 scan's eleven findings sit on the same file, line and text as the 2.8.6 scan file's eleven. The score is not comparable to the 53 recorded at v2.3.9: the previous dist (v1.0.0) re-scanned with v2.11.2 scores 68, and the v2.5.0 and v2.6.0 dists re-scanned with v2.12.0 also score 100 with the same 12 findings, as do v2.6.2, v2.6.3, v2.7.0 and v2.8.0 on the pin; v2.8.1 scores 100 with those 12 plus AS1, and v2.8.2, v2.8.3, v2.8.4, v2.8.5 and v2.8.6 score 100 with the 12 and no AS1. The 2.8.6 scan file (skillspector-20261024.json) holds 11 issues (RA1 7, TM1 1, EA2 2, BH1 1) and no RA2, although the v2.8.6 score sentence above says 12 findings; its own revision (metadata.llm_provenance.analyzers[*].analyzer_revision.source_revision.value) is d7338f58200a9ae2a24810cee08911ab68b107a7, while the 2.8.6 record named 2226747. v2.8.7 scores 100 with the same 11.
- Method:
uvx --from git+https://github.com/NVIDIA/skillspector.git skillspector scan <plugin> --format json—uvx fetches its own ephemeral Python, so no manual Python/pip install is needed; a JSON report is written even when the optional LLM stage is skipped. - LLM Semantic Scan: not run this pass (
--no-llm—static-only is the documented, FP-prone baseline: pattern-match without the skill-contract context).
- Zero dependencies — no third-party packages, so no dependency-CVE surface (nothing to
npm audit; the lockfile-scan step other projects need is N/A here). - No network — the engine is entirely local filesystem; it never makes a network request.
- Node builtins only —
fs,path,os. No child processes — the hooks spawn nothing (the earlier best-effortgit statusspawn was removed; modified files come from the tool-call payloads the hook observes).
- The
SessionStart,PostToolUse, andUserPromptSubmit(Claude Code only,bin/user-prompt-submit.js, issue #13) hooks — and their multi-platform adapters (bin/ag-pre-invocation.js,bin/ag-post-tool-use.js, thin shims over the same shared core, argv-switched per platform: Antigravity / Gemini CLI / Copilot CLI / Devin CLI / Kiro / Augment) — are fail-silent: all logic is wrapped in try/catch, they exit 0 on every path, and they never crash the host agent. - The only output is the sanctioned context injection (the recovery block — plain stdout on Claude Code and the CC-shaped file-copy platforms, one
{"injectSteps":[{"ephemeralMessage":...}]}JSON line on Antigravity (run from the native plugin'shooks.json:node bin/ag-pre-invocation.jsandnode bin/ag-post-tool-use.js, the same adapters, each with a 10-second timeout, adding no new capability), one nested{"hookSpecificOutput":{"additionalContext"}}line on Gemini CLI); nothing else is written to stdout/stderr.
- Path-contained orphan sweep. The resume-time cleanup removes only known scratch/worktree name-patterns, only inside CoalHearth-owned dirs (
.claude/coalhearth/scratch,.agents/coalhearth/scratch, and CoalHearth-owned stale worktrees), and each owned directory must resolve to exactly its literal location under the workspace root, so a link planted at one of them makes the sweep skip it. It never touches the user's own tree (e.g. yourscripts/) and never does a blind recursive delete. - Contained journal directory.
journal.outputDirectory(since 2.7.0 read from the global config only; a project.coalhearth.jsonvalue is ignored) is realpath-contained under the workspace root at construction — a path escaping the workspace (e.g."../../victim") clamps to the default owned dir, so neither the journal write, the ENOSPC prune, nor the corrupt-quarantine can be aimed outside. The helper iscontainedOutputDirinlib/contained-dir.js: it resolves the project root and the candidate withfs.realpathSync.native(through the nearest existing directory), skips a candidate whose real path lies outside the root, and returns nothing when none passes. For the default.claude/coalhearth/it is stricter: the folder is refused, each time a hook builds the journal (every tool call), unless its real path is exactly<project root>/.claude/coalhearth, so a link planted at it, or at.claudeabove it, leaves the journal with no directory that session; only the default folder also gets its own self-ignoring.gitignore. Ajournal.outputDirectoryset in the global config is contained to the project root only, not pinned to that folder, and the journal still writes there. The ENOSPC prune (HandoffJournal._pruneOldLogs) deletes onlyerror.logand*.tmpfiles, and only inside the project root's own.claude/coalhearth(isOwnedDefaultDir, checked against the project root, so not a same-named folder elsewhere), whateveroutputDirectoryis. The orphan sweep (ResumeEngine.sweepOrphans) does not go through it: it has its own check and deletes inscratchandworktreesonly when each directory's real path equals its literal location. - Bounded, contained reads. Every file a cloned repository can plant (the project and global config, the journal,
AGENTS.md,task.md, the update stamp) is read through one reader (lib/repo-fs.js): a FIFO, device, socket, or a link that escapes or dangles is refused before it is opened, a file over its bound (configs 1 MiB, the journal andAGENTS.md/task.md4 MiB, the stamp 1 KiB) is skipped and never truncated, and a project file reached through a link must be a file in its own folder inside the project (since v2.8.6; before it, anywhere inside the project); a link at any folder between the project root and a project config file (.claude,.claude/coal) is refused wherever it points (since v2.8.5). The journal's three reads also ask the journal folder's pin first (since v2.8.3), so a link swapped in at the folder after the journal was built is not read. The global config and the stamp, which dotfile managers legitimately link, get the kind check and the bound without the containment. - Atomic journal writes (an exclusive temp-write + rename, with retry/backoff: a link planted at the temp name is replaced, never written through); a corrupt journal is quarantined aside rather than crashing the boot; the writer keeps the journal under the reader's 4 MiB bound by dropping its oldest recorded paths and counting them, and a journal over the bound that this tool did not write is not read and is replaced by a fresh one on the next recorded step; a disk-quota error prunes old logs and keeps the core state.
- The
.coalhearth.jsonread is bounded and contained (see "Bounded, contained reads" above).scripts/configure.mjsrefuses a config that is over 1 MiB, not a regular file, or reached through a link that leaves the project or through a link at a folder above it (since v2.8.5), or a file link at the config path whose target is outside the file's own folder (since v2.8.6), and writes the config and its.bakthrough an exclusive temp renamed into place. - The
.coalhearth.jsonparse is prototype-pollution-guarded —__proto__/constructor/prototypekeys are dropped at parse time, so an untrusted project config (e.g. one shipped by a cloned repo) cannot polluteObject.prototypethrough the config merge.
- A finite clock on every job. Every job in every workflow declares
timeout-minutes, sized from that job's own recent runs; a hung job fails instead of holding a runner for the 360-minute default. - No persisted token where nothing pushes. Every checkout sets
persist-credentials: false(no step in this repository pushes), and the Dependabot auto-merge workflow takes the pull-request URL throughenv, never interpolated into a script. - A secret scan before every commit and push.
scripts/secret-gate.mjsruns first in.githooks/pre-commit(the staged tree) and.githooks/pre-push(every pushed commit's added lines, message and tag), and fails if the scan cannot run. GitHub scans a public repository for provider tokens; this scan catches a provider-shaped token anywhere on a line (a URL or an HTTP header included), a private-key header, and a high-entropy value assigned to a name like secret, token, password or key. It does not catch a credential inside a URL or connection string (scheme://user:pass@host) unless the credential is provider-shaped, an HTTP authentication header whose value is a scheme and a token (Authorization: Bearer <key>,X-Api-Token: Bearer <key>) unless that token is provider-shaped, or a key split across lines. A hit never prints its value. Enable the hooks withgit config core.hooksPath .githooks. - The Release is created by a workflow, from the tag. The
create-releaseworkflow is the only creator of a GitHub Release: on a stablev*tag push it derives the title and body from that tag's own CHANGELOG entry and re-reads the published Release byte for byte. It holdscontents: writeon that one job only.
CoalHearth reduces the damage of a session limit-hit; it does not prevent one or guarantee recovery (the recovery block always instructs verification against git — the journal may be stale). External scan provenance is recorded in "Independent scanning" above.