fix(router-gate-v4): exclude readonly Bash from per-tool judge — scope fix, discipline unchanged

This commit is contained in:
Дмитрий
2026-05-31 08:59:18 +03:00
parent ec8f66b8a1
commit f8a7e4ecee
2 changed files with 92 additions and 0 deletions
+25
View File
@@ -22,6 +22,7 @@ import { judgePerTool, MUTATING_TOOLS, readDeclaredTask } from './llm-judge-per-
import { resolveJudgeConfig } from './llm-judge-config.mjs';
import { readJudgeBudget, bumpJudgeBudget, JUDGE_SESSION_BUDGET, llmJudgeCall } from './llm-judge.mjs';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
import { classifyBashCommand } from './enforce-router-gate.mjs';
/**
* Pure decision. Composes the Layer-4 enabling-gate (resolveJudgeConfig output)
@@ -67,6 +68,26 @@ export async function decide({
* 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;
}
}
export async function runPerTool({
event,
judgeConfig,
@@ -76,6 +97,10 @@ export async function runPerTool({
llmJudgeCallImpl,
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)' };
}
const sessionId = event && event.session_id;
const declaredTask = readDeclaredTaskImpl({ sessionId });
const spent = readBudgetImpl({ sessionId });
+67
View File
@@ -178,3 +178,70 @@ describe('runPerTool — spend-gate + budget binding (live wiring 2b)', () => {
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);
});
});