Skip to content

Commit 69a9b2a

Browse files
HetCreepclaude
andcommitted
fix: CoalTipple v1.0.11 — vuln-hunt fixes (safety gate, language backstop, config honesty)
A comprehensive vulnerability hunt (4 parallel scanners + an adversarial work-review). 9 confirmed fixes + a plural false-negative caught at the commit-gate: - resolveWorker: the never-down sensitive gate could be breached by a mis-cased / typo'd floor (indexOf -1 -> Math.max(-1,0)=0 collapsed a sensitive task to the cheapest tier); now case-normalized + fail-safe (an unrecognized floor returns null, never the floor). - Non-English sensitive prompts lost the deterministic flag (English-only keywords): the Step-2 HARD GATE now names the model the sensitive-gate authority for non-English, and the conductor injects a generic non-Latin-script nudge. - mode + per-domain disableRouting were documented but dead; now wired (the sensitive HARD GATE overrides mode). - grade matcher over-matched (token->tokenizer) then under-matched: fixed via a stem (*) vs whole-word convention, with the common plurals (tokens/secrets/passwords/sessions/ payments/deadlocks/mutexes) listed so a plural no longer escapes the never-down flag. - modelTiers pin doc cheap -> low; project config now anchors at the git root (not cwd); strict validateRanking (rejects array/{}/missing-key/non-array/all-empty/complete-truthy); verify.mjs uses the shared stripJsonc; grade() degrades on null input. 110 tests, verify PASS. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2d2c737 commit 69a9b2a

18 files changed

Lines changed: 431 additions & 46 deletions

File tree

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coaltipple",
3-
"version": "1.0.10",
3+
"version": "1.0.11",
44
"description": "CoalTipple — model/effort router for Claude Code. Delegates a task you CAN do but is large+cheap DOWN to a lower tier to save tokens; escalates a task beyond the current tier UP for quality. Two-knob routing (tier × effort, effort-before-tier), introspection-first model classification, a validity-gated ranking Lock that never routes on broken state, and git-optional damage control. Advise-only Phoenix-pure hook; the model performs the routing.",
55
"author": { "name": "HetCreep" },
66
"homepage": "https://github.com/TheColliery/CoalTipple",

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,26 @@
22

33
All notable changes to CoalTipple are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow SemVer (the canonical version lives in `.claude-plugin/plugin.json`).
44

