Files
portal/tools/enforce-llm-judge-per-tool.mjs
T
2026-05-31 19:05:20 +03:00

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));
}