Skip to content

Security: TheColliery/CoalBoard

SECURITY.md

Verifying CoalBoard

CoalBoard is verified under the same framework as CoalMine and CoalTipple: the execution hook follows the Phoenix-13 commandments, the build is reproducible from source, and the design is security-first.


πŸ”’ 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 conductor hook's detection and its config-cascade merge-safety clamp, the lens spawn/leaf-ness mechanism and each seat's tool grants, the staging/apply pipeline (.coalboard/proposed/, the verify gate, the secrets scrubber), the Fable consent-gate (the one path that spends real money), and scripts/configure.mjs's config writes. Out of scope: a defect found IN the code a board run reviews β€” that belongs to that code's own maintainer, not CoalBoard β€” and a SkillSpector false positive. 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.


🩹 Fixed Security Issues

  • v2.8.7 (2026-10-19): the apply check could judge a relative target joined to the root while a writer sent it to another drive. On Windows a relative target that starts with a drive letter and a colon (z:foo/x.txt) is resolved against that drive, not the project root, so a writer that resolves such a target would land on z:\foo\x.txt while the checker had judged it joined to the root. The shipped checker skills/coalboard/scripts/apply-target.mjs now refuses, on every platform, a relative target that starts with a drive prefix, with the reason name, and a refused target is named in the digest and the report as not applicable. An absolute target is not affected: a drive prefix counts only at the very start of a path. The 2.8.6 name rule also refuses a dot or a space right before a stream colon. That the agent runs the checker is an instruction it follows, a mitigation and not a guarantee. What ran here, on Windows 11: the refusal in both letter cases and the controls that stay allowed, red first and then green, and a mutant table of eight (seven killed; the one that refuses only on Windows survives on this host and is for a POSIX CI runner to kill). See CHANGELOG.md, ### Security under 2.8.7.
  • v2.8.6 (2026-10-18): the apply check could judge one spelling of a name and the write land on another. On Windows a path component that ends in a dot or a space is stripped by the system, so foo. and foo name one file, and a target spelled with such an ending could be checked under one spelling and written under the other. The shipped checker skills/coalboard/scripts/apply-target.mjs now refuses, on every platform, a target with a segment below the project root that ends in a dot or a space, with the reason name, and a refused target is named in the digest and the report as not applicable, so "apply all" never covers it. The segments of the project root itself are not judged. That the agent runs the checker is an instruction it follows, a mitigation and not a guarantee. What ran here, on Windows 11: the refusal in both the relative and the absolute spelling, and a root with a trailing-dot folder above it (that folder can be made on this machine, so the leg ran); the file-symlink legs of the same test file still skip here by a capability probe and are expected to run on the Linux and macOS CI runners. See CHANGELOG.md, ### Security under 2.8.6.
  • v2.8.4 (2026-10-16): applying an approved fix could write through a planted link. The final consent step (GATE 3) named each apply target by its path and then wrote to it, so a symlink or junction planted in a cloned repository, at the target or on the way to it, sent the write to the link's target, possibly a file outside the project. The skill now runs a shipped checker, skills/coalboard/scripts/apply-target.mjs, on every target before the digest and again before each live write; it refuses a link at or below the project root (a link above the root is allowed), a target whose real path leaves the root, and an unresolvable path (fail closed), and a refused target is never written. The checker writes nothing and reads only path metadata. That the agent runs it is an instruction the agent follows, a mitigation and not a guarantee; once it runs its verdict is exact, and if it cannot run no live file is written. An absolute target's .. steps after a link are judged by the real parent: that was red first and then green here, on Windows junctions; the POSIX behaviour it guards (the system resolves a symlink before it applies ..) is the mechanism, inferred, and was not run here. A window remains between the last check and the write, which a local process could use. What ran here, on Windows 11: directory junctions as the target's directory, in the middle of the path, to a folder inside the root, at the target name and above the root, and with a real access-denied file (refused as unresolvable); the file-symlink legs skip here by a capability probe (this machine cannot create a file symlink) and are expected to run on the Linux and macOS CI runners, where file symlinks need no privilege; the runner's log counts files, not tests, so their unskipped run there is inferred, not shown. See CHANGELOG.md, ### Security under 2.8.4.
  • v2.8.3 (2026-10-15): a planted link could make the hook read a config outside the project. A junction or symlink in a cloned repository (at .claude, .claude/coal, .agents, the legacy config location, or the config file itself) could point the session hook at a file outside the project. The hook now reads the project config only after its real path is confirmed to sit inside the folder it expects, and reports a refusal on the session-start line. The skill also checks for a link before it reads, writes or deletes under .coalboard/ (or writes the project config), against the project root's real path and any link at or below .coalboard, and its memory cleanup removes only the files the run created, by name, then a non-recursive rmdir of memory/ only if it is a real directory; that second half is an instruction the agent follows, a mitigation and not a guarantee. Verified on Windows 11 with directory junctions at .claude, .claude/coal, .agents, the legacy location and an in-project junction outside the expected folder; a cross-drive leg ran here (a preload stands in for the second volume); the file-symlink legs skip on a machine that cannot create a file symlink (this one) and are expected to run on the Linux and macOS CI runners, where file symlinks need no privilege; the runner's log counts files, not tests, so their unskipped run there is inferred, not shown. See CHANGELOG.md, ### Security under 2.8.3.
  • Not closed, by name (the config tool, scripts/configure.mjs; the link refusal itself is in the next release). The tool checks the config folder's path and the config file before it makes the missing folders, then writes the config to a temp file in the config's own folder and renames it over the target. So a link planted at the config file's name after the checks is replaced, never written through, and a process crash leaves the old file or the new one, never a torn one (a power loss is not covered: there is no fsync). What stays open is the folder-swap window: a hostile local process that swaps a checked folder for a junction to somewhere outside before the temp file is made lands the temp file and the rename outside the project. The per-component walk narrows that window; it does not close it. A link planted ahead of time is refused before anything is made.