5+
## [1.0.11] - 2026-06-18
6+
7+
A comprehensive vulnerability hunt (4 parallel scanners + an adversarial work-review pass) — safety-gate, routing-correctness, config-honesty, and worldwide-language fixes.
8+
9+
### Fixed
10+
11+
- **The never-down sensitive gate could be breached by a mis-cased / typo'd floor.** `resolveWorker` (classify.mjs) matched `floorTier` case-sensitively, so `'Heavy'` or a typo fell through `indexOf → -1 → Math.max(-1,0) = 0` and collapsed a SENSITIVE task to the *cheapest* tier under a limit-hit. Now case-normalized + fail-safe: an unrecognized floor returns `null` (hand back), never the floor.
12+
- **Non-English sensitive prompts lost the deterministic safety flag.** The keyword grader + the conductor hint match English literals only, so a Thai/CJK/Arabic prompt meaning "scan for bugs" / "constant-time compare" fired no flag — the "keyword is the gate" backstop silently vanished. The Step-2 HARD GATE now states the model is the sensitive-gate authority for non-English (grade by MEANING), and the conductor injects a generic non-English nudge on non-Latin script. (The model layer has been multilingual since 1.0.9; this closes the *deterministic* backstop.)
13+
- **`mode` and per-domain `disableRouting` were documented but dead.** `mode:"off"` still routed; `disableRouting:["coding"]` did nothing. Both are now wired (SKILL + conductor): `mode` constrains direction (`auto`/`delegation`/`escalation`/`off`, the sensitive HARD GATE overriding it), and per-domain disable is honored.
14+
- **The grade keyword matcher over-matched, then a fix under-matched.** A missing trailing word-boundary let `token`→"tokenizer", `crypto`→"cryptocurrency" wrongly grade sensitive. Fixed with a stem (`*`) vs whole-word convention — and the common plurals (`tokens`/`secrets`/`passwords`/`sessions`/`payments`/`deadlocks`/`mutexes`) are now listed so a plural no longer escapes the never-down flag.
15+
- **The `modelTiers` pin doc named a non-existent tier.** The `--help`/schema text said `cheap` (silently dropped by `applyPins`); the real cheapest tier is `low`.
16+
- **Project config could be read from the wrong directory.** `config-load.mjs` resolved from `process.cwd()` while the conductor + configure used the git root — a subdir cwd read a different file. All three now anchor at the git root (git stays optional).
17+
- **`validateRanking` blessed broken rankings.** An array, `{}`, a missing key, or `complete` merely truthy passed the Lock, letting `resolveWorker` return `null` for every tier (routing dead while the Lock read green). Now strict: every tier present + an array, `complete === true`, ≥1 non-empty.
18+
- **`verify.mjs` used a third, divergent JSONC parser** instead of the shared `stripJsonc` — the gate now validates with the same parser runtime uses.
19+
- **`grade()` threw on null input** (`{files:null}`, etc.); a boundary authority now degrades instead of crashing.
20+
21+
### Added
22+
23+
- Regression tests across every fix — the case-insensitive/fail-safe floor, the non-English nudge, `mode:"off"`, the stem/whole-word + plural matching (A/B both directions), strict `validateRanking`, the git-root config anchor, and null-input degradation. 110 tests.
24+
525
## [1.0.10] - 2026-06-18
626

727
Keyword/config-precision hardenings, caught by the 3-sub review pass.

