c55e14b626
Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com
183 lines
8.3 KiB
JavaScript
183 lines
8.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* enforce-llm-judge-per-tool — PreToolUse wrapper around the pure
|
|
* llm-judge-per-tool engine (router-gate v4.1 §4.7 Layer 4).
|
|
*
|
|
* The engine (llm-judge-per-tool.mjs) asks a single Sonnet judge whether a
|
|
* mutating tool call is consistent with the declared user task + recommended
|
|
* skill scope (NO / doubt → block). Running it costs real LLM money, so the
|
|
* judge MUST stay OFF until the owner deliberately activates Layer 4. This
|
|
* wrapper is the missing seam between the engine and settings.json, built — like
|
|
* the sibling Stream H wrappers (enforce-safe-baseline-metering / -decomposition-
|
|
* detector) — with a testable pure `decide()` and a DELIBERATE no-op `main()`.
|
|
*
|
|
* Activation (step 2b — owner-driven, NOT done here):
|
|
* 1. store the API key (keychain `router-gate-llm-judge`/`default` or ROUTER_LLM_KEY),
|
|
* 2. set ROUTER_LLM_JUDGE_ENABLED=1,
|
|
* 3. register this hook (PreToolUse, block) in .claude/settings.json.
|
|
* Until all three, decide() short-circuits to allow on a disabled config and the
|
|
* live main() is a no-op (exit 0) — $0, no LLM call, no self-lockout.
|
|
*/
|
|
import { judgePerTool, MUTATING_TOOLS, readDeclaredTask, resolveEffectiveTask } from './llm-judge-per-tool.mjs';
|
|
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
|
import { readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET, llmJudgeCall } from './llm-judge.mjs';
|
|
import { readStdin, parseEventJson, exitDecision, readTranscript, lastUserPromptText, logJudgeVerdict } from './enforce-hook-helpers.mjs';
|
|
import { classifyBashCommand } from './enforce-router-gate.mjs';
|
|
|
|
/**
|
|
* Pure decision. Composes the Layer-4 enabling-gate (resolveJudgeConfig output)
|
|
* with the per-tool judge engine:
|
|
* - non-mutating tool → allow (out of judge scope)
|
|
* - judge disabled / no key → allow + degraded flag (Layer 4 off, $0)
|
|
* - judge enabled → delegate to judgePerTool (YES → allow; NO / doubt → block)
|
|
*
|
|
* @param {object} args
|
|
* @param {object} args.event - PreToolUse event ({ tool_name, tool_input })
|
|
* @param {{enabled:boolean, apiKey:?string}} args.judgeConfig - resolveJudgeConfig() output
|
|
* @param {object} [args.declaredTask] - { task_summary, recommended_node, recommended_chain }
|
|
* @param {object} [args.budgetState] - { spent, limit } per-session judge budget
|
|
* @param {Function} [args.llmJudgeCallImpl] - injected single-judge caller (tests / real binding)
|
|
* @returns {Promise<{block:boolean, reason?:string, degraded?:boolean, verdict?:string|null}>}
|
|
*/
|
|
export async function decide({
|
|
event,
|
|
judgeConfig,
|
|
declaredTask = {},
|
|
budgetState,
|
|
llmJudgeCallImpl,
|
|
}) {
|
|
const toolName = event && event.tool_name;
|
|
if (!MUTATING_TOOLS.has(toolName)) {
|
|
return { block: false, reason: 'non-mutating tool — outside per-tool judge scope' };
|
|
}
|
|
if (!judgeConfig || !judgeConfig.enabled) {
|
|
return { block: false, degraded: true, reason: 'Layer 4 judge disabled' };
|
|
}
|
|
return judgePerTool({
|
|
toolName,
|
|
toolInput: (event && event.tool_input) || {},
|
|
declaredTask,
|
|
apiKey: judgeConfig.apiKey,
|
|
budgetState,
|
|
llmJudgeCallImpl,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Testable wiring core. Composes resolveJudgeConfig output + decide(); bumps the
|
|
* session budget ONLY when a real judge call was made (result carries a verdict).
|
|
* No verdict ⇒ non-mutating / disabled / no-key / budget-exhausted ⇒ no spend.
|
|
*/
|
|
/**
|
|
* Calibration 2026-05-31 (SCOPE fix, NOT a discipline drop): readonly Bash
|
|
* commands ("смотрелки" — git status/log/diff, cat, grep, ls) change nothing,
|
|
* so they are outside the "judge on mutating tools" scope. Reuse the router-gate
|
|
* Bash classifier: an allow-verdict whose reason mentions readonly/reading is a
|
|
* no-state-change command. Everything that can mutate (file edits, git
|
|
* commit/push, dangerous Bash, Skill/Task) is unaffected — doubt→block stands.
|
|
*/
|
|
export function isReadonlyBashEvent(event) {
|
|
if (!event || event.tool_name !== 'Bash') return false;
|
|
const command = (event.tool_input && event.tool_input.command) || '';
|
|
if (!command) return false;
|
|
try {
|
|
const c = classifyBashCommand(command, {});
|
|
return !!c && c.result === 'allow' && /readonly|reading/i.test(c.reason || '');
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calibration 3 (2026-05-31, SCOPE fix, NOT a discipline drop): a test run
|
|
* (vitest / pest / phpunit / php artisan test / composer test / npm test) only
|
|
* inspects the code and reports pass/fail — it mutates no protected state, and
|
|
* running tests is a MANDATORY step of TDD which the rules require. Treat such
|
|
* commands like readonly Bash: outside the mutating-tool judge scope. A command
|
|
* that chains to anything else (&& / ; / | / backtick / $( ) is NOT exempt and
|
|
* stays judged — the exemption covers a pure test invocation only.
|
|
*/
|
|
const TEST_RUNNER_RE =
|
|
/^(?:npx\s+)?vitest(?:\s|$)|^(?:\.\/)?(?:node_modules\/\.bin\/|vendor\/bin\/)?pest(?:\s|$)|^(?:\.\/)?vendor\/bin\/phpunit(?:\s|$)|^php\s+artisan\s+test(?:\s|$|:)|^composer\s+test(?::\S+)?(?:\s|$)|^npm\s+(?:run\s+)?test(?::\S+)?(?:\s|$)/i;
|
|
|
|
export function isTestRunnerBashEvent(event) {
|
|
if (!event || event.tool_name !== 'Bash') return false;
|
|
const command = ((event.tool_input && event.tool_input.command) || '').trim();
|
|
if (!command) return false;
|
|
// Exemption is for a pure test run only — reject anything chaining to another command.
|
|
if (/[;&|`]/.test(command) || command.includes('$(')) return false;
|
|
return TEST_RUNNER_RE.test(command);
|
|
}
|
|
|
|
export async function runPerTool({
|
|
event,
|
|
judgeConfig,
|
|
readDeclaredTaskImpl,
|
|
readLastUserPromptImpl,
|
|
readBudgetImpl,
|
|
bumpBudgetImpl,
|
|
llmJudgeCallImpl,
|
|
logVerdictImpl = () => {},
|
|
sessionBudget = JUDGE_SESSION_BUDGET,
|
|
}) {
|
|
// Readonly Bash never mutates → outside the judge's scope; skip (no LLM call, no spend).
|
|
if (isReadonlyBashEvent(event)) {
|
|
return { block: false, reason: 'readonly bash — outside mutating-tool judge scope (calibration 2026-05-31)' };
|
|
}
|
|
// Test-runner Bash only inspects + reports; mandatory TDD step → outside scope (calibration 3).
|
|
if (isTestRunnerBashEvent(event)) {
|
|
return { block: false, reason: 'test-runner bash — outside mutating-tool judge scope (calibration 3, 2026-05-31)' };
|
|
}
|
|
const sessionId = event && event.session_id;
|
|
const declaredTask = readDeclaredTaskImpl({ sessionId });
|
|
// Calibration 4 (soft): only when the classifier summary is unknown/empty,
|
|
// consult the user's actual last prompt and judge against that instead.
|
|
let effectiveTask = declaredTask;
|
|
const summary = declaredTask && declaredTask.task_summary;
|
|
const summaryUnknown = !summary || summary === '(unknown)' || !String(summary).trim();
|
|
if (summaryUnknown && typeof readLastUserPromptImpl === 'function') {
|
|
const lastPrompt = readLastUserPromptImpl({ transcriptPath: event && event.transcript_path });
|
|
effectiveTask = resolveEffectiveTask(declaredTask, lastPrompt);
|
|
}
|
|
const spent = readBudgetImpl({ sessionId });
|
|
const result = await decide({
|
|
event,
|
|
judgeConfig,
|
|
declaredTask: effectiveTask,
|
|
budgetState: { spent, limit: sessionBudget },
|
|
llmJudgeCallImpl,
|
|
});
|
|
if (result.verdict !== undefined) {
|
|
bumpBudgetImpl({ sessionId, by: 1 });
|
|
logVerdictImpl({ sessionId, tool: event && event.tool_name, verdict: result.block ? 'block' : (result.verdict ?? 'YES') });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function main() {
|
|
// Live wiring (2b): spend is gated by resolveJudgeConfig (flag AND key). With
|
|
// the flag off or no key, decide() short-circuits to a degraded allow — NO LLM
|
|
// call, $0. Fail-quiet so a judge bug can never wedge the session.
|
|
try {
|
|
const event = parseEventJson(await readStdin());
|
|
const judgeConfig = resolveJudgeConfig();
|
|
const result = await runPerTool({
|
|
event,
|
|
judgeConfig,
|
|
readDeclaredTaskImpl: readDeclaredTask,
|
|
readLastUserPromptImpl: ({ transcriptPath }) => lastUserPromptText(readTranscript(transcriptPath)),
|
|
readBudgetImpl: readJudgeBudget,
|
|
bumpBudgetImpl: bumpJudgeBudget,
|
|
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
|
logVerdictImpl: ({ sessionId, tool, verdict }) => logJudgeVerdict(sessionId, { tool, verdict }),
|
|
});
|
|
exitDecision({ block: result.block, message: result.reason });
|
|
} catch {
|
|
exitDecision({ block: false });
|
|
}
|
|
}
|
|
|
|
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-per-tool.mjs')) {
|
|
main().catch(() => process.exit(0));
|
|
}
|