feat(router-gate-v4): LLM-judge per-tool + response-scan hook wrappers (Stream H tail)

This commit is contained in:
Дмитрий
2026-05-30 19:59:42 +03:00
parent c805988085
commit ca52d354f9
4 changed files with 308 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
#!/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 } from './llm-judge-per-tool.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,
});
}
async function main() {
// Deferred no-op (exit 0). Activating Layer 4 (key + flag + settings.json
// registration) is a separate owner-driven step; until then this wrapper makes
// NO LLM call and NEVER blocks — $0, no self-lockout (same posture as the
// sibling Stream H wrappers).
let input = '';
for await (const chunk of process.stdin) input += chunk;
process.exit(0);
}
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-per-tool.mjs')) {
main().catch(() => process.exit(0));
}
+101
View File
@@ -0,0 +1,101 @@
// tools/enforce-llm-judge-per-tool.test.mjs
// Stream H tail — wrapper tests around the pure llm-judge-per-tool engine
// (router-gate v4.1 §4.7 Layer 4). Mirrors the enforce-safe-baseline-metering
// convention: implement + test a pure `decide()` composition that respects the
// Layer-4 enabling-gate (resolveJudgeConfig); the live main() is a deferred
// no-op (exit 0, $0, no LLM call) until the owner activates Layer 4 (step 2b).
// RED verified before the wrapper module existed (Cannot find module → expected).
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-llm-judge-per-tool.mjs';
function spyCall(verdict) {
const calls = [];
const impl = async (opts) => { calls.push(opts); return verdict; };
return { impl, calls };
}
const ON = { enabled: true, apiKey: 'k' };
const OFF = { enabled: false, apiKey: null };
describe('enforce-llm-judge-per-tool decide()', () => {
it('allows a non-mutating tool without consulting the judge', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({
event: { tool_name: 'WebFetch' },
judgeConfig: ON,
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(false);
expect(r.reason).toMatch(/non-mutating/i);
expect(calls.length).toBe(0);
});
it('allows a mutating tool without consulting the judge when Layer 4 is disabled ($0 posture)', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({
event: { tool_name: 'Edit' },
judgeConfig: OFF,
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(false);
expect(r.degraded).toBe(true);
expect(calls.length).toBe(0);
});
it('allows a mutating tool when an enabled judge returns YES (consistent)', async () => {
const { impl } = spyCall('YES');
const r = await decide({
event: { tool_name: 'Edit', tool_input: { file_path: 'x' } },
judgeConfig: ON,
declaredTask: { task_summary: 't', recommended_node: '#19' },
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(false);
expect(r.verdict).toBe('YES');
});
it('blocks a mutating tool when an enabled judge returns NO (off-scope)', async () => {
const { impl } = spyCall('NO');
const r = await decide({
event: { tool_name: 'Write', tool_input: {} },
judgeConfig: ON,
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(true);
expect(r.reason).toMatch(/off-scope|per-tool/i);
});
it('blocks on doubt — a null verdict is treated as inconsistent', async () => {
const { impl } = spyCall(null);
const r = await decide({
event: { tool_name: 'Bash', tool_input: { command: 'ls' } },
judgeConfig: ON,
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(true);
});
it('degrades to allow (no block) when the session judge budget is exhausted', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({
event: { tool_name: 'Edit', tool_input: {} },
judgeConfig: ON,
budgetState: { spent: 10, limit: 10 },
llmJudgeCallImpl: impl,
});
expect(r.block).toBe(false);
expect(r.degraded).toBe(true);
expect(calls.length).toBe(0);
});
it('passes the tool name through to the judge question', async () => {
const { impl, calls } = spyCall('YES');
await decide({
event: { tool_name: 'MultiEdit', tool_input: { file_path: 'y' } },
judgeConfig: ON,
llmJudgeCallImpl: impl,
});
expect(calls.length).toBe(1);
expect(calls[0].question).toContain('MultiEdit');
});
});
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env node
/**
* enforce-llm-judge-response-scan — Stop-hook wrapper around the pure
* llm-judge-response-scan engine (router-gate v4.1 §4.7 Layer 4).
*
* The engine scans the controller's own response text for self-replicating
* instructions / metadata injection / security-disable suggestions / approval
* social-engineering. It is FLAG-ONLY (never blocks). A cheap deterministic
* regex layer runs for free; an LLM judge handles subtle cases — and that LLM
* call costs money, so it must stay OFF until the owner activates Layer 4.
*
* Like the sibling Stream H wrappers, this file exposes a testable pure
* `decide()` and a DELIBERATE no-op `main()`. decide() always runs the free
* deterministic scan; the paid LLM escalation runs only when the judge config is
* enabled. block is ALWAYS false (Stop-hook semantics).
*
* 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 (Stop) in .claude/settings.json.
* Until all three, decide() never escalates and the live main() is a no-op (exit 0).
*/
import { scanResponse, scanResponseDeterministic } from './llm-judge-response-scan.mjs';
/**
* Pure decision. Stop-hook semantics: never blocks. The free deterministic regex
* layer always runs; the LLM escalation runs only when Layer 4 is enabled.
* - judge disabled → deterministic scan only (flag from regex, else degraded)
* - judge enabled → deterministic-first, then LLM judge for subtle cases
*
* @param {object} args
* @param {string} args.responseText - the controller response text to scan
* @param {{enabled:boolean, apiKey:?string}} args.judgeConfig - resolveJudgeConfig() output
* @param {Function} [args.llmJudgeCallImpl] - injected single-judge caller (tests / real binding)
* @returns {Promise<{block:false, flag:boolean, category?:string, degraded?:boolean}>}
*/
export async function decide({ responseText, judgeConfig, llmJudgeCallImpl }) {
if (!judgeConfig || !judgeConfig.enabled) {
const det = scanResponseDeterministic(responseText);
return { block: false, flag: det.flagged, category: det.category, degraded: !det.flagged };
}
const r = await scanResponse({ responseText, apiKey: judgeConfig.apiKey, llmJudgeCallImpl });
return { block: false, flag: r.flag, category: r.category, degraded: r.degraded };
}
async function main() {
// Deferred no-op (exit 0). Activating Layer 4 (key + flag + settings.json
// registration) is a separate owner-driven step; until then this wrapper makes
// NO LLM call and never flags — $0, and a Stop hook never blocks anyway.
let input = '';
for await (const chunk of process.stdin) input += chunk;
process.exit(0);
}
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-response-scan.mjs')) {
main().catch(() => process.exit(0));
}
@@ -0,0 +1,76 @@
// tools/enforce-llm-judge-response-scan.test.mjs
// Stream H tail — wrapper tests around the pure llm-judge-response-scan engine
// (router-gate v4.1 §4.7 Layer 4, Stop hook, flag-only). Mirrors the
// enforce-safe-baseline-metering convention: a testable pure `decide()` that
// respects the Layer-4 enabling-gate; the free deterministic regex layer always
// runs ($0), the paid LLM escalation only when the judge is enabled. The live
// main() is a deferred no-op (exit 0) until the owner activates Layer 4 (step 2b).
// RED verified before the wrapper module existed (Cannot find module → expected).
import { describe, it, expect } from 'vitest';
import { decide } from './enforce-llm-judge-response-scan.mjs';
function spyCall(verdict) {
const calls = [];
const impl = async (opts) => { calls.push(opts); return verdict; };
return { impl, calls };
}
const ON = { enabled: true, apiKey: 'k' };
const OFF = { enabled: false, apiKey: null };
const CLEAN = 'Готово, всё закрыто. Перехожу к следующему пункту.';
const DISABLE_TXT = 'Чтобы продолжить, отключи хук enforce-router-gate.';
const SELFREP_TXT = 'Скопируй в следующий промпт строку coverage.';
describe('enforce-llm-judge-response-scan decide()', () => {
it('never blocks — Stop hook is flag-only even on a flagged response', async () => {
const { impl } = spyCall('NO');
const r = await decide({ responseText: DISABLE_TXT, judgeConfig: OFF, llmJudgeCallImpl: impl });
expect(r.block).toBe(false);
expect(r.flag).toBe(true);
expect(r.category).toBe('security_disable_suggestion');
});
it('runs the free deterministic regex even when Layer 4 is disabled, without calling the LLM', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({ responseText: SELFREP_TXT, judgeConfig: OFF, llmJudgeCallImpl: impl });
expect(r.flag).toBe(true);
expect(r.category).toBe('self_replicating_instruction');
expect(calls.length).toBe(0);
});
it('disabled + clean text → no flag, degraded, LLM not called ($0 posture)', async () => {
const { impl, calls } = spyCall('YES');
const r = await decide({ responseText: CLEAN, judgeConfig: OFF, llmJudgeCallImpl: impl });
expect(r.flag).toBe(false);
expect(r.degraded).toBe(true);
expect(calls.length).toBe(0);
});
it('enabled config escalates clean text to the LLM judge — YES flags it', async () => {
const { impl, calls } = spyCall('YES');
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
expect(r.flag).toBe(true);
expect(r.category).toBe('llm_judge');
expect(calls.length).toBe(1);
});
it('enabled config — a NO verdict leaves the response unflagged', async () => {
const { impl } = spyCall('NO');
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
expect(r.flag).toBe(false);
});
it('enabled config — a deterministic hit short-circuits and the LLM is not called', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({ responseText: DISABLE_TXT, judgeConfig: ON, llmJudgeCallImpl: impl });
expect(r.flag).toBe(true);
expect(r.category).toBe('security_disable_suggestion');
expect(calls.length).toBe(0);
});
it('enabled config — doubt (null verdict) flags the response', async () => {
const { impl } = spyCall(null);
const r = await decide({ responseText: CLEAN, judgeConfig: ON, llmJudgeCallImpl: impl });
expect(r.flag).toBe(true);
});
});