Skip to content

Security: TheColliery/CoalTipple

SECURITY.md

Verifying CoalTipple

CoalTipple is verified under the same framework as CoalMine: all execution hooks follow the Phoenix-13 commandments, builds are fully reproducible from source, and security scans run periodically (event-driven).


🔒 Reporting a Vulnerability

Report a vulnerability via GitHub private vulnerability reporting—Security → Report a vulnerability. Do not open a public issue for a security finding.

  • In scope: the conductor hook's routing/sensitive never-down gate, the config-cascade merge-safety clamp, the installer's file-write and self-target paths, .claude/.coaltipple/proposed//damage-control, and anything else that could make the skill route, spend, or write somewhere it shouldn't.
  • Out of scope: a SkillSpector false positive, a style nit, or anything without a security impact—those go through the project's normal (public) issue flow instead.
  • What to expect: acknowledged promptly, triaged, and coordinated disclosure once a fix ships—no fixed SLA is committed today.

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

📦 Dist Integrity

CoalTipple is distributed as source (human-auditable skill Markdown). The plugin distribution is generated at publish time:

  • Pre-commit/Pre-push Gates: node scripts/verify.mjs automatically verifies config schema matching, files presence, ensures the conductor is in sync with scripts/lib/keywords.mjs, and flags a detectable config key documented on a user-facing surface (SKILL.md, its references, README, and the conductor's notice regions) that no longer resolves in the schema—to prevent silent drift.
  • Reproducible Builds: Run node scripts/build-dist.mjs to regenerate the plugin distribution from source, then node scripts/verify.mjs to gate it against source—the parity check matches byte-for-byte, except .js/.json/.md files (today's shipped extensions) are compared EOL-normalized: a CRLF-vs-LF checkout of identical content is not flagged as drift, but any other content difference still is.
  • Test Suite: Run node scripts/test.mjs to execute zero-dependency unit tests.

🔬 Independent Scanning—NVIDIA SkillSpector

CoalTipple is evaluated against 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). Last scan: CoalTipple v1.7.3 (commit 6f51abf, the v1.7.3 tag), 2026-10-16—static stage (--no-llm), 17 findings (RA1 ×13 · AR1 · RA2 · EA2 · BH1), all false positives on adjudication. Static coverage was partial (9 of 10 files fully inspected): hooks/hooks.json is opaque to the scanner; it was read by hand. Scanning is event-driven (a new SkillSpector version, or a genuinely new attack surface)—this pins the last version actually verified.

  • Static Scan (83/100 · 17 findings, all false positives on adjudication): 13 × HIGH · RA1 Self-Modification matching "self-update"/"ask" across commands/update.md (×2, carried) and the consent-gated Self-Updating channel's own text—7 sites in hooks/coaltipple-conductor.js (3 in the self-update directive strings themselves—the ask-once/auto/remind text—and 4 in surrounding comments describing that same throttle) and 4 sites in skills/coaltipple/SKILL.md documenting that same channel (the stamp path, the updateMode config row, the "No network" row) · 1 × HIGH · AR1 on commands/update.md's "Always answer in the user's language" line (carried; a localization rule, not refuse-suppression) · 1 × MEDIUM · RA2 on the conductor's stamp-location-migration comment · 1 × MEDIUM · EA2 on SKILL.md's P4 prohibition—the rule's INVERSE of what the analyzer names: "Never spawn a fable-family worker without consent" requires consent, it does not suppress a refusal · 1 × MEDIUM · BH1, the scanner's own note that hooks/hooks.json registers two lifecycle hooks (SessionStart, UserPromptSubmit). The hook only reads the config/prompt locally and schedules a throttled check (a timestamp stamp under ~/.claude/coal/coaltipple/, no network ever); /coaltipple:update 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 51 recorded at v1.0.23/v2.3.9: the previous dist (v1.0.23, commit ce0ebc0) re-scanned with v2.11.2 scores 68; the v1.6.0 dist re-scanned with v2.12.0 also scores 83 with the same findings, so the v1.7.0 conductor change adds none; v1.7.0 re-scanned on the pin 2226747 scores 83 with the same 17, and v1.7.1, v1.7.2 and v1.7.3 add none. The v1.7.2 change is a security fix on the conductor's read side: a project-config candidate whose path holds a link (junction or symlink) between the project root and the file is skipped as if absent and never read, through one containment helper that fails closed (it adds only lstatSync/realpathSync calls and no write, delete, spawn, network, eval or import site; the hook manifest is unchanged). The v1.7.3 change, four files (the version string, the conductor, hooks/hooks.json and skills/coaltipple/SKILL.md, 14 lines added and 5 removed), was read in full by hand: the conductor names, in a SessionStart notice, a project-config candidate that exists as an entry but that the containment check refused (one lstat per candidate, then the existing helper), and widens two hot-keyword lists with hyphenated spellings of phrases they already held (a data block synced from the keyword source, which can only make the router more cautious); hooks/hooks.json moves both entries (SessionStart and UserPromptSubmit, one script) to the exec form, node as the command and the script path in args, which this scan cannot show the host resolving; and skills/coaltipple/SKILL.md gains one bullet that tells the model to check, before the project-config, delegation-sandbox and state-journal writes, that the target's real path stays inside the project folder that holds it, and to refuse and say so when a link is in the way, an instruction at prose strength that narrows what a run may do. It adds no write, delete, spawn, network, eval or import site, and the primitive census is identical except one more link-check call. The report JSON is not shipped.
  • Method: uvx --from git+https://github.com/NVIDIA/skillspector.git@2226747e4ca97198bb82faf5085b8a75f2e1dc02 skillspector scan <plugin> --format json—the @<rev> pin reproduces this exact scan (an unpinned URL resolves to upstream's moving HEAD, and a tag is a movable name, so the commit is what is pinned); 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)

The primary security assurance is structural. The coaltipple-conductor.js hook follows the Phoenix-13 rules:

  • Zero Dependencies & No Network: Runs 100% locally with no third-party libraries.
  • No Child Processes: Does not execute external terminal shell commands.
  • Fail-Silent: Exits 0 on any error, preventing execution blockages in the host agent.
  • No Secrets: Never reads, logs, or stores hardcoded API keys or credentials.

Damage Control is a main-agent workflow, not the hook's. The hook itself only reads the config and prompt locally and emits an advisory routing hint—it never writes a proposal. When SKILL.md routes a task carrying an external side effect, the main agent writes proposals to a local .claude/.coaltipple/proposed/ sandbox or isolates the change in a git worktree before merging (see README's Damage Control section).