πŸ”‘ Commit & Tag Signatures

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" > coalboard_signers
git config gpg.ssh.allowedSignersFile ./coalboard_signers
git tag -v "$(git describe --tags --abbrev=0)"

πŸ“¦ Dist Integrity

The clean plugin/ distribution is generated from source by node scripts/build-plugin.mjs; node scripts/verify.mjs checks the dist is in sync, the manifest is valid, and the config schema is well-formed. node scripts/test.mjs runs the zero-dependency unit + hermetic-hook tests (the canonical runner β€” the node --test <glob> form is avoided: it breaks on Node 24, MODULE_NOT_FOUND).


πŸ”¬ Independent Scanning β€” NVIDIA SkillSpector

Last scan: CoalBoard v2.8.8 dist (plugin/, commit 0547dd8, the v2.8.8 tag; plugin tree 8c01b6f), on 2026-10-20, 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). Score 90/100 (CRITICAL), 17 findings. Sixteen are the same rule-and-file set as the v2.5.1 scan, which this pin reproduces exactly (69/100, 16 findings), and every one of them was a false positive on adjudication. One is new, in skills/coalboard/references/source-set.md, a file that v2.7.0 added after v2.5.1: a P6 "print rule" match on the words "footprint rule" (a false positive). The v2.7.1 scan also raised an E1 external-URL notice in that file, on the request forms of public vulnerability databases (a true reading, benign: documentation of a read-only lookup); v2.8.0 reworded that sentence and the notice is gone. The score moved from 69 to 90 because of that one added finding, not because of a change in behaviour. The bullets below keep the v2.5.1 baseline's figures and adjudication (69/100 Β· 16; 15 of 18 files); this line carries the current scan. Static coverage was partial (17 of 21 files fully inspected; 17 of 20 at v2.7.1 to v2.8.3, the one added file being the checker below): hooks/hooks.json is opaque to the scanner, and skills/coalboard/SKILL.md and skills/coalboard/references/lens-prompts.md carry the scanner's obfuscated_instruction_text exception, the same three as at v2.5.1, and, new at v2.8.4, skills/coalboard/scripts/apply-target.mjs reached the scanner's parser span limit. The v2.7.1 to v2.8.0 dist changes were read by hand: the version string and four reference files, run-safety.md (the S2 backup now sits in backup/ beside the clone inside one run directory; the S3 run directory, a recommended lab root and an icacls restriction on Windows; S4 becomes mandatory for a target whose effects are not files; S5 removes the whole run directory, clone and backup, every run), lens-prompts.md and source-set.md (matching wording) and failure-modes.md (one sentence); all of it is guidance to the board's own seats, in the register of its neighbours, and narrows what a run may leave behind. No instruction-shaped text entered SKILL.md at that step, which did not change, and no hook or command file changed. The v2.8.0 to v2.8.3 dist changes (seven files in all, read by hand) are: v2.8.1 a mirrored run-directory restriction in the lens line, a reworded source-set row and one SKILL.md sentence that documents the clamp order key by key; v2.8.2 a fallback to /tmp in the lens line when TMPDIR sits in the home tree and bare URLs wrapped as links in two reference files; v2.8.3 the security fix, in the conductor: a project config candidate is read only when its resolved path sits inside the folder it is expected to live in (insideFolder; the expected folder is hand-joined from the walk's own real level directory, only the candidate is resolved, an unresolvable candidate is refused and its reason shown at SessionStart, fail closed), both hook entries moved to the exec form (node as the command, the script path in args, which this scan cannot show the host resolving), and two additions to SKILL.md that narrow what a run may do: a Links paragraph (before touching .coalboard/, resolve the project root and check that no junction or symlink sits at or below it, else touch nothing and tell the user) and a run-memory cleanup that removes the files the run created by name and removes the directory only if it is a real directory, never recursively. The scanner raised nothing on them; they are guidance to the board's own seats in the register of their neighbours. The v2.8.3 to v2.8.4 dist change, three files (the version string, skills/coalboard/SKILL.md and the new skills/coalboard/scripts/apply-target.mjs, 134 lines added and 4 removed), was read in full by hand, the checker included because the scanner did not parse it. skills/coalboard/SKILL.md gains two instructions in the final consent step, one before the digest (run the checker on each apply target and name a refused target in the digest and the report as not applicable) and one before each live write (run it again and write only targets it accepts; where it cannot run, write no live file and courier the fixes), and one clause in the run-memory cleanup (if rmdir refuses, leave the directory and say so); all three narrow what a run may do, and the text states that the second is an instruction the agent follows, a mitigation and not a guarantee, with a window left between the last check and the write. skills/coalboard/scripts/apply-target.mjs (130 lines, Node built-ins only: node:fs, node:path and node:url) is a command-line checker that reads path metadata and nothing else: it walks each target below the project root with lstat, canonicalizes with realpath.native, prints OK or REFUSE with a reason (link, outside, unresolvable) per target on stdout with control characters in a printed target replaced, and exits 0, 1 or 2; it refuses a link at or below the root (a link above the root is the user's layout), a real path that leaves the root and an unresolvable path, and it reads one file besides, the plugin manifest, for --version. It writes no file, spawns no process, opens no connection, reads no environment variable and uses no eval or dynamic import. The v2.8.4 to v2.8.6 dist change (the v2.8.5 tag sits between them and was read through the same two-tag diff, never scanned alone), five files (the version string, skills/coalboard/SKILL.md, skills/coalboard/references/lens-prompts.md, skills/coalboard/references/run-safety.md and skills/coalboard/scripts/apply-target.mjs, 13 lines added and 9 removed), was read in full by hand, the checker included because the scanner did not parse it. apply-target.mjs gains one refusal reason, name: a target segment below the project root that ends in a dot or a space, also right before a stream colon (a.txt.::$DATA), is refused before any lstat or realpath, on every platform, because Windows strips that character and a target could be checked under one spelling and written under another; .. keeps its meaning and the root's own segments are not judged. It narrows (one more refusal, and no new file-system call: the dist's call census is unchanged). skills/coalboard/SKILL.md lists the fourth reason. The other edits are guidance to the board's own seats, in the register of their neighbours: run-safety.md S2 and S5, with matching sentences in lens-prompts.md and SKILL.md, let a run that finds a real file changed during the run save its post-run version into one private post-run folder, a sibling of the run directory under the lab root (outside the project and .coalboard/, restricted to the user before anything is written, each copy named by its SHA-256 beside a manifest, only the folder path named in the report), which the S5 delete does not remove and the user reconciles and deletes. That widens what a run may leave on disk by one folder of copies of files that the run's S1 survey already read; the text says the folder may hold a secret and answers it with the user-only restriction. The v2.8.6 to v2.8.7 dist change, three files (the version string, skills/coalboard/SKILL.md and skills/coalboard/scripts/apply-target.mjs, 5 lines added and 2 removed), was read in full by hand, the checker included: apply-target.mjs gains one more name refusal (T-18e-2), for a RELATIVE target whose raw text starts with a drive prefix (one letter and a colon, z:foo/x): it is refused on every platform, because Node's absolute-path test says no for it on win32, so it would be judged joined to the root while a writer that resolves it lands on another drive; an absolute spelling and a colon later in the name are not touched. It narrows (one more refusal, a regex test and a call to that absolute-path test, no file-system call; the call census is unchanged), at the price that on POSIX too a relative name beginning with a letter and a colon is refused. SKILL.md GATE 3 now counts the four refusal reasons in one sentence and says the real path is named where the checker gives one. The v2.8.7 to v2.8.8 dist change, three files (the version string, hooks/coalboard-conductor.js and skills/coalboard/references/platform-cc.md, 6 lines added and 6 removed), was read in full by hand. In the conductor the one changed line is a code comment (it names the repository's shared reference copy of the arbitration sentence, a path the dist does not ship); with whole-line comments removed the file is identical, and its write, delete, spawn, network, eval and import words are unchanged. platform-cc.md is a reference the orchestrator reads only when it seats the fable model, and its changes are guidance to the board's own seats in the register of its neighbours: the fable alias now resolves to Fable 5.1 (claude-fable-5-1) and Fable 5 stays reachable by its ID, the rate note says Max has a separate weekly Fable limit while other plans and seat tiers can bill to usage credits, the version-sensitive paragraph names three vendor pages as plain text (not links) as checked on 2026-10-20, and one sentence says that in -p, and in an Agent SDK app that shows no prompt, Claude Code never asks before a Fable request bills usage credits, so the board's own consent ask is the only consent there. It adds no instruction aimed at a tool result, no link or command, and no file-system, process or network use. v2.8.1 to v2.8.8 each score 90 with the same 17 findings by rule and file and by source text (v2.8.3 moves eleven sites by line shift only, v2.8.4, v2.8.5, v2.8.6, v2.8.7 and v2.8.8 none); the dist's write, delete, spawn, network, eval and import sites are unchanged through v2.8.8, and only the counts of link-check calls (the checker's lstat and realpath walk) and of plain reads (its one read of the manifest) rise. A hand sweep of the whole dist found no child-process call (the word appears only in a keyword list in the conductor), no eval or new Function, no network primitive, and only the conductor's update-stamp write and removal, as at v2.5.1; the hidden-Unicode census finds only the emoji variation selector U+FE0F after an emoji in six reference files, identical at v2.8.7 and v2.8.8 (this line said "empty" before; that was wrong for these two versions). The v2.5.1 to v2.7.0 changes to the conductor were not re-read line by line for this scan. The scanner did not find the one real defect in v2.5.1: CB-R1 was found by hand (an unrecognised project updateMode slipped past the config-cascade clamp and was echoed into the session; rated HIGH, the clamp bypass alone MEDIUM) and is fixed since v2.6.0 β€” see CHANGELOG.md, ### Security. A clean scanner run is therefore not clearance: the scanner reads text, and that defect lived in behaviour.

  • Static findings (v2.5.1 baseline: 69/100 Β· 16 Β· all false-positive):
    • RA1 Self-Modification Γ—11 (hooks/coalboard-conductor.js Γ—9, commands/update.md Γ—2)β€”the series self-update is consent-gated: the hook only SCHEDULES (never networks), the agent offers the platform's own claude plugin update; the skill never rewrites its own files. One of the nine (hooks/coalboard-conductor.js:322, a comment reading "Self-update is kind-1 … No network here") surfaced only because the conductor was parser-limited in the control scan and fully inspected in this one.
    • EA2 Autonomous Decision Γ—3 (skills/coalboard/SKILL.md lines 131 and 150, references/opinion-board.md line 37)β€”the matched clauses are the user-CONFIGURED standing-consent modes (coalboardMode: auto β†’ convene without asking, default ask; fableConsent: always β†’ seat without asking, set only by the user's own answer at the real-money gate) and a rule that Fable never seats in the opinion lane; the hard rules force report-only with no human present and refuse gateless auto-apply.
    • RA2 Session Persistence Γ—1 (skills/coalboard/SKILL.md line 146)β€”the dev-contamination rule: when the target sits inside a governed tree, spawn the lenses from a neutral directory so the governance does not load; prompt hygiene, not an OS or session-persistence mechanism.
    • BH1 Γ—1 (hooks/hooks.json)β€”the scanner's notice that the file registers lifecycle hooks.
  • Coverage (v2.5.1 baseline): partialβ€”15 of 18 files fully inspected (83.3%). hooks/hooks.json is opaque to the scanner, and skills/coalboard/SKILL.md and skills/coalboard/references/lens-prompts.md were flagged as obfuscated instruction text and not fully inspected. Those three were not cleared by the scan.
  • Control: the v2.5.0 dist re-scanned with v2.12.0 also scores 69/100 (HIGH), 15 findings, coverage 77.8% (14 of 18); the one added finding is the comment above, a coverage effect rather than a change in what ships. The earlier pin (v1.5.5, SkillSpector v2.3.9, 2026-07-02, 71/100) is superseded; scores are not comparable across scanner versions.
  • Method: uvx --from git+https://github.com/NVIDIA/skillspector.git skillspector scan <plugin> --format json over the dist extracted from the tag; by-hand sweeps (dangerous primitives, file-system writes, URLs, hidden Unicode, config values interpolated into emitted text) and a behavioural probe (hermetic HOME, the real conductor, SessionStart stdin). The report JSON is not shipped.
  • LLM Semantic Scan: not run (--no-llmβ€”static-only is the documented, false-positive-prone baseline: pattern match without the skill-contract context).

This matches the family baseline: CoalMine and CoalTipple carry the same RA1 self-modification false positive (from consent-gated Self-Updating); each repo's SECURITY.md pins its own last-scan score.


πŸ›‘οΈ Structural Safety (Phoenix-13)

hooks/coalboard-conductor.js is advise-only and Phoenix-pure: zero dependencies (Node builtins only), no network, no child processes, fail-silent (exits 0 on any error; never crashes the host), and it only emits the two sanctioned channels. It detects and injects β€” it never spawns workers, networks, or applies anything. Its stdin parse is guarded against non-object input.


πŸ” Security by Design β€” the Board

The board is built so that reviewing untrusted work cannot harm the host:

  • The work under review is DATA, never instructions β€” the lens prompts never obey an injected "approve this".
  • Propose, never execute β€” the lenses emit a diff to .coalboard/proposed/; the board itself runs no side-effect. A real side-effect (a migration, an API call, a deploy) fires only at the human-approved apply, with a warning, and is never auto-retried.
  • What a run may leave on your machine: a run that executes a command against your target works on a lab clone and removes it, with its backup, when it ends. If it restores a real file it changed, it first saves that file's post-run version into a private folder, <run-id>.post-run/, beside the run directory under the lab root (outside your project, restricted to your user (icacls on Windows, mode 0700 on POSIX), copies named by SHA-256 with a manifest.txt). That folder may hold a secret, so the report names only its path; it is kept for you to reconcile and delete.
  • Verify is contract-isolated, NOT OS-sandboxed β€” a skill cannot OS-sandbox; the judge runs reviewed checks in the staging dir with a pre-run lint (banned modules / rm -rf / network), no real DB/network where possible, and a disposable VM for genuinely hostile code. Every run against a target also follows references/run-safety.md: the real files in the tool's reach are surveyed and hashed first, backed up, compared after the run and restored if changed, the run works on a lab clone outside the home tree (recommended roots in references/run-safety.md S3) that on Windows is restricted to the current user, sits beside the S2 backup of the real configs, and that every run removes whole (backup included) when it ends, and an isolation box is used only when a probe finds one (not required in general; a target whose effects are not files, such as registry or package removal, is never run without one and is read statically instead, S4). Contract-enforced + the judge's discipline, not an OS guarantee. External SAST is optional, never a hard requirement.
  • Secrets are scrubbed β€” credential patterns are scrubbed from anything logged or displayed (best-effort defense-in-depth, contract-enforced β€” NOT a guarantee a secret is caught; staging + the propose-not-execute contract + each seat's least-privilege grant are the real protection β€” leaf-ness is platform-enforced, a seat's run/fetch right is contract-strength on Claude Code, never a platform restriction; see Permissions in the README for the per-seat grants). scripts/lib/secrets.mjs is the reference scrubber + its test target β€” a DEV file, not part of the shipped plugin runtime.
  • No human, no apply β€” a non-interactive (cron/headless) run is report-only; the human consent gate is the load-bearing safety node.

There aren't any published security advisories