Skip to content

Security: TheColliery/CoalFace

SECURITY.md

Verifying CoalFace

CoalFace is verified under the same framework as CoalMine, CoalTipple, and CoalBoard: the conductor hook follows the Phoenix-13 commandments, the build is reproducible from source, and scanning is event-driven.


🔒 Reporting a Vulnerability

Channel: GitHub private vulnerability reporting — Security → Report a vulnerability on the repo page. Enabled for this repo, verified live at press.

Scope: the shipped plugin/ skill and its conductor hooks (coalface-conductor.js, ag-conductor.js), the build/verify scripts under scripts/, and the swarm-discipline mechanisms (snapshot/rollback, worker isolation, QC gating) described below.

What to expect: an acknowledgement, triage against the scope above, and coordinated disclosure timing agreed with you before any public detail — all inside the advisory thread, never a public issue.

A public issue stays the route for ordinary bugs; it is not the route for a vulnerability.


🔑 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 unsigned by design (they carry no maintainer key), so verify a signed release tag — the artifact a release consumer trusts:

echo "* ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEtqTWGKhX1Dk9nZP8ns13Wl5zsO1Cz3VlTS6m1p2fP9" > coalface_signers
git config gpg.ssh.allowedSignersFile ./coalface_signers
git tag -v "$(git describe --tags --abbrev=0)"

🕵️ Secret Scan Before Commit and Push

GitHub scans this public repository for provider tokens. The repository's own gate, scripts/secret-gate.mjs (scanner scripts/lib/secret-scan.mjs), 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 , X-Api-Token: Bearer ) unless that token is provider-shaped, or a key split across lines. It runs as follows: .githooks/pre-commit scans the staged tree, and .githooks/pre-push also scans the added lines of every pushed commit plus every pushed commit and annotated-tag message. A scan that cannot run fails the hook, and a hit never prints its value. It runs on a maintainer's machine through the git hooks; CI does not run it, and a clone that has not set core.hooksPath to .githooks does not run it either. It reports what its patterns match and is not a guarantee that no secret is present.


🔗 Planted Links in the Project Config

The project config (.claude/coal/coalface.json and its legacy locations) sits in a folder a cloned repository supplies. A link planted at the config file or at a folder above it (a junction on Windows, a symlink on POSIX) made the conductor read a file outside the project, and made scripts/configure.mjs read, back up and write outside it. Fixed in v0.14.3: the conductor (and the Antigravity adapter, which shares the read) accepts a project config only when its real path is exactly its folder's real path plus the literal path the walk built, and a refused file counts as absent and is named in the report with the reason "it resolves outside its folder"; configure.mjs pins the config it finds, the file it writes and the .bak copy the same way before anything is read or written, and on a refusal prints one FAIL line and writes nothing. Verified here on Windows (directory junctions): 9 hook, Antigravity and helper witnesses and 4 configure witnesses failed before the fix and pass after (hook tests 71 to 85, configure tests 16 to 25), and the full suite is green (749 tests, 743 pass, 6 skipped). The file-symlink cases need a privilege this machine lacks, and the ENOTDIR case does not occur on Windows (a path under a file reads ENOENT there), so those skip visibly here and run on CI; the CI legs follow the push and are recorded after it. Nothing on POSIX was run here. Named residue: the check and the read are two acts, so a link swapped in between them is not caught; a hard link cannot be told from a file when the config is READ (a write replaces it, see below); a differently cased existing folder on a case-insensitive volume is refused (the safe direction).