Link containment for the project's own config and state folders (v1.7.2). A cloned repository can ship .claude, .claude/coal, .agents/coal, .gemini/coal or .claude/.coaltipple/ as a symlink or junction. Every project-config read in the conductor and every write in install.mjs and configure.mjs (the factory config, the conductor copy, the legacy-file move-aside) therefore goes through one rule: both the project root and the path are resolved to their real locations, and the path must lead to the folder itself, not merely somewhere inside the project; anything that cannot be resolved is refused (fail closed). A refused read is treated as no project config; since v1.7.3 the conductor names it once per session on SessionStart with a REFUSED: line (the path relative to the project and a plain reason), never on a prompt. A refused write writes nothing, names the path on stderr and exits non-zero. Files are replaced by temp-file-and-rename, so a hard link at the config is replaced by a rename. Since v1.7.3, where Windows refuses the rename (EPERM or EBUSY: the file held open by another program, or read-only) the rename is retried up to five times about 40 ms apart, then the write fails with an error and the old file is kept; it never falls back to writing in place (v1.7.2 did). This covers both rename writers, the config write and the global ranking write. The rule lives in scripts/lib/contain.mjs; the conductor carries a verbatim copy that verify.mjs checks for drift.

  • Behaviour change: a user who keeps .claude, .claude/coal, .agents/coal, .gemini/coal or .claude/.coaltipple as a symlink or junction (a dotfile manager, for instance) has project-config writes refused and that project config read as absent. The fix is to keep those folders real directories inside the project. A real folder spelled with a different case (.Claude) on a case-insensitive volume is refused too, which is the safe direction. The global config under ~/.claude is not affected.
  • Named residual, not fixed: the writes the model performs from SKILL.md instructions (the fableConsent edit, .claude/.coaltipple/proposed/, state.json, the memory-anchor file) are prose the model follows; no code guards them. Since v1.7.3 SKILL.md tells the model to check the real path before the project-config, proposed/ and state.json writes (not the memory-anchor file, which is the user's own file) and to refuse and tell the user when a link is in the way: that is an instruction at prose strength, not a guard. A check-then-write window also remains between the containment check and the directory step of a write (the file step is atomic).
  • Environments verified: Windows 11, Node 24.19 (the junction tests ran; the six file-symlink tests are skipped there because the box cannot create file symlinks, as the tests say). The six file-symlink tests run on the CI matrix's Linux and macOS legs (Node 22 and 24) on the commit the v1.7.2 tag names; they skip on Windows, where a file symlink cannot be created. No SkillSpector re-scan ran for v1.7.4 or v1.7.5: the pin above is the last real scan, of v1.7.3, and v1.7.4 and v1.7.5 are not scanned.

There aren't any published security advisories