101 lines
4.6 KiB
JavaScript
101 lines
4.6 KiB
JavaScript
#!/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';
|
|
import { resolveJudgeConfig } from './llm-judge-config.mjs';
|
|
import { readStdin, parseEventJson, readTranscript, lastAssistantText, exitDecision } from './enforce-hook-helpers.mjs';
|
|
import { llmJudgeCall } from './llm-judge.mjs';
|
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
|
|
/**
|
|
* Testable wiring core. Stop-hook semantics: block is always false. The free
|
|
* deterministic regex scan runs even when the judge is disabled; the paid LLM
|
|
* escalation runs only when judgeConfig.enabled (handled inside decide()).
|
|
*/
|
|
export async function runResponseScan({ transcript, judgeConfig, llmJudgeCallImpl, lastAssistantTextImpl = lastAssistantText }) {
|
|
const responseText = lastAssistantTextImpl(transcript || []);
|
|
const r = await decide({ responseText, judgeConfig, llmJudgeCallImpl });
|
|
return { ...r, responseText };
|
|
}
|
|
|
|
function flagToFile({ sessionId, category, excerpt }) {
|
|
try {
|
|
const dir = join(homedir(), '.claude', 'runtime');
|
|
mkdirSync(dir, { recursive: true });
|
|
appendFileSync(join(dir, `rationalization-flags-${sessionId || 'unknown'}.jsonl`),
|
|
JSON.stringify({
|
|
ts: new Date().toISOString(),
|
|
session_id: sessionId || null,
|
|
type: 'controller_response_suspicious',
|
|
category,
|
|
response_excerpt: String(excerpt || '').slice(0, 200),
|
|
}) + '\n');
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function main() {
|
|
// Live wiring (2b). Stop hook: flag-only, NEVER blocks. The free deterministic
|
|
// regex runs regardless ($0); the paid LLM escalation only when the config is
|
|
// enabled (flag AND key). Fail-quiet.
|
|
try {
|
|
const event = parseEventJson(await readStdin());
|
|
const transcript = readTranscript(event.transcript_path);
|
|
const judgeConfig = resolveJudgeConfig();
|
|
const r = await runResponseScan({
|
|
transcript,
|
|
judgeConfig,
|
|
llmJudgeCallImpl: (opts) => llmJudgeCall(opts),
|
|
});
|
|
if (r.flag) flagToFile({ sessionId: event.session_id, category: r.category, excerpt: r.responseText });
|
|
exitDecision({ block: false });
|
|
} catch {
|
|
exitDecision({ block: false });
|
|
}
|
|
}
|
|
|
|
if ((process.argv[1] || '').replace(/\\/g, '/').endsWith('/enforce-llm-judge-response-scan.mjs')) {
|
|
main().catch(() => process.exit(0));
|
|
}
|