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).
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.
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)"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.mjsautomatically verifies config schema matching, files presence, ensures the conductor is in sync withscripts/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.mjsto regenerate the plugin distribution from source, thennode scripts/verify.mjsto gate it against source—the parity check matches byte-for-byte, except.js/.json/.mdfiles (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.mjsto execute zero-dependency unit tests.
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-Modificationmatching "self-update"/"ask" acrosscommands/update.md(×2, carried) and the consent-gated Self-Updating channel's own text—7 sites inhooks/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 inskills/coaltipple/SKILL.mddocumenting that same channel (the stamp path, theupdateModeconfig row, the "No network" row) · 1 ×HIGH · AR1oncommands/update.md's "Always answer in the user's language" line (carried; a localization rule, not refuse-suppression) · 1 ×MEDIUM · RA2on the conductor's stamp-location-migration comment · 1 ×MEDIUM · EA2on 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 thathooks/hooks.jsonregisters 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:updateverifies the tag online and offersclaude 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, commitce0ebc0) 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 onlylstatSync/realpathSynccalls and no write, delete, spawn, network,evalor import site; the hook manifest is unchanged). The v1.7.3 change, four files (the version string, the conductor,hooks/hooks.jsonandskills/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 (onelstatper 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.jsonmoves both entries (SessionStartandUserPromptSubmit, one script) to the exec form,nodeas the command and the script path inargs, which this scan cannot show the host resolving; andskills/coaltipple/SKILL.mdgains 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,evalor 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).
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/coalor.claude/.coaltippleas 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~/.claudeis not affected. - Named residual, not fixed: the writes the model performs from
SKILL.mdinstructions (thefableConsentedit,.claude/.coaltipple/proposed/,state.json, the memory-anchor file) are prose the model follows; no code guards them. Since v1.7.3SKILL.mdtells the model to check the real path before the project-config,proposed/andstate.jsonwrites (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.