From f8a7e4ecee44e766e2fc45781799e3e13aa07fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sun, 31 May 2026 08:59:18 +0300 Subject: [PATCH] =?UTF-8?q?fix(router-gate-v4):=20exclude=20readonly=20Bas?= =?UTF-8?q?h=20from=20per-tool=20judge=20=E2=80=94=20scope=20fix,=20discip?= =?UTF-8?q?line=20unchanged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/enforce-llm-judge-per-tool.mjs | 25 +++++++++ tools/enforce-llm-judge-per-tool.test.mjs | 67 +++++++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/tools/enforce-llm-judge-per-tool.mjs b/tools/enforce-llm-judge-per-tool.mjs index bad11a38..fb19dd70 100644 --- a/tools/enforce-llm-judge-per-tool.mjs +++ b/tools/enforce-llm-judge-per-tool.mjs @@ -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 }); diff --git a/tools/enforce-llm-judge-per-tool.test.mjs b/tools/enforce-llm-judge-per-tool.test.mjs index d8f22ae7..81155518 100644 --- a/tools/enforce-llm-judge-per-tool.test.mjs +++ b/tools/enforce-llm-judge-per-tool.test.mjs @@ -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); + }); +});