Making the missing folders (CWK2-042, CWK2-049). scripts/configure.mjs makes the parent folders of a new project config only after judging every component from the level it writes under down to the target (scripts/lib/repo-fs.mjs, mkdirRepoParents): one lstat per component, a missing one made with a plain non-recursive mkdir and checked again at once. The link rule it follows: a link or junction at ANY component of that chain is refused, wherever it points, and so is a file where a folder must be, with one clean FAIL line and nothing written. That is the rule CoalLedger's exemplar states for its own write root, and the rule the up-front pin above already applies to the config file and the folders above it. Residual, named: the judging and the later write are two acts, so a hostile local process that swaps a just-judged folder for a link to somewhere outside the project in the gap between them can make the config file land outside it (the folder chain is judged before the write, and the temp file and the rename land in that folder). Since v0.14.8 the config file and its .bak are written to a temp file in that folder and renamed into place (writeFileAtomic in scripts/lib/repo-fs.mjs), so a symlink or junction already at the config file or the .bak is refused by the up-front pin before anything is written, and a hard link there (which the pin cannot tell from a file), or a link swapped in after the check, is REPLACED, never written through, and a kill mid-write leaves the old file or the new one; a leftover temp of the same process id refuses with a sentence, and a leftover from another run is left in place; when a malformed config cannot be backed up (a folder or a held file sits at the .bak name) the run stops with one FAIL line naming the backup, leaves the malformed config untouched and writes nothing; a folder at the config path is refused with one FAIL line, and a path component that is a file reads "a component of the path is not a folder" on POSIX and "it exists and is not a directory" on Windows (where a path under a file reads ENOENT at the pin and the folder walk refuses it), both with the same fix text. Not yet in a release (on main, after the v0.14.8 tag): the temp is filled in full before the rename, and a replaced config keeps its permission bits (the .bak takes those of the config it copies; a new file gets the default mode), where v0.14.8 itself wrote the temp with a single write and installed the default mode. What stays open is only the folder chain: a folder swapped for a junction between the last check and the temp's creation sends the temp and the rename outside. On Windows a rename over a file another process holds open can fail, which is a FAIL line and keeps the old file. The walk narrows that gap and does not close it, and no duration is measured (the same gap the read side's check-then-read names above); a stricter per-component walk does not remove it either. Nothing a cloned repository plants ahead of time reaches outside the project: such a link is refused before anything is made.


📦 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 both directions (stale files AND dist-only orphans) — 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) — the manifests are valid, the factory config matches the schema, and the issue-template version pins are current. node scripts/test.mjs runs the zero-dependency unit + hermetic-hook tests.


🔬 Independent Scanning—NVIDIA SkillSpector