hooks/coaltipple-conductor.js

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const os = require('os');
1919
// file may be missing/corrupt — each is read in isolation and contributes nothing
2020
// on failure, so the merge always yields the best available config (never throws).
2121
// Inlined (not imported) to keep the hook standalone-portable (Phoenix #9).
22+
// Keep findGitRoot byte-identical to scripts/lib/config-load.mjs + configure.mjs
23+
// (verify.mjs's config-path-sync gate guards the drift).
2224
function findGitRoot(startDir) {
2325
let dir = path.resolve(startDir);
2426
while (true) {
@@ -59,16 +61,48 @@ function loadCfg() {
5961
// fuller file-aware grade). The grader/hint below fires ONLY on a hot
6062
// keyword; the always-on routing forcer in main() fires on every prompt. ---
6163
// <coaltipple-shared: hot-keywords> — synced from scripts/lib/keywords.mjs by build-plugin.mjs; edit keywords.mjs, NOT this block
62-
const HOT5 = ['concurrency', 'mutex', 'race condition', 'deadlock', 'thread-saf', 'atomic', 'crypto', 'timing attack', 'timing-attack', 'constant-time', 'constant time', 'timing-safe', 'side-channel', 'encrypt', 'decrypt', 'mathematical proof', 'formal proof', 'derive equation', 'complexity bound'];
63-
const HOT4 = ['oauth', 'authenticat', 'authoriz', 'auth bypass', 'sql injection', 'access control', 'permission', 'secret', 'token', 'password', 'session', 'migration', 'schema change', 'payment', 'billing', 'rate limit', 'optimize query', 'bug scan', 'scan for bugs', 'find bugs', 'find all bugs', 'security audit', 'security review', 'vulnerability scan', 'audit the codebase', 'code audit', 'legal contract', 'compliance', 'license terms', 'financial audit', 'tax filing', 'valuation', 'medical diagnosis', 'clinical diagnosis', 'dosage', 'clinical trial', 'gdpr', 'hipaa', 'pii'];
64+
const HOT5 = ['concurrency', 'mutex', 'mutexes', 'race condition', 'deadlock', 'deadlocks', 'thread-saf*', 'atomic', 'crypto', 'cryptographic', 'cryptography', 'timing attack', 'timing-attack', 'constant-time', 'constant time', 'timing-safe', 'side-channel', 'encrypt*', 'decrypt*', 'mathematical proof', 'formal proof', 'derive equation', 'complexity bound'];
65+
const HOT4 = ['oauth', 'authenticat*', 'authoriz*', 'auth bypass', 'sql injection', 'access control', 'permission*', 'secret', 'secrets', 'token', 'tokens', 'password', 'passwords', 'session', 'sessions', 'migrat*', 'schema change', 'payment', 'payments', 'billing', 'rate limit', 'optimize query', 'bug scan', 'scan for bugs', 'find bugs', 'find all bugs', 'security audit', 'security review', 'vulnerability scan', 'audit the codebase', 'code audit', 'legal contract', 'compliance', 'license terms', 'financial audit', 'tax filing', 'valuation', 'medical diagnosis', 'clinical diagnosis', 'dosage', 'clinical trial', 'gdpr', 'hipaa', 'pii'];
6466
// </coaltipple-shared: hot-keywords>
67+
// Match a hot keyword with the SAME stem-vs-whole-word convention as the grader
68+
// (grade.mjs includesAny): a trailing `*` = STEM (prefix, leading \b only); a bare
69+
// word = WHOLE-WORD (leading + trailing \b), so the conductor's hint can't over-match
70+
// where the grader doesn't (token -> tokenizer, crypto -> cryptocurrency). `t` is lowercased.
71+
function matchKw(t, list) {
72+
for (const k of list) {
73+
const raw = String(k).toLowerCase();
74+
const stem = raw.endsWith('*');
75+
const word = (stem ? raw.slice(0, -1) : raw).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76+
if (!word) continue;
77+
if (new RegExp('\\b' + word + (stem ? '' : '\\b')).test(t)) return true;
78+
}
79+
return false;
80+
}
6581
function hintFor(prompt) {
6682
const t = String(prompt || '').toLowerCase();
67-
if (HOT5.some((k) => t.includes(k))) return { grade: 5, tier: 'reasoning', why: 'reasoning-hard keyword' };
68-
if (HOT4.some((k) => t.includes(k))) return { grade: 4, tier: 'heavy', why: 'sensitive keyword' };
83+
if (matchKw(t, HOT5)) return { grade: 5, tier: 'reasoning', why: 'reasoning-hard keyword' };
84+
if (matchKw(t, HOT4)) return { grade: 4, tier: 'heavy', why: 'sensitive keyword' };
6985
return null;
7086
}
7187

88+
// The deterministic HOT keyword flags are ENGLISH literals, so a non-English prompt
89+
// matches NOTHING and the keyword sensitive-gate silently vanishes for it. Detect a
90+
// non-Latin SCRIPT char — anything outside Basic Latin + Latin-1 Supplement + Latin
91+
// Extended-A/B (code point <= 0x24F), while excluding the General Punctuation block
92+
// (0x2000-0x206F: em-dash / smart quotes / ellipsis, common in English text) so an
93+
// English prompt with typographic punctuation does NOT trigger a false nudge. Catches
94+
// Thai / CJK / Arabic / Cyrillic / Hebrew / Devanagari / etc. The character class is
95+
// BUILT from char codes — never a raw high-Unicode literal in source (the tool layer
96+
// mangles those; this hook must stay deterministic + portable, Phoenix #8/#9).
97+
const NON_LATIN_RE = (() => {
98+
const cc = String.fromCharCode;
99+
const cls = '[^' + cc(0x00) + '-' + cc(0x24f) + cc(0x2000) + '-' + cc(0x206f) + ']';
100+
return new RegExp(cls);
101+
})();
102+
function hasNonLatinScript(prompt) {
103+
return NON_LATIN_RE.test(String(prompt || ''));
104+
}
105+
72106
// The contract is model-facing English (the model reads it fine); the language
73107
// LEVER is a directive telling the model what language to PRODUCE for the user —
74108
// the same approach CoalMine's conductor uses. cfg.language drives it, so the
@@ -86,6 +120,7 @@ function contract(cfg) {
86120
'- DELEGATE-DOWN a task you can do but is large + cheap, to a lower tier — ONLY with a compact task-contract (goal+constraints+interface+done) AND verify the returned output on merge. Skip it for small tasks (spawn overhead beats the saving).',
87121
'- ESCALATE-UP a task beyond the current tier for quality. Workers are leaves by policy (routing stays depth-0): give each a bounded task-contract so it RETURNS rather than spawning its own workers; a worker that fails RETURNS its result and the MAIN re-routes.',
88122
'- Grade by the deterministic rubric, not a model self-assessment. Opus is scarce: cheapest lever first - raise effort, then a stronger same-tier version (e.g. Opus 4.6 -> 4.8), before escalating the tier.',
123+
'- mode (.coaltipple.json, default auto): auto = route both directions per grade; delegation = delegate-down only (escalate-up suppressed, a budget-saving mode); escalation = escalate-up only (delegate-down suppressed, a quality mode); off = routing off, do it yourself. The sensitive HARD GATE overrides mode (sensitive is still never-down and may always escalate up).',
89124
'- Honor qualityBar (.coaltipple.json, 0-100, default 60): a result must clear it or climb the model ladder — start at the grade floor, verify vs the contract done-criteria by domain-appropriate means (code: tests/build; text: completeness; research: sourced claims), climb one rung if short, jump to the top tier if far below or out of attempts. 0 = anything passes (cheapest); 100 = climb until best.',
90125
langLine(cfg),
91126
'- Consent + token spend: honor .coaltipple.json; never silently fan out costly work.',
@@ -96,9 +131,18 @@ function readStdin() {
96131
try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
97132
}
98133

134+
// Routing is OFF when the master switch is off OR mode is "off" (do-it-yourself).
135+
// Both silence the SessionStart contract AND the per-prompt forcer — the off switch.
136+
function routingOff(cfg) {
137+
if (!cfg) return false;
138+
if (cfg.enableRouting === false || cfg.routing === false) return true; // legacy key honored
139+
if (typeof cfg.mode === 'string' && cfg.mode.toLowerCase() === 'off') return true; // mode:"off" short-circuit
140+
return false;
141+
}
142+
99143
function main() {
100144
const cfg = loadCfg();
101-
if (cfg && (cfg.enableRouting === false || cfg.routing === false)) return; // legacy key honored
145+
if (routingOff(cfg)) return;
102146
const disabled = cfg && cfg.disableRouting;
103147
if (Array.isArray(disabled) && disabled.includes('all')) return;
104148

@@ -107,9 +151,14 @@ function main() {
107151
const event = input.hook_event_name || input.hookEventName || '';
108152

109153
if (event === 'UserPromptSubmit') {
110-
const h = hintFor(input.prompt || input.user_prompt || '');
154+
const prompt = input.prompt || input.user_prompt || '';
155+
const h = hintFor(prompt);
111156
const hint = h ? ` Complexity hint: grade ${h.grade} (${h.why}) -> start tier "${h.tier}"; fold into the grade, then the result must clear qualityBar or routing climbs the ladder.` : '';
112-
process.stdout.write(`[CoalTipple] Route BEFORE acting on this prompt: apply the coaltipple routing contract (SKILL.md) -- grade the task, then delegate-down (large + cheap), escalate-up (beyond this tier), or keep-on-self, per the rubric. Routing actuates on Claude Code only.${hint}`);
157+
// The English HOT-keyword hint cannot fire on a non-English prompt; add ONE generic
158+
// deterministic nudge so the sensitive-gate backstop is not silently lost there.
159+
// Complements (never replaces) the English hint above.
160+
const nonEnglish = hasNonLatinScript(prompt) ? ' Non-English prompt -- grade by MEANING and apply the sensitive-gate by intent; the English keyword flags will not fire.' : '';
161+
process.stdout.write(`[CoalTipple] Route BEFORE acting on this prompt: apply the coaltipple routing contract (SKILL.md) -- grade the task, then delegate-down (large + cheap), escalate-up (beyond this tier), or keep-on-self, per the rubric. Routing actuates on Claude Code only.${hint}${nonEnglish}`);
113162
return;
114163
}
115164
// SessionStart (and any non-prompt event) -> inject the routing contract.

platform-configs/.coaltipple.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
"rankingMode": "auto",
9797

9898
// ## Optional pins that override auto-classification — e.g. when introspection is unclear (a renamed flagship).
99-
// # Setting type: Object { low | mid | heavy | reasoning | local : "model" | ["priority", "chain"] }
99+
// # Setting type: Object { local | low | mid | heavy | reasoning : "model" | ["priority", "chain"] } (cheapest tier is `low`, not `cheap`)
100100
// # Default value: unset (auto-classify by introspection; the heuristic floor is the last resort)
101101
// "modelTiers": { "low": "haiku", "mid": "sonnet", "heavy": "opus", "reasoning": ["Fable 5", "opus"] },
102102

@@ -138,10 +138,10 @@
138138
// # The value below is GENERATED from keywords.mjs (the SSoT) by build-plugin.mjs; verify.mjs fails on drift.
139139
// <coaltipple-shared: keywords>
140140
"keywords": {
141-
"concurrency": { "grade": 5, "words": ["concurrency", "mutex", "race condition", "deadlock", "thread-saf", "atomic"] },
142-
"crypto": { "grade": 5, "sensitive": true, "words": ["crypto", "timing attack", "timing-attack", "constant-time", "constant time", "timing-safe", "side-channel", "encrypt", "decrypt"] },
143-
"security": { "grade": 4, "sensitive": true, "words": ["oauth", "authenticat", "authoriz", "auth bypass", "sql injection", "access control", "permission", "secret", "token", "password", "session"] },
144-
"coding": { "grade": 4, "sensitive": true, "words": ["migration", "schema change", "payment", "billing", "rate limit", "optimize query"] },
141+
"concurrency": { "grade": 5, "words": ["concurrency", "mutex", "mutexes", "race condition", "deadlock", "deadlocks", "thread-saf*", "atomic"] },
142+
"crypto": { "grade": 5, "sensitive": true, "words": ["crypto", "cryptographic", "cryptography", "timing attack", "timing-attack", "constant-time", "constant time", "timing-safe", "side-channel", "encrypt*", "decrypt*"] },
143+
"security": { "grade": 4, "sensitive": true, "words": ["oauth", "authenticat*", "authoriz*", "auth bypass", "sql injection", "access control", "permission*", "secret", "secrets", "token", "tokens", "password", "passwords", "session", "sessions"] },
144+
"coding": { "grade": 4, "sensitive": true, "words": ["migrat*", "schema change", "payment", "payments", "billing", "rate limit", "optimize query"] },
145145
"audit": { "grade": 4, "words": ["bug scan", "scan for bugs", "find bugs", "find all bugs", "security audit", "security review", "vulnerability scan", "audit the codebase", "code audit"] },
146146
"math": { "grade": 5, "words": ["mathematical proof", "formal proof", "derive equation", "complexity bound"] },
147147
"knowledge": { "grade": 3, "words": ["systematic review", "literature review", "citation", "claim verification"] },

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "coaltipple",
3-
"version": "1.0.10",
3+
"version": "1.0.11",
44
"description": "CoalTipple — model/effort router for Claude Code. Delegates a task you CAN do but is large+cheap DOWN to a lower tier to save tokens; escalates a task beyond the current tier UP for quality. Two-knob routing (tier × effort, effort-before-tier), introspection-first model classification, a validity-gated ranking Lock that never routes on broken state, and git-optional damage control. Advise-only Phoenix-pure hook; the model performs the routing.",
55
"author": { "name": "HetCreep" },
66
"homepage": "https://github.com/TheColliery/CoalTipple",

0 commit comments

Comments
 (0)