Files
brain/tools/enforce-llm-judge-per-tool.test.mjs
T

375 lines
14 KiB
JavaScript

// 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);
});
// Calibration 1 (2026-05-31) — Skill is out of judge scope; invoking it
// mutates nothing and is the prescribed §17 entry into work.
it('allows a Skill invocation without consulting the judge (calibration 1)', async () => {
const { impl, calls } = spyCall('NO');
const r = await decide({
event: { tool_name: 'Skill', tool_input: { skill: 'superpowers:test-driven-development' } },
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');
});
});
import { runPerTool } from './enforce-llm-judge-per-tool.mjs';
describe('runPerTool — spend-gate + budget binding (live wiring 2b)', () => {
const deps = (over = {}) => ({
readDeclaredTaskImpl: () => ({ task_summary: 't', recommended_node: null, recommended_chain: [] }),
readBudgetImpl: () => 0,
bumpBudgetImpl: () => {},
sessionBudget: 200,
...over,
});
it('disabled config + mutating tool → degraded allow, NO budget bump, NO llm call', async () => {
let bumped = 0; let called = 0;
const r = await runPerTool({
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
judgeConfig: { enabled: false, apiKey: null },
llmJudgeCallImpl: () => { called++; return 'NO'; },
...deps({ bumpBudgetImpl: () => { bumped++; } }),
});
expect(r.block).toBe(false);
expect(r.degraded).toBe(true);
expect(called).toBe(0);
expect(bumped).toBe(0);
});
it('enabled + mutating + judge YES → allow, budget bumped once', async () => {
let bumped = 0;
const r = await runPerTool({
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
llmJudgeCallImpl: async () => 'YES',
...deps({ bumpBudgetImpl: () => { bumped++; } }),
});
expect(r.block).toBe(false);
expect(r.verdict).toBe('YES');
expect(bumped).toBe(1);
});
it('enabled + mutating + judge NO → block, budget bumped once', async () => {
let bumped = 0;
const r = await runPerTool({
event: { tool_name: 'Bash', tool_input: { command: 'x' }, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
llmJudgeCallImpl: async () => 'NO',
...deps({ bumpBudgetImpl: () => { bumped++; } }),
});
expect(r.block).toBe(true);
expect(r.verdict).toBe('NO');
expect(bumped).toBe(1);
});
it('non-mutating tool → allow, NO call, NO bump', async () => {
let bumped = 0; let called = 0;
const r = await runPerTool({
event: { tool_name: 'Read', tool_input: {}, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
llmJudgeCallImpl: () => { called++; return 'NO'; },
...deps({ bumpBudgetImpl: () => { bumped++; } }),
});
expect(r.block).toBe(false);
expect(called).toBe(0);
expect(bumped).toBe(0);
});
it('enabled but budget exhausted → degraded allow, NO bump', async () => {
let bumped = 0; let called = 0;
const r = await runPerTool({
event: { tool_name: 'Edit', tool_input: {}, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
llmJudgeCallImpl: () => { called++; return 'NO'; },
...deps({ readBudgetImpl: () => 200, bumpBudgetImpl: () => { bumped++; } }),
});
expect(r.block).toBe(false);
expect(r.degraded).toBe(true);
expect(called).toBe(0);
expect(bumped).toBe(0);
});
});
import { isReadonlyBashEvent } from './enforce-llm-judge-per-tool.mjs';
// Calibration 2026-05-31 — SCOPE fix only, discipline NOT lowered.
// The per-tool judge is "judge on MUTATING tools"; readonly Bash ("смотрелки"
// — git status/log/diff, cat, grep, ls) change nothing, so they were friction
// with zero discipline value. We exclude them from the judge. The doubt→block
// rule and full judging of every state-changing action (Edit/Write/commit/push/
// Skill/Task) are UNCHANGED.
describe('isReadonlyBashEvent — readonly Bash exclusion (calibration, no discipline drop)', () => {
it.each([
'git status',
'git status --short',
'git log -1 --oneline',
'git diff HEAD~1',
'cat package.json',
'grep -n foo bar.js',
'ls -la',
])('treats readonly command as out-of-judge-scope: %s', (command) => {
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(true);
});
it.each([
'git commit -m "x"',
'git push origin main',
'rm -rf foo',
])('does NOT treat a mutating/blocked command as readonly: %s', (command) => {
expect(isReadonlyBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(false);
});
it('non-Bash tool is never readonly-bash', () => {
expect(isReadonlyBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'x' } })).toBe(false);
});
});
describe('runPerTool — readonly Bash skips the judge; mutating Bash still judged', () => {
it('readonly Bash → allow WITHOUT consulting judge even when enabled (no spend)', async () => {
let called = 0; let bumped = 0;
const r = await runPerTool({
event: { tool_name: 'Bash', tool_input: { command: 'git status' }, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
readBudgetImpl: () => 0,
bumpBudgetImpl: () => { bumped++; },
llmJudgeCallImpl: () => { called++; return 'NO'; },
sessionBudget: 200,
});
expect(r.block).toBe(false);
expect(called).toBe(0);
expect(bumped).toBe(0);
});
it('mutating Bash (git commit) STILL judged when enabled — discipline preserved', async () => {
let called = 0;
const r = await runPerTool({
event: { tool_name: 'Bash', tool_input: { command: 'git commit -m "x"' }, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
readBudgetImpl: () => 0,
bumpBudgetImpl: () => {},
llmJudgeCallImpl: async () => { called++; return 'NO'; },
sessionBudget: 200,
});
expect(called).toBe(1);
expect(r.block).toBe(true);
});
});
import { isTestRunnerBashEvent } from './enforce-llm-judge-per-tool.mjs';
// Calibration 3 (2026-05-31) — SCOPE fix, discipline NOT lowered.
// A test run (vitest / pest / composer test / php artisan test) only inspects
// the code and reports pass/fail — it mutates no protected state. It is also a
// mandatory step of TDD, which the rules require. Treat recognised test-runner
// commands like readonly Bash: out of judge scope. Anything that chains to a
// mutation (&& / ; / |) is NOT exempt and stays judged.
describe('isTestRunnerBashEvent — test-runner exclusion (calibration 3, no discipline drop)', () => {
it.each([
'npx vitest run --root app --config vitest.config.tools.mjs',
'vitest run',
'pest',
'./vendor/bin/pest --parallel',
'vendor/bin/pest',
'php artisan test',
'composer test',
'npm run test:tools',
'npm test',
])('treats test-runner command as out-of-judge-scope: %s', (command) => {
expect(isTestRunnerBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(true);
});
it.each([
'git commit -m "x"',
'rm -rf foo',
'pest && git push origin main', // chained to a mutation → NOT exempt
'echo pest',
'composer require evil/package', // not a test run
])('does NOT treat non-test-runner / chained command as test-runner: %s', (command) => {
expect(isTestRunnerBashEvent({ tool_name: 'Bash', tool_input: { command } })).toBe(false);
});
it('non-Bash tool is never test-runner-bash', () => {
expect(isTestRunnerBashEvent({ tool_name: 'Edit', tool_input: { file_path: 'x' } })).toBe(false);
});
});
describe('runPerTool — test-runner Bash skips the judge; mutating Bash still judged', () => {
it('test-runner Bash → allow WITHOUT consulting judge even when enabled (no spend)', async () => {
let called = 0; let bumped = 0;
const r = await runPerTool({
event: { tool_name: 'Bash', tool_input: { command: 'npx vitest run' }, session_id: 's' },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
readBudgetImpl: () => 0,
bumpBudgetImpl: () => { bumped++; },
llmJudgeCallImpl: () => { called++; return 'NO'; },
sessionBudget: 200,
});
expect(r.block).toBe(false);
expect(called).toBe(0);
expect(bumped).toBe(0);
});
});
describe('runPerTool — verdict logging', () => {
it('runPerTool logs verdict only when a real judge call happened', async () => {
const logged = [];
const r = await runPerTool({
event: { tool_name: 'Edit', session_id: 's', tool_input: {} },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: 't' }),
readBudgetImpl: () => 0,
bumpBudgetImpl: () => {},
llmJudgeCallImpl: async () => 'YES',
logVerdictImpl: (rec) => logged.push(rec),
});
expect(r.block).toBe(false);
expect(logged).toEqual([{ sessionId: 's', tool: 'Edit', verdict: 'YES' }]);
});
});
// Calibration 4 (soft, 2026-05-31): when the classifier summary is "(unknown)",
// runPerTool reads the user's last prompt and judges against THAT (better
// evidence) instead of an empty task. When the summary is meaningful, the
// user-prompt reader is never consulted — behaviour unchanged.
describe('runPerTool — calibration 4 soft user-prompt fallback', () => {
it('uses the user prompt as the judged task when classifier summary is unknown', async () => {
const calls = [];
const r = await runPerTool({
event: { tool_name: 'Edit', tool_input: { file_path: 'tools/x.mjs' }, session_id: 's', transcript_path: '/t' },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: '(unknown)', recommended_node: null, recommended_chain: [] }),
readLastUserPromptImpl: () => 'реализуй parallel-session-lock',
readBudgetImpl: () => 0,
bumpBudgetImpl: () => {},
llmJudgeCallImpl: async (opts) => { calls.push(opts); return 'YES'; },
sessionBudget: 200,
});
expect(r.block).toBe(false);
expect(calls.length).toBe(1);
expect(calls[0].question).toContain('реализуй parallel-session-lock');
});
it('does NOT consult the user-prompt reader when the classifier summary is meaningful', async () => {
let promptReads = 0;
const calls = [];
await runPerTool({
event: { tool_name: 'Edit', tool_input: {}, session_id: 's', transcript_path: '/t' },
judgeConfig: { enabled: true, apiKey: 'k' },
readDeclaredTaskImpl: () => ({ task_summary: 'clear task', recommended_node: null, recommended_chain: [] }),
readLastUserPromptImpl: () => { promptReads++; return 'irrelevant'; },
readBudgetImpl: () => 0,
bumpBudgetImpl: () => {},
llmJudgeCallImpl: async (opts) => { calls.push(opts); return 'YES'; },
sessionBudget: 200,
});
expect(promptReads).toBe(0);
expect(calls[0].question).toContain('clear task');
});
});