fix(router-gate-v4): exclude readonly Bash from per-tool judge — scope fix, discipline unchanged
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user