b4fb2cece9
- tools/router-tool-gate.mjs: PreToolUse hook читает state из ~/.claude/runtime/router-state-<session>.json, решает block/proceed для Edit/Write/Bash (non-read-only). Escape hatch через HTML-тег <!-- routing: direct_justified=true reason="..." -->. Режим warn-only (default) / enforce через router-gate-mode.json. - tools/router-tool-gate.test.mjs: 15 тестов GREEN (4 describe-блока: isReadOnlyBash / decodeRoutingTag / shouldBlock / decideDecision). - CLI guard: fileURLToPath(import.meta.url) — Windows-cyrillic quirk. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
100 lines
3.6 KiB
JavaScript
100 lines
3.6 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
shouldBlock,
|
|
decodeRoutingTag,
|
|
isReadOnlyBash,
|
|
decideDecision,
|
|
} from './router-tool-gate.mjs';
|
|
|
|
const enforcementState = {
|
|
enforcementRequired: true,
|
|
skillInvokedThisTurn: false,
|
|
classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' },
|
|
chainProgress: [],
|
|
};
|
|
|
|
describe('isReadOnlyBash', () => {
|
|
it('detects ls / cat / grep / git status as read-only', () => {
|
|
expect(isReadOnlyBash('ls -la')).toBe(true);
|
|
expect(isReadOnlyBash('cat file.txt')).toBe(true);
|
|
expect(isReadOnlyBash('grep "x" file')).toBe(true);
|
|
expect(isReadOnlyBash('git status')).toBe(true);
|
|
expect(isReadOnlyBash('git log')).toBe(true);
|
|
expect(isReadOnlyBash('git rev-parse HEAD')).toBe(true);
|
|
});
|
|
|
|
it('does not classify git commit / push as read-only', () => {
|
|
expect(isReadOnlyBash('git commit -m "x"')).toBe(false);
|
|
expect(isReadOnlyBash('git push origin main')).toBe(false);
|
|
});
|
|
|
|
it('does not classify rm / cp / mv as read-only', () => {
|
|
expect(isReadOnlyBash('rm file')).toBe(false);
|
|
expect(isReadOnlyBash('cp a b')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('decodeRoutingTag', () => {
|
|
it('parses direct_justified=true with reason', () => {
|
|
const r = decodeRoutingTag('<!-- routing: direct_justified=true reason="micro fix per user override" -->');
|
|
expect(r.directJustified).toBe(true);
|
|
expect(r.reason).toContain('micro fix');
|
|
});
|
|
|
|
it('returns null on missing tag', () => {
|
|
expect(decodeRoutingTag('just a regular response')).toBeNull();
|
|
});
|
|
|
|
it('rejects direct_justified=true WITHOUT reason', () => {
|
|
const r = decodeRoutingTag('<!-- routing: direct_justified=true -->');
|
|
expect(r).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('shouldBlock', () => {
|
|
it('blocks Edit on enforcement state without skill invoked', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true);
|
|
});
|
|
|
|
it('does NOT block when skill invoked this turn', () => {
|
|
const state = { ...enforcementState, skillInvokedThisTurn: true };
|
|
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block when enforcement not required', () => {
|
|
const state = { ...enforcementState, enforcementRequired: false };
|
|
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block when routing-tag has direct_justified=true with reason', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '<!-- routing: direct_justified=true reason="testing" -->', { warnOnly: false })).toBe(false);
|
|
});
|
|
|
|
it('does NOT block read-only Bash', () => {
|
|
expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false);
|
|
});
|
|
|
|
it('warn-only mode never blocks (always returns false)', () => {
|
|
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('decideDecision', () => {
|
|
it('returns decision: block with message when shouldBlock=true', () => {
|
|
const r = decideDecision('Edit', enforcementState, '', { warnOnly: false });
|
|
expect(r.decision).toBe('block');
|
|
expect(r.reason).toMatch(/#19/);
|
|
});
|
|
|
|
it('returns empty (proceed) when shouldBlock=false', () => {
|
|
const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false });
|
|
expect(r.decision).toBeUndefined();
|
|
});
|
|
|
|
it('warn-only mode logs to stderr but does not block', () => {
|
|
const r = decideDecision('Edit', enforcementState, '', { warnOnly: true });
|
|
expect(r.decision).toBeUndefined();
|
|
expect(r.warning).toMatch(/#19/);
|
|
});
|
|
});
|