Last scan: CoalFace v0.14.8 dist (plugin/, commit a27c19c, the v0.14.8 tag), on 2026-10-17, 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). Static coverage was partial (10 of 13 files fully inspected): hooks/ag-conductor.js and the new skills/coalface/scripts/machine-reading.mjs reached the scanner's parser span limit and hooks/hooks.json is opaque to it; ag-conductor.js and hooks.json were read by hand at v0.10.0 (ag-conductor.js is unchanged since v0.12.0), the v0.10.0 to v0.12.0 changes to them were read, the v0.12.0 to v0.14.0 changes to the conductor (a .native realpath and one notice path) were read, and machine-reading.mjs (183 lines) was read in full at v0.14.0 and differs in v0.14.2 by one comment and one usage-line string (the same rewording, "whatever the reading says" to "regardless of the reading"), both read, no logic: Node built-ins only, two fixed cgroup reads on Linux, stdout only, no child process, network or file write. The v0.14.1 to v0.14.2 dist changes are one sentence of references/admission-control.md, that comment and usage string in machine-reading.mjs, and the version string in plugin.json; no logic differs. The v0.14.2 to v0.14.3 dist change, five files (the version string, hooks/coalface-conductor.js, hooks/hooks.json, skills/coalface/SKILL.md and references/admission-control.md), was read in full by hand: the conductor gains a containment check on the project config (containedProjectFile: a candidate is accepted only when its realpath, through realpathSync.native, is exactly the level's realpath joined with the candidate's relative path, so a link at the file or at a folder above it reads as unreadable and contributes nothing, fail closed on any throw) and a write-side twin (containedWriteTarget) it exports for scripts/configure.mjs, and it adds no write, delete, spawn, network, eval or import site (the dist's primitive census is identical; only the count of link-check calls rises); hooks/hooks.json moves its one SessionStart entry to the exec form, node as the command and the script path in args, which this scan cannot show the host resolving; the SKILL.md and admission-control.md changes reword the wallet claim (dollars, on cheaper worker tiers only) and carry no instruction. The v0.14.3 to v0.14.4 dist change, six files (the version string, hooks/coalface-conductor.js, skills/coalface/SKILL.md, skills/coalface/references/admission-control.md, skills/coalface/references/taxonomy.md and skills/coalface/scripts/machine-reading.mjs, 53 lines added and 20 removed), was read in full by hand: the conductor changes two directive strings, the wallet wording again (dollars only on cheaper worker tiers); skills/coalface/SKILL.md moves its granularity-honesty sentence to skills/coalface/references/taxonomy.md (where it is now a section), drops the nested-conductor sentence about spawns beyond depth 2 (the P2 row of the same file still carries it), and adds the new reading rules to the waves step; skills/coalface/references/admission-control.md states them; none of it is an instruction to the agent beyond what those files already carried. skills/coalface/scripts/machine-reading.mjs (183 to 211 lines) changes its arithmetic and nothing it touches: the CPU reading is the mean over the k busiest logical CPUs, k being os.availableParallelism(), so a process confined by an affinity mask or a cpuset reads busy when its usable cores are saturated (an unconfined box reads the whole-machine mean as before); a new exported memFreePct treats a constrainedMemory of 0, at or above 2**63 or above the machine total as no limit and, where process.availableMemory is absent, leaves the memory axis unmeasured instead of falling back to os.freemem(); with every axis unmeasured only --running 0 is admitted; and a WAIT line names the thresholds it crossed. Its imports are still node:fs, node:os and node:url and its I/O lines are unchanged: the two fixed cgroup reads on Linux, stdout only, no child process, network, file write, eval or dynamic import added, and the dist's primitive census is identical. The v0.14.4 to v0.14.5 dist change, three files (the version string, step 2 of skills/coalface/SKILL.md and the granularity section of skills/coalface/references/taxonomy.md, 3 lines added and 3 removed), was read in full by hand: documentation only, no script, hook or manifest line moved; step 2 now says that only the min-unit floor merges independent spots and that the wave width bounds each wave, never the unit count, and the taxonomy section is rewritten from a cap into an observation (the wall-clock arithmetic and the benchmark arm it cites), so the change is to a rule the skill states, which the room's CHANGELOG names as a rail change, and not to anything this scan reads. The v0.14.5 to v0.14.6 dist change, five files (the version string, hooks/coalface-conductor.js, skills/coalface/SKILL.md, skills/coalface/references/admission-control.md and skills/coalface/references/receipt.md, 15 lines added and 8 removed), was read in full by hand: the conductor's self-update nudge now follows updateMode, as the sibling conductors' do: auto is a standing consent that covers the version CHECK and never the update itself (the nudge tells the agent to offer the update and never apply it unasked), remind is one line to the user with no web check and no question, and any other value reads as before (offer the check); updateMode is clamped by the conductor's safer-enum rule, so a project config cannot raise it above the global value, whose default is ask; the one directive string of the nudge became three, one per mode, which is the only change the scan sees. skills/coalface/SKILL.md step 4 and skills/coalface/references/admission-control.md bound the worker-wave width to at least one and at most the platform width, skills/coalface/references/receipt.md says machine admission is reported on its own line and never sets the effective width, and one sentence of the Journal section is dropped. No write, delete, spawn, network, eval or import site was added, and the dist's primitive census is identical. The v0.14.6 to v0.14.8 dist change (the v0.14.7 tag sits between them and was read through the same two-tag diff), four files (the version string, hooks/coalface-conductor.js, skills/coalface/SKILL.md and skills/coalface/references/receipt.md, 13 lines added and 13 removed), was read in full by hand: the conductor's report-only refusal reason for a project config the containment pin refuses gains a second text, a component of the path is not a folder, chosen when the realpath of the file fails with ENOTDIR (a fixed constant, no repository text), and refusalReason and the constant are exported; findProjectCfg, an unused helper, is removed (the repository's own configure script uses findProjectHit; no other file in the dist referred to it); the rest is comment wording. SKILL.md step 8 and references/receipt.md add a Machine line to the receipt, a documentation row. The v0.14.8 work on the repository's own scripts/configure.mjs (the config write replaces a link instead of writing through it, temp-then-rename for the config and its backup) sits outside the plugin/ dist and is not covered by this scan. No write, delete, spawn, network, eval or import site was added in the dist, and its primitive census is identical. The 20 findings are the 18 of v0.14.5 (the same rule-and-file set, on the same lines, as v0.14.4's, v0.14.3's, v0.14.2's, v0.14.1's, v0.14.0's and v0.12.0's on the same scanner commit; v0.14.3 moved five findings by line shift only, none added or lost; v0.14.4 and v0.14.5 move none) with the one RA1 site on the conductor's self-update nudge replaced by three, one per updateMode string (RA1 in hooks/coalface-conductor.js 6 to 8, v0.14.6), a false positive of the same class; the score stays 100; v0.14.8 adds none and moves six sites by line shift only, the rest being the same rule, file and source text as v0.14.6's. A re-scan is triggered by this room's version bump, gated by a baseline diff; a genuinely new attack surface is a second trigger, by hand. This pins the last scan actually run.

  • Static Scan (100/100 · 20 findings, all false positive): 16 × HIGH · RA1 Self-Modification matching the string "self-update" across the conductors' comments and directive text, the /coalface:update command, plugin.json, and the SKILL.md "Config + self-update" section—the family's consent-gated Self-Updating static false positive · 1 × HIGH · AS1 on a comment in hooks/ag-conductor.js that names ~/.gemini/config/skills as the file-copy install location (the file never reads or writes that path) · 1 × HIGH · AR1 on the SKILL.md prohibition row "queue it, never refuse" (about workers above the admission cap, not about model refusals) · 1 × MEDIUM · RA2 on a comment in the conductor · 1 × MEDIUM · BH1, the scanner's own note that hooks/hooks.json registers a lifecycle hook. The hook only SCHEDULES a throttled check (a timestamp stamp at ~/.claude/coal/coalface/update-check—no network ever); the /coalface:update agent procedure verifies the tag online and offers claude plugin update—it never auto-applies, and the skill never rewrites its own files. The score is not comparable to the 37 recorded at v2.3.9: the previous dist (v0.1.0-beta.2) re-scanned with v2.11.2 scores 50; the v0.10.0 dist re-scanned with v2.12.0 also scores 100 with the same 18 findings, so the v0.12.0 changes add none; v0.14.6 adds two RA1 of the same class, the conductor's self-update nudge being three directive strings, one per updateMode, where it was one, and v0.14.8 adds none. This matches the family baseline—all-false-positive across the family; each sibling's SECURITY.md pins its own last-scan score. The report JSON is not shipped.
  • 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).

