Releases: TheColliery/CoalMine
Release list
v3.20.2 - a link planted in a cloned repository can no longer crash the hooks or make install and configure write outside it
A link planted in a cloned repository can no longer crash the hooks or make install and configure write outside it.
Security
A cloned repository is untrusted input, and three defects let one act on your machine through a planted
symbolic link (a junction on Windows), FIFO or device file. Every release from 1.0.0 (the first
release, untagged: its heading below is dated 2026-06-09) through v3.20.1 is affected; the
range comes from a walk of versions (plugin.json history, these headings and the tags), not of tags alone.
The per-defect first version and the full advisory are in
SECURITY.md. No CVE id is claimed; none exists. Found by a blind
automated security review (2026-09-24).
- CWK-137 (1 of 3) — the hooks read repo-derived paths with no bound. A link to
/dev/zeroat
AGENTS.md,MEMORY.md,README.md, a rules file or the projectcoalmine.jsoncrashed the hook
(std::bad_alloc, exit 134, measured on Linux under a 3 GB address-space cap); a FIFO at any of those
paths, or a.claude/ruleslink to/, made the hook never return. The same unbounded read sat in
verify.mjs <target>and its manifest check. Reads now go throughreadRepoFileBounded, in the three
hooks (hooks/_shared/node-config.js, synced bybuild-plugin.mjs) and inscripts/lib/repo-fs.mjs
for the CLIs. It doeslstatfirst. A regular file proceeds. A symlink proceeds only when its
realpath.nativetarget lies inside the project root and is a regular file. A FIFO, device, socket,
directory, or a link that escapes the project or dangles is skipped beforeopen. The open uses
O_NONBLOCKwhere the platform has it, the fd is re-checked (regular file, size), and a file over
its bound is skipped, never truncated:MAX_CONFIG_BYTES= 1 MiB for configs,MAX_DOC_BYTES=
4 MiB for governance docs (measured: the largest real config is 9,114 B and the largest real doc is
216,465 B). The conductor's two rule-tree walks became one bounded walk,forEachRuleDoc, capped at
MAX_RULE_WALK_ENTRIES= 5000 entries andMAX_RULE_WALK_DEPTH= 16 levels; an escaping.claude/rules
root is not entered. The stop hook's language probe reads a 4096-byte prefix through the same helper.
Your own home files (the global config, the mode switch, the update stamp) keep their symlinks, since
dotfile managers link them, but still must be a regular file within the bound. Behaviour that is
now narrower, on purpose: a project config or rule file that is a link out of the project, or larger
than its bound, is ignored by the hooks.verify.mjs <target>reports such an installed file as
REFUSED(distinct fromMISSING) and such aSKILL.mdas unreadable. The PowerShell fallback hooks carry the same check asTest-CoalmineSafeFile
(hooks/_shared/ps-config.ps1), stricter than Node by design: PowerShell 5.1 has no
realpath.native, so it refuses every reparse point on the file or on any directory between the file
and the project root, even one that stays inside — test:scripts/lib/repo-fs.test.mjs, the
CWK-137:tests inscripts/lib/hooks.test.mjsandscripts/lib/ps-config.test.ps1. - CWK-137 (2 of 3) —
install.mjswrote through a planted link. With.github/copilot-instructions.md
linked to~/.bashrc,install.mjs copilotappended CoalMine's rules block to the shell rc and
reported success (measured on the newest 1.0.0 tree, v2.0.0 and v3.20.1). The same write-through applied to the platform rules
file each target writes, an existing git hook or its.pre-coalminebackup slot, the default project
config, the manifest, and a link on any of the nine project-level agent foldersinstall.mjswrites
into as of v3.20.1 (.github,.agents,.claude,.gemini,.cursor,.windsurf,.junie,.kiro,
.augment) that carried the skills install and the default config outside the project. Writes now go throughwriteRepoFile: the nearest existing ancestor must resolve
inside the project, the target must not be a link and must be a regular file, and the bytes go to a
sibling temp opened withwxand are renamed into place, so a link planted after the check is replaced
and never written through. A refusal is loud:[refused] <path>: <reason>, exit 1, nothing written.
A hooks directory outside the worktree (a linked worktree's gitdir, an absolutecore.hooksPath) is its
own root, on the reasoning that git configuration chose it rather than a file the repo planted. That holds
for a git clone, which carries neither.git/confignor a.gitfile, and not for a tree delivered as an
archive, so it is a named residual below. Where Windows refuses the rename over
a file another process holds open (EPERM/EBUSY/EACCES, e.g. re-installing the hooks from inside a
running pre-commit), the write falls back to an in-place write only for a single-link regular file — test:
scripts/lib/repo-fs.test.mjsand theCWK-137:tests inscripts/lib/install.test.mjs. - CWK-137 (3 of 3) —
configure.mjsread, backed up and overwrote through a planted link. With the
project config linked to~/.bashrc,configure.mjstreated it as malformed, copied its bytes into a
.bakinside the repository, then overwrote the link target (measured on v3.3.0 and v3.20.1: the rc file
ended as{ "language": "en" }and the.bakheld the original bytes). It now checks the read path and
the write path withcheckRepoWriteTargetbefore any read or backup, refuses with the path named
(exit 1), reads throughreadRepoFileBounded, and writes the config and the.bakthrough
writeRepoFile.configure.mjs --globalkeeps its follow-through write to~/.claude/.coalmine.json,
because dotfile managers link that file; its read is bounded and regular-file only — test:
scripts/lib/repo-fs.test.mjsand theCWK-137:tests inscripts/lib/configure.test.mjs.
Residuals, named: a regular file swapped in between the lstat and the open may lie outside the root
(the fd re-check still holds the read to a bounded regular file); a link planted between a write's check and
its rename is replaced, not followed, except on the single-link in-place fallback above, where a link swapped
in between its lstat and its open is not caught. A tree delivered as an archive can carry a planted
.git/config with an outside core.hooksPath (or a .git file naming an outside gitdir), and install.mjs
will then replace git hooks in that directory; the bound is that the bytes are CoalMine's own fixed gate
script, never attacker text, and an existing hook is first kept as <hook>.pre-coalmine. An agent's own
file reads through its tools are the host's permission system, not covered here.
What you need to do: update. On Claude Code run claude plugin update coalmine@coalmine; users of coalmine@claude-community receive it when that catalog's pin moves; for any other agent, update your CoalMine checkout and re-run node scripts/install.mjs <agent>. If you ran CoalMine in a clone you did not write, the "What to check" list in the advisory says what to look for.
v3.20.1 - 25 CodeRabbit claims adjudicated, the pre-transfer repository address swept from the dist, and the SkillSpector pin refreshed
Changed
- CWK-121 (b) — the shipped
.claude-plugin/plugin.jsonhomepage/repositoryfields,
coalmine-conductor.js's self-error-report URL,commands/update.md's latest-tag check, and the
sharedescalation-footer.md's self-error-report URL (renders into all 9 canarySKILL.mdbodies)
still pointed at the pre-transfergithub.com/HetCreep/CoalMineaddress (redirects, but the dist
should carry the live one). All now readgithub.com/TheColliery/CoalMine.plugin.json's
author.name("HetCreep") is left unchanged — it names the person, not the repo address.
Fixed
- CWK-120 row 10 —
commands/update.md's latest-tag check could select an annotated tag's peeled
^{}deref record instead of the release tag itself, sincegit ls-remote --tags | head -1has
no guarantee the plain and^{}lines for one tag sort adjacently. Filtered with
awk '!/\^\{\}$/ { print; exit }'before selecting — test: none (a shell-pipeline correctness fix,
no test harness coverscommands/*.mdprose). - CWK-120 row 5 —
rot-canary/SKILL.md's Fix-mode menu condition was self-contradictory: it opened
"in an interactive session" and then separately listed "no user is present" as a skip clause inside
that already-interactive scope. Reworded to one non-overlapping condition, matching the shared
escalation-footer.mdHook Context rule's own vocabulary — no behavior change, the menu still offers
on any interactive session (manual or hook-nudged) and stays report-only when non-interactive — test:
none (a legibility fix, no test harness reads SKILL.md prose for this condition). - CWK-120 row 6 —
scale-canary/SKILL.md's Fix-mode checkpoint instructedgit stash/git commit
as an ad-hoc backup, which can hide (stash) or include (commit) unrelated staged/unstaged user
work sitting in the same repo. Changed to: back up only the touched file(s), or use an isolated
worktree — nevergit stash/git commit— test: none (a prose safety-instruction fix). - CWK-120 row 7 —
telemetry-canary/SKILL.mdandtestability-canary/SKILL.md's Fix-mode
"auto-revert if newly red" had no baseline to compare against, so the agent could not tell a
post-edit failure was NEW versus already-failing before the edit. Both now record a build+test
baseline before applying, and auto-revert only on a failure that is new versus that baseline — test:
none (a prose safety-instruction fix). - CWK-120 row 23 —
rot-canary/references/tooling.md's Python row listed barepython -W error
as a project check, which runs no project code, tests, or static analysis (it starts the REPL in
an interactive shell). Replaced with the project's own test command +-W error(e.g.
python -m pytest -W error) — test: none (a reference-table content fix). - CWK-120 row 14 — the Antigravity auto-cadence status in
rot-canary/references/cadence.md
(shipped) cited only the 2026-07-12 pilot's live fire, omitting the 2026-08-04 isolated re-test that
recorded ZERO fires on a real AG 2.0 install.platform-configs/hooks/antigravity-hooks.json's own
$commentalready discloses both measurements; the shipped cadence reference and the root README's
primeddefinition andplatform-configs/hooks/README.md's AG row (neither ships intoplugin/)
now say the same: firing is UNRESOLVED, not verified, and a reader is told to probe their own copy
before relying on it — test: none (an accuracy fix; no fabricated resolution of the contested fact). - CWK-120 rows 22/24 — four
coalmine: verifiedreference stamps re-verified content-first, not
merely re-dated (drift-canary/references/checks.md,gold-standard/references/method.md,
telemetry-canary/references/checks.md,supply-chain-audit/references/tooling.md, allrevalidate 90d, all overdue since 2026-09-10): each file's content was re-read in full and confirmed still
accurate before its stamp moved to2026-09-22.skills/_shared/references/escalation.md(revalidate 30d, overdue since 2026-08-22) is left EXPIRED and undisposed here — its per-platform Heavy-tier
levers (Cursor Max Mode, Amp Oracle, GitHub Copilot/fleet, …) are exactly the fast-moving
version-sensitive claims this room's own doctrine says need a live source-grounding pass, not a
same-unit rubber-stamp; the file's own text already tells a reader to verify live rather than trust
it. Routed upward as a pending decision — test: none (stamp-and-content maintenance). - CWK-120 SAME-BATCH CLASS SWEEP — the two overclaims rows 2/15 and row 6 fixed on one surface each
stood uncorrected on their siblings, against this room's own MUST-class ONE FLOCK ONE COLOR rule
(AGENTS.md, consequence (1): a fix is swept to every sibling surface IN THE SAME BATCH).
Re-derived both surface sets fresh by grep rather than trusting the prior unit's own count (which
undercounted the second class by one). The config-cascade "project wins per key" overclaim —
checked againsthooks/_shared/node-config.js's real clamp code, not restated by feel — corrected
onplatform-configs/copilot-instructions.template,platform-configs/cursor.mdc.template,
commands/stats.md,commands/update.md, and the sharedskills/_shared/language-header.md
(renders into all 9 canarySKILL.mdbodies — the highest-blast-radius instance of this class).
skills/rot-canary/SKILL.md:38's own "project wins per key" is DIFFERENT and left alone: it
scopes toautoFixModealone, which is genuinely unclamped (not one of the 6SAFER_ENUM/
UNION_ARRAY_KEYSkeys) — the claim is true as written for that one key, not the same overclaim.
Thegit stash/git commitcheckpoint data-integrity hazard — corrected on
drift-canary/SKILL.md,rot-canary/SKILL.md,telemetry-canary/SKILL.md, and
testability-canary/SKILL.md(4 siblings, one more than the prior unit's own estimate of 3 —
drift-canarywas the uncounted instance). Same correction text as the exemplar fix in both
classes, no rewording en route — test: none (prose safety/precedence-instruction fixes, no test
harness reads SKILL.md/command prose for this content). - CWK-120 FINDINGS-BACK — the class sweep above shipped a clamp correction that was ITSELF wrong,
in the PERMISSIVE direction, on all 18 surfaces it touched. The new text read "…can only quieten,
never escalate, an explicit global…", implying the safety clamp does not bind when the global layer
is unset. Measured againsthooks/_shared/node-config.js:290
(const globalValue = globalVal !== undefined ? globalVal : def;): an ABSENT global reads as the
SCHEMA DEFAULT and the clamp still binds — with no global config at all, a project's
scanEverything: truestill resolves tofalse.README.md:189already states this correctly; the
swept text disagreed with this repo's own README. Corrected on the same 7 source files (7 →
18 with theirplugin/mirrors and shared-partial renders): "(a project can only quieten, never
escalate; an absent global reads as the schema default and is clamped the same way)" — derived from
README.md:189and the clamp code directly, not a third composed wording — test: none (prose
correctness fix;hooks/_shared/node-config.jsitself is untouched and its own test suite covers
the clamp behavior this text now accurately describes). - CWK-120 FINDINGS-BACK — row 7's build+test-baseline fix had a third, unswept sibling set, and two
files now contradicted THEMSELVES.telemetry-canary/SKILL.mdandtestability-canary/SKILL.md
had their Fix-mode bullet (:26) updated to require a baseline while their own grants table
(:34, eight lines below) still read "auto-revert if newly red" — an agent reading the second half
of the file got back the exact defect row 7 removed from the first half. Closed together with the
unswept class:drift-canary/SKILL.md,rot-canary/SKILL.md(both its Fix-mode bullet and its
standing-consent line), andscale-canary/SKILL.mdall gained the same baseline-before-revert
language, and all five files' grants tables now read "checkpoint → baseline → build+tests →
auto-revert only on a NEW failure" — test: none (prose safety-instruction fix, matching row 7's own
test disposition). - CWK-120 FINDINGS-BACK ROUND 2 — the round-1 baseline-class sweep (row 7 + its findings-back close)
had a THIRD unswept form, a comma/space spelling neither grep pass matched:gold-standard/SKILL.md
andresilience-audit/SKILL.md(2 sites) still read "checkpoint → [fix] → build+tests → revert if
newly red" with no baseline concept anywhere in either file. Closed the same way as the rest of the
class: both now record a build+test BASELINE before applying and revert only on a failure new versus
it, in both the Fix-mode bullet and (forresilience-audit) its grants-table row. A form-independent
sweep (grep -rln "revert\|rollback\|undo" skills/*/SKILL.md) confirms exactly these 7 files carry
the class now (drift-canary,gold-standard,resilience-audit,rot-canary,scale-canary,
telemetry-canary,testability-canary) and no eighth shape —supply-chain-auditis correctly
outside the class (checkpoint → apply → verify, no build/test revert step at all) — test: none
(prose safety-instruction fix, matching the rest of the class's own disposition). - CWK-120 FINDINGS-BACK ROUND 2 — the permissive-clamp correction (round 1's findings-back) read as
EXHAUSTIVE, and a real, pre-existing, code-side gap sits behind that reading.node-config.js:293
(if (gi === -1 || pi === -1) continue;) lets an unrecognized project value escape the clamp
entirely and win the plain merge — a value outside the enum, not merely a louder one inside it. The
ship-text fix is TEXT-ONLY, per the reviewer's own explicit bound (the clamp's behavior is a shipped
safety guard and is not changed here, unproven, at the...
v3.20.0 - both legacy project-config paths honoured and deprecated; a config at a non-candidate path is reported
Added
- A project config written where the walk does not read it is now REPORTED, not silently ignored (UMB-133). The session-start
conductor checks a fixed, closed list of plausible wrong homes --<project>/.agents/.coalmine.json,
<project>/.gemini/.coalmine.json,<project>/coal/coalmine.json(agent dir dropped) and
<project>/.claude/coalmine.json(coal/dropped) -- and, if one exists, adds one context line
IGNORED: <path> is not a config path; canonical = .claude/coal/coalmine.jsonfor the agent to relay (settings in
that file have NO effect). It rides the channel each mode already has (Claude Code session context, Antigravity
injectSteps, GeminiadditionalContext) -- no new channel, nothing on stderr (Phoenix #13). The probe is
existsSyncon those fixed paths: no directory walk, and the text around each path is constant, so a cloned repo
cannot steer what the line says. HONEST BOUND: a config anywhere outside that list is not reported -- this is
a report of the likely typos, not a search of the project.- Cost, stated: a project still on a legacy path (see Deprecated) now carries one extra context line per session
start until it migrates (roughly 40 tokens).
- Cost, stated: a project still on a legacy path (see Deprecated) now carries one extra context line per session
Deprecated
- Both legacy per-project config paths --
<project>/.claude/.coalmine.jsonand<project>/.coalmine.json(UMB-133).- Marker + replacement: both are marked DEPRECATED in the README's Configure section, which names the canonical path
<project>/.claude/coal/coalmine.jsonverbatim as the replacement. A canonical file always wins over both. - Window: deprecated at this MINOR, removable no earlier than the next MAJOR -- this series' own SemVer boundary
(scripts-quality.md§3), not a calendar count. Until then both keep being read, exactly as before. - Owner: CoalMine.
node scripts/configure.mjsmoves either legacy file to the canonical path on its next write
(nothing is moved on a mere read). - Channel: this entry and the README note. No hook prints a deprecation warning -- Phoenix #13 keeps hooks
silent on stdout/stderr, so nothing appears in the terminal. The one runtime signal is the conductor's single
migration-notice context line described under Added, sent only when the config actually read is a legacy one.
- Marker + replacement: both are marked DEPRECATED in the README's Configure section, which names the canonical path
Fixed
- A project config at
<project>/.claude/.coalmine.jsonwas silently ignored (UMB-133). It was never a candidate in the
per-project read order -- only the root dotfile was honoured as legacy -- so a config written where a user reasonably
expects it had no effect and nothing said so. The order is now canonical (own agent dir, then.agents, then.gemini),
then<project>/.claude/.coalmine.json, then<project>/.coalmine.json; first found wins, and the merge, the safer-value
clamp and the global layer are untouched.configure.mjsandinstall.mjshonour both shapes too (a writer blind to the
nested one would have written a fresh canonical file that shadowed it and silently dropped every setting in it).- A guard the change needed: when the project root IS the home directory,
<root>/.claude/.coalmine.jsonis the
GLOBAL config. It is compared by identity (both sides throughrealpathSync.native) and never treated as a project
config, so the walk does not anchor at~andconfigure.mjsdoes not migrate -- move and delete -- the file the
hooks read as the global layer. - The cascade wording on eight agent-instruction surfaces named only ONE legacy shape -- the shared language header
rendered into all nine skills,rot-canary's fix-mode rail,/coalmine:stats,/coalmine:updateand the four
platform-configs/*.templatefiles -- so an agent following it would have skipped a config the hook reads. All eight now
name both shapes, in order. - PowerShell fallback: not ported, and the gap is named (
alt/powershell/README.md): the twins still read only
<gitroot>/.coalmine.json, so the second legacy shape is one more config they do not see.
- A guard the change needed: when the project root IS the home directory,
- On the Antigravity and Gemini adapters the conductor read the project config from the hook process's own working
directory, not from the workspace it was reporting on (UMB-133). The workspace is named by the hook's stdin payload
(Antigravity:workspacePaths[0], falling back to the payload'scwd; Gemini: the payload'scwd). The conductor now
reads that workspace's project config, and the config gates follow it --enableConductor,disabledCanaries,
updateMode-- not only the new migration /IGNOREDlines. Before, the gates and the config both came from wherever
the hook process happened to start, while the lines beside them were computed for the workspace, so a workspace's
own config had no effect when the two differed. User-visible: on those two adapters, a workspace whose config sets
enableConductor: false, adisabledCanarieslist or anupdateModenow takes effect where it silently did not;
and a config sitting only in the hook process's start directory no longer governs a different workspace.- Unchanged: Claude Code and the file-copy platforms (they read from the process directory as before), a payload that
names no workspace (falls back to the process directory), androt-canary's own hooks (they still read from the
process directory). - The safer-value clamp is unaffected: it runs inside the config merge for whatever directory is read, so a
workspace config can quietenupdateModebut never escalate it past the global layer.
- Unchanged: Claude Code and the file-copy platforms (they read from the process directory as before), a payload that
- A Coal uninstall could delete a repo's own TRACKED hook files (CWK-096).*
uninstallGitHooks()
resolvescore.hooksPath(correct sinced1c917f) but then unlinked whatever it found there with
no tracked-ness check -- in any repo whosecore.hooksPathpoints at a versioned directory (this
room's own.githooks/included), that deleted the repo maintainer's tracked hooks. Tracked-ness is
now asked of git (git ls-files --error-unmatch), never inferred: a confirmedtrackedor an
unknown(could-not-tell) answer REFUSES loudly and exits non-zero, naming the file and why; only a
confirmeduntrackedanswer deletes. The question is asked only when the resolved hooks dir sits
INSIDE the worktree -- the ordinary<gitDir>/hookscase is untracked by construction and is
unaffected. Distributed via the Universal Installer (scripts/install.mjs), a surface outside
plugin/(Option B) -- same shipped-surface-outside-the-dist precedent asd1c917f.
v3.19.0 - ships the dist that had been sitting untagged, and closes a shell injection in the changed-paths classifier
Ships the dist that had been sitting untagged: 42 commits past v3.18.3 moved 14 files under plugin/ (all three hooks plus every skill body) while plugin.json still read 3.18.3 — and the publish event is the version-string bump on the default branch, so none of it reached claude plugin update until now.
MINOR, not patch: the release block carries two ### Added sections (per-workflow concurrency groups; the fully-suppressed-auto-scan notice with its scanEverything status), which is MINOR-minimum under this flock's own sizing rule.
New in this commit (comment only, no behaviour change) — CWK-087/CWK-088. The Stop hook's emit block now records the measured cost of its own loud channel. Measured on Claude Code 2.1.266 with a discriminating pair, one turn each:
| Stop hook emits | result |
num_turns |
|---|---|---|
decision:"block" + reason |
the hook's text — the session's own answer is gone | 2 |
systemMessage only |
the session's answer, intact | 1 |
The block is not a defect to silence — it is what makes the agent actually run the scan — and the hook has no honest way to detect a -p session: the Stop payload carries no mode field, hook stdio is never a TTY (the platform pipes the payload in and reads stdout back), and CLAUDE_CODE_ENTRYPOINT is inherited, so a -p child of an interactive session reads that parent's value. The consequence therefore belongs to the caller: read a child's report from disk, or run --output-format stream-json --verbose (verbose is required — the CLI refuses otherwise), which keeps the pre-hook answer as its own assistant event, delivers the hook's reason as a user event, and marks the blocked turn system/post_turn_summary with status_category:"blocked".
Everything else in this release shipped in earlier commits; the full list is in CHANGELOG.md under [3.19.0].
Gates: test.mjs 294 pass / 0 fail / 5 skipped · verify.mjs PASS · consistency.mjs PASS · release-gate.ps1 CoalMine PASS.
v3.18.3 - explicit mode on every shipped os.tmpdir() write
Every os.tmpdir() write in the shipped hooks now passes an explicit restrictive mode.
Security
- All six
os.tmpdir()writes now passmode: 0o600(CodeQLjs/insecure-temporary-file#66/#67). The alerts fired on v3.18.2's own sweep-marker hardening. Adjudicated by reading the query's own definition rather than assuming a false positive: it fires on a missingmodeargument, or a mode whose lowest 6 bits are nonzero, and reads neither thewxflag, nor the enclosing 0o700 directory, nor the lstat guards, nor filename randomness — so a random filename suffix would have closed nothing. The finding is real if low-impact: the stamp was created at default mode inside a directory whose own 0o700 is a no-op when it already exists, which is the residual the v3.18.2 round had recorded and accepted. TwoappendFileSyncwrites carrying the user's edited file paths were hardened too, although they sit outside the query's sink list — letting a scanner's sink list draw the threat boundary would be the tail wagging the dog.flag: 'wx'is preserved at every site that had it.
Notes
- The mode assertion skips on Windows and exercises on the Linux and macOS CI legs: NTFS reports the same bits either way, so the check cannot discriminate there. The skip is visible and named, never a silent pass.
Back-filled 2026-08-31: body written from the CHANGELOG [3.18.3] entry's own headings.
v3.18.2 - sweep-marker symlink hardening
Hardens the rot-canary sweep marker so a planted symlink can no longer redirect it.
Security
rot-canary Stop hook: the tmp-sweep throttle marker no longer writes through a planted symlink (CWK-031/U8, CoalBoard nasa-L3 round). The 24h throttle marker sat flat in the shared tmp root under a fixed name, written with the default w flag, which follows a symlink at the destination. On Unix the shared /tmp let any local user pre-plant a link at that name; the victim next Stop truncated the link target (truncate-to-empty / empty-file creation, not arbitrary content).
Fixed by moving the marker into the private os.tmpdir()/coalmine/ subdir (mode 0700 + the lstat dir guard) and re-stamping via a per-pid temp + rename rather than a plain write. A symlink at the marker path is never obeyed as a gate, so it cannot suppress the sweep either.
Full detail: CHANGELOG.md [3.18.2].
Full Changelog: v3.18.1...v3.18.2
v3.18.1 - SKILL.md body-lean campaign pass
Compression pass over the skill bodies: one real cut, eight already lean.
Fixed
SKILL.md body-lean campaign (campaign #6 unit 6, belt 2/board 2). Measured via before touching anything; 8 of 9 skills declared already-lean (no fat found, no forced cuts); gold-standard (the one skill over the ~5000-tok body budget) had a Discipline section restating Prohibitions P10/P11/P12 and Degrade-path D1 near-verbatim -- collapsed to two lines pointing at the ledgers, matching this room's own established Dedup convention.
Full 3-tier variance walk (weak/medium/strong, 11 leaves) on the changed prose: zero variance on every enumerated rail.
Full detail: CHANGELOG.md [3.18.1].
Full Changelog: v3.18.0...v3.18.1
v3.18.0 - CLASSIFY-BLOCK grants sections on 8 skills
Added
Retrofits (CLASSIFY-BLOCK, board #93) onto 8 of CoalMine's 9 skills: a declared "Grants & denials" table stating each skill's read/write(/network) grants and what a permission denial does (report + never claim done, never a false clean bill) -- source-grounding deliberately excluded (read+network only, its own D1 already covers the one hot class).
New shared partial skills/_shared/classify-block.md injected via a new <!-- SHARED:CLASSIFY_BLOCK --> marker in render.mjs; verify.mjs now enforces both content-match AND presence for the marker across all 9 skills.
Full findings-back + RE-INSPECT trail (INSPECT: 3 MEDIUM + 2 LOW, all closed, SHIP verdict) in CHANGELOG.md.
Full Changelog: v3.17.4...v3.18.0
Fixed
scripts/lib/claude-ai-trim.mjs'strimDescription()could split a non-BMP character mid-surrogate-pair (board #40 fixback F3), leaving a lone high surrogate that decodes toU+FFFD. Nothing is live today -- every shipped description is English with ASCII spaces -- but this org writes Thai, so it is a when-not-if fix. Fixed by dropping the one trailing lone high surrogate before the whitespace rescue, chosen over a code-point rewrite that would change what "200 chars" means. Does not reachplugin/; it does change what a user downloads from the Release. New regression test, confirmed red before the fix.
Back-filled 2026-08-31: body re-shaped to the CHANGELOG [3.18.0] entry's own headings; written from that entry.
v3.17.4 - claude-ai-trim surrogate-pair fix
Least-privilege and workflow-guard fixes, plus three CodeQL alerts dismissed with corrected reasoning.
Fixed
- claude-ai-trim.mjs surrogate-pair fix (board #40 fixback F3) -- a non-BMP character at the trim boundary could split mid-surrogate-pair with no ASCII word break to rescue it.
- Least-privilege token on claude-ai-zips.yml (board #117/#118): contents: write moved off the top level onto the one job that needs it; a workflow_dispatch-on-main hole closed (a re-run against the default branch could no longer attempt a Release tagged "main").
- create-or-update release-publishing tooling (scripts/lib/publish-release.mjs) plus its own 422-already-exists race-window close.
See CHANGELOG.md for full detail.
v3.17.3 - three more hook config gates clamped
Closes board #112's own follow-up: three more hook-read config gates now honor the same safer-value-wins clamp as updateMode, in both Node and PowerShell.
Security
enableConductor,disabledCanaries, androtCanaryMode(+ their legacy aliasesconductor/disable/mode) join the merge's safer-value-wins clamp — closing board #112's own next-touch set (board #113). Before this, a project-only.coalmine.jsoncould silently re-enable a globally-disabled canary, flip the conductor back on, or re-arm auto-scanning past an explicit global choice.disabledCanariesclamps by UNION: the safer array direction is union, never pick-one-side), not by ordered enum.- Two escalation holes found auditing the fix, both closed: a legacy key name (e.g.
disablefordisabledCanaries) could bypass the new-key clamp entirely; and the merge was storing the raw-cased winning value instead of the canonical enum member, which could silently fail a strict===comparison downstream. - Ported to the PowerShell twin (
ps-config.ps1), all three keys plus legacy aliases. - Findings-back:
main's CI had been red sincev3.17.0— the gate-wired PowerShell test suite was still asserting board #112's pre-fix behavior, and nobody caught it locally becausepwshis absent on the dev box (the pre-commit gate silently skips it there). Fixed.
Back-filled 2026-08-31: body re-shaped to the CHANGELOG [3.17.3] entry's own headings; written from that entry.