Skip to content

Security: TheColliery/CoalLedger

SECURITY.md

Verifying CoalLedger

CoalLedger is verified under the same framework as its TheColliery siblings—Phoenix-13 hooks, reproducible builds, and event-driven independent scans. Its threat surface is deliberately small: it READS docs and reports; the only online activity is the consent-gated grounding fetch your agent runs in the semantic Full tier—the hook and the engine never network.

Reporting a Vulnerability

Report a security issue in this repo through GitHub's private vulnerability reporting—Security → Report a vulnerability—never a public issue. In scope: the 6+1 canary skills, the shipped hooks (the Claude Code SessionStart conductor, PostToolUse docs tracker, and Stop docs-drift reminder, plus the Antigravity conductor adapter), scripts/—including the vendored CommonMark+GFM AST engine under scripts/lib/ and the scripts/configure.mjs config CLI—commands/, and the plugin/ dist built from them. Out of scope: a vulnerability in a third-party doc, repo, or codebase a canary merely scans—report that to its own maintainer. 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.

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" > coalledger_signers
git config gpg.ssh.allowedSignersFile ./coalledger_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, skill frontmatter, version pins, the engine's anti-cry-wolf fixtures, and a census that checks each git spawn under scripts/ takes its environment from the shared gitEnv() helper or from an explicit allowlist of named keys, rather than the process's own (an unguarded spawn can poison an unrelated repository via inherited GIT_* variables; the census is the org canon's token census, a lexer and a small grammar and not a JavaScript parser, so it refuses the shapes on its witness list, among them a helper re-written after its declaration and an escaped env option key, and states its own named ceiling in its header; it is not a proof that no crafted spawn escapes it, and two files stay blob-pinned because they cannot pass it); node scripts/test.mjs runs the zero-dependency suite with an explicit file list, one node --test child per file admitted by the live machine reading, and judges each by its TAP names, so a file that exits early reads as a failure, not a pass. Zero dependencies—no lockfile, nothing to npm audit.

Independent Scanning—NVIDIA SkillSpector

Last scan: CoalLedger v0.15.0-beta.4 dist (plugin/, commit dde16f0), on 2026-10-09, with the same scanner pin as below (NVIDIA SkillSpector v2.12.0, commit 2226747), static stage (--no-llm). Score 58/100 (HIGH), 13 findings, the same rule-and-file set, on the same source text, as the beta.2 scan below; this scan was run by the scan deputy and its figures are that deputy's record, not a hand read. Static coverage was partial (15 of 29 files fully inspected; the dist is one file smaller than at beta.2, because scripts/lib/git-env-census.mjs, a development gate no dist file imported, no longer ships): thirteen JavaScript/ESM files reached the scanner's parser span limit (the nine the v0.13.0-beta.1 scan listed, plus scripts/lib/config-load.mjs and the three byte-equal copies of repo-fs.mjs), hooks/hooks.json is opaque to it, and one skills subdirectory hit its runtime limit (a timing artifact that moves between runs). The v0.15.0-beta.2 scan, read in the detail below, is the last one read by hand: its figures are history and are not rewritten. The v0.15.0-beta.2 scan: CoalLedger v0.15.0-beta.2 dist (plugin/, commit 084796e), on 2026-10-05, 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 58/100 (HIGH), 13 findings, all adjudicated FALSE POSITIVE: RA1 ×11 matching the string "self-update" across both conductors' comments, the claude plugin update OFFER directive, the Antigravity adapter's note that the nudge is NOT ported there, and the commands/ and config-schema text (the hook only SCHEDULES a check via a local stamp, no network; the agent verifies online and OFFERS claude plugin update, which the user runs); EA2 ×1 matching "no consent" in a config-key table row classifying the keys that carry no consent, spend or outward axis; BH1 ×1, the scanner's own notice that hooks/hooks.json registers lifecycle hooks. Static coverage was partial (15 of 30 files fully inspected): fourteen JavaScript/ESM files reached the scanner's parser span limit, nine of them the files the v0.13.0-beta.1 scan already listed as limited (hooks/ag-conductor.js, hooks/coalledger-conductor.js, scripts/lib/config-schema.mjs, scripts/lib/md-ast.mjs, scripts/lib/md-checks.mjs, plugin/skills/doc-quality/lib/emdash.mjs, plugin/skills/doc-quality/lib/lang-mechanics.mjs, plugin/skills/doc-structure/lib/md-ast.mjs, plugin/skills/doc-structure/lib/md-checks.mjs) and five added since (scripts/lib/config-load.mjs, which grew by 100 lines, scripts/lib/git-env-census.mjs, and the three byte-equal copies of repo-fs.mjs in scripts/lib/, plugin/skills/doc-quality/lib/ and plugin/skills/doc-structure/lib/); one skills subdirectory also hit its runtime limit on this run (a timing artifact that moves between runs), and hooks/hooks.json is opaque to it. Of those nine, seven (all but hooks/coalledger-conductor.js and scripts/lib/config-schema.mjs) and hooks/hooks.json were read by hand at v0.12.0-beta.1, and the other two were covered by the dangerous-primitive, file-write and hidden-Unicode sweeps, not re-read line by line; the v0.12.0-beta.1 to v0.13.0-beta.1 change (one snippet-source line in lang-mechanics.mjs) was read. Four of the five added files (git-env-census.mjs and the three repo-fs.mjs) did not exist at v0.12.0-beta.1 or v0.13.0-beta.1, so none of them was read by hand then, and config-load.mjs was inspected by the scanner at v0.13.0-beta.1 and is limited now. The five, and the other dist changes since v0.13.0-beta.1, were covered by the same sweeps over all 30 files, not re-read line by line, except that repo-fs.mjs's contained writer, git-env.mjs and the v0.15.0-beta.1 to v0.15.0-beta.2 change were read in full: that change is one test-support file, scripts/lib/git-env-census.mjs, edited in comments, one blob-id map and one error message; it spawns and writes nothing, reads only the room's own scripts/**/*.mjs, and nothing in the dist imports it. The v0.14.0-beta.1 and v0.15.0-beta.1 dists were not scanned. Since v0.13.0-beta.1 the dist adds a bounded file reader and a contained file writer (repo-fs.mjs, the writer with no caller in the dist) and a helper that strips the GIT_* environment family; it still holds no process-spawn, network or eval primitive, and the 13 findings are the same rule-and-file set, on the same source text, as v0.13.0-beta.1's on the pin. The score is not comparable to the 33 recorded at v2.3.11: the previous dist (v0.1.0-beta.1) re-scanned with v2.11.2 scores 48, and the v0.12.0-beta.1 and v0.13.0-beta.1 dists re-scanned with v2.12.0 also score 58 with the same 13 findings, as does this one on the pin.

Structural Safety

  • Phoenix-13 hooks. Every hook—the three wired on Claude Code (SessionStart conductor · PostToolUse docs tracker · Stop docs memory-drift reminder) plus the Antigravity conductor adapter—is fail-silent, zero-dependency, no network, no child processes, and silent except its sanctioned context-injection channel. A headless run is safe by construction: the hooks only print and write the small local state below, never anything a user must clean up by hand.
  • What the installed skill never does: it never auto-fixes a doc (every fix sits behind a choice-gated menu and is applied by your agent with a checkpoint first), never auto-submits anything anywhere (problem reports are offered, user-reviewed, manual), and never writes outside its own footprint. (A repo checkout adds one more writer outside that footprint—scripts/configure.mjs, a config CLI that ships from no DIST_ITEM; see README.md ## Permissions for what it does.) The complete write list: the self-update throttle stamp ~/.claude/coal/coalledger/update-check; the docs-drift session state os.tmpdir()/coalledger-<session-id>.docs and .docmemmoved (a path list and a 0-byte marker, cleared as the session proceeds and OS-tmp reaped otherwise); and, on Antigravity, the once-per-session marker os.tmpdir()/coalledger/ag-conductor-*.marker. The engine scripts write nothing at all.
  • Online activity is scoped and consented. The grounding/standard canaries' real-time source verification is an AGENT action in the paid Full tier, run with your consent—the shipped code contains no network call. Offline, they degrade to ⚠️ unverified, never a guess.
  • Untrusted config is parse-guarded. The .coalledger.json JSONC parse drops __proto__ / constructor / prototype keys; every read is schema-clamped to the factory default on any invalid value.
  • Repo-derived reads and writes are bounded and symlink-safe. Every file this process reads from inside a project (the config, the docs an agent names) is kind-gated before it is ever opened—a FIFO, a device file, or a symlink that escapes the intended root is refused rather than read (a link planted at a parent folder of the project config is refused too, next bullet)—and bounded, so a pathological file cannot exhaust memory. A write (configure.mjs's config write-back) first walks the project root down to the config folder and refuses a link or junction on the way before it makes any folder, then refuses an existing non-file target, writes to a temp file first, and renames it into place, so a crash mid-write never leaves a half-written config.
  • A link planted at the project config's folder is refused (v0.15.0-beta.5). The project config (<project>/.claude/coal/coalledger.json, or the same under .agents or .gemini, the nested legacy .claude/.coalledger.json and the root dotfile) used to be checked only when the FILE was a link. A Windows junction or a POSIX symlink planted at a PARENT folder (.claude or .claude/coal) left the file looking plain, so a cloned repo could make the read follow it out of the project and merge an outside config. Now a read under a project root walks every folder between the root and the file with lstat and refuses any link there, wherever it points (a target inside the project included), and the file must resolve strictly inside its own folder; the project root itself may be reached through a link. A refused read is treated as an absent config (the unreadable notice names it) and never throws. Verified: the Windows junction cases run here, red on the old code and green after; the POSIX symlink cases are capability-probed and run on the CI legs. Residual: the check and the open are two steps, so a link swapped in between them is not caught.
  • A wrong-typed value in a project config can no longer undo your clamped setting (v0.15.0-beta.6). The config clamp judged a value's shape (an array, a string) while the reader judges its validity by the schema (a list of strings, a true/false, a known mode), so a project value of the right shape and the wrong type reached the merged config and the reader then threw the whole key back to its default, losing the user's setting. A cloned repo could use that on two keys: disabledCanaries (a number, null, an object, or a list holding a non-string such as [7] dropped the global list, so a canary the user had disabled came back) and the true/false keys docLeak and scanEverything (the string "false" or "FALSE" re-enabled a doc-leak the user had turned off). Now a project value that fails the schema's validity rule has no say and the global value stands; a project may add canaries to the list and never remove them. The two mode keys, coalledgerMode and updateMode, were swept with every type the schema rejects and none escalated. One consequence, not a hole: with no global value set, an invalid project value reads as the factory default, because it cannot be honoured either way. Verified here on Windows, red then green; the capability-probed link cases run on the CI legs.
  • A linked file nested past the container-depth cap can no longer hang the doc-structure scan (v0.15.0-beta.6). The cross-file anchor check parsed a link's target under the size cap only, and a 32 KB target nested 16,000 levels deep took 5.9 s. The target now gets the primary doc's depth guard and is left unchecked past the cap. Verified here on Windows, red then green; the bounded-time leg is the second assertion only, and the first is the deterministic one. Residual: a target within the depth cap but large is still parsed up to the size cap.
  • The config CLI judges the folder chain before it makes a folder (commit 31a1c3fbb8b7435463a20376c980742d1a06086b). scripts/configure.mjs made the config folder with a recursive make and judged the target afterwards, so a Windows junction or POSIX symlink planted at .claude (or .claude/coal) in a cloned repo made an empty folder outside the project, and a link whose target stayed inside the project was written through. It now walks the chain from the project root one level at a time with lstat and refuses any link, wherever it points, or any non-directory, before and after each make; the refusal exits 1 with a [refused] message. This is the repo-checkout CLI, which ships from no DIST_ITEM: the helper (mkdirRepoParents in repo-fs.mjs) is also in the three generated repo-fs.mjs copies under plugin/, but no code in the installed plugin calls it. (A follow-up edit of the same file, commit ed36168fe49eb2004468445271dd895b57a9dc44, also changed the containment test that the installed plugin's read path does call: the outside-the-project test is now on the first path segment, so a folder named ..notes inside the project is read, not refused; a real .. climb is still refused.) Verified here on Windows: the junction cases ran red on the old code (a folder appeared outside; a link target inside the project was written) and green after; the directory-symlink cases are capability-probed and run on the CI legs. Not closed, by name: the walk narrows the check-then-write window, it does not close it. The walk (mkdirRepoParents) and the write (writeRepoFile) are separate steps, and the write's own check resolves the nearest existing folder by realpath once, before it opens its temp file, so a hostile process on the same machine that swaps a just-checked folder for a junction in the instant after that check could still land a folder or the config file outside the project. A link planted ahead of time is refused before anything is made. The write already goes through a temp file and a rename, which keeps a symlinked FILE target from being followed but does not defend against a swapped parent folder; a handle-relative open of the folder is the real fix and is not built.
  • The GIT_* strip is case-insensitive (commit 8725bda403c5d521211c7ad3a774ebd76e7b14eb). gitEnv() removed an inherited variable only when its name began with the exact text GIT_, and Windows reads environment names without regard to case, so git_dir or Git_Index_File set in the calling environment reached a git child and could point it at another repository. The strip now matches the prefix in any case, and GIT_CEILING_DIRECTORIES is still set. Verified here on Windows, red then green (four new cases failed on the old strip, among them a real child process that printed its own environment); the helper is in plugin/scripts/lib/git-env.mjs, and no code in the installed plugin imports it.
  • Doc content is data, never instructions—the canary contracts bind the agent to judge doc content, not obey it (prompt-injection via a poisoned doc is the named threat model).

Honest scope: these measures are the series' data-safety discipline—injection-aware, consent-gated spend, offline code, no exfiltration path. No formal verification; the scanner record above pins exactly what was scanned and when.

There aren't any published security advisories