🛡️ Structural Safety (Phoenix-13)

Both hooks — hooks/coalface-conductor.js (Claude Code, SessionStart) and hooks/ag-conductor.js (Antigravity 2.0, the first-PreInvocation adapter) — are advise-only and Phoenix-pure: zero dependencies (Node builtins only), no network, no child processes, fail-silent (all logic wrapped in try/catch; it never crashes the host), and silent except the one sanctioned context-injection channel (plain stdout on Claude Code · a single {"injectSteps":[{"ephemeralMessage":...}]} JSON line on Antigravity — the current AG PreInvocation contract). They inject a standing directive — never spawn workers, never network, never apply anything. ag-conductor.js shares the CC conductor's readCfg/directiveFor (one implementation, no fork) and guards its injection to once per session (a os.tmpdir() marker, written before emit, fail-closed on write failure). The .coalface.json parse is prototype-pollution-guarded (__proto__ / constructor / prototype keys dropped at parse time, so an untrusted project config cannot pollute Object.prototype through the config merge), and every numeric the hook reads is range-clamped.


🐜 Security by Design — the Swarm

The discipline itself is the safety mechanism:

  • Workers are leaves. Spawned via a spawn-tool-less agent type where the platform offers one; they read and RETURN text — never write the tree, never spawn, never self-retry.
  • Propose, never execute. Workers return anchor-edit orders as text; a side-effect fires only at the conductor's consented sequential apply — and is never auto-retried.
  • Snapshot + gate. The apply happens behind a pre-swarm snapshot (git stash / HEAD-record, or plain file copies — git is never assumed); a red domain gate = full rollback.
  • Honest QC ceiling. QC is mechanical (scope + spec). An in-scope, on-spec, semantically-wrong return with no covering test CAN pass — the receipt flags test-uncovered spots; the escalation for that class is CoalBoard, not another swarm.

There aren't any published security advisories