feat(router): §17 mode-based gate, continuation NOT exempt (phase 2 task 13)
Spec §4.4 — shouldBlock rewritten on mode='off'|'warn-only'|'enforce'. Old
boolean warnOnly API kept as legacy fallback. Continuation deliberately NOT
in the §17 exempt set (D1) — an inherited 'feature' classification still
triggers the gate.
- tools/router-tool-gate.mjs:
+ NON_BLOCKING_TASK_TYPES = ['conversation','micro','manual_override']
+ shouldBlock returns false OR { block: true, reason } with reason ∈
{'no_skill_found_block','direct_in_non_conversation'}.
+ Reads state.classification.task_type (v4 snake_case) with fallback to
legacy taskType — backward-compatible until Task 14 updates prehook.
+ resolveMode(): options.mode wins; legacy warnOnly=false maps to enforce.
+ decideDecision returns decision/reason/reason_code on block, warning on
warn-only with non-exempt classification, empty on proceed/exempt.
+ gateMode() now recognises 'off' alongside warn-only/enforce.
- tools/router-tool-gate.test.mjs: rewritten 25 tests (mode-based) — covers
§17 exempt set, no_skill_found path, skill invoked, routing-tag escape,
read-only Bash, tool whitelist, legacy back-compat (warnOnly + taskType),
decideDecision reason_code + warn-only warning suppression on exempt tasks.
Tests: 25/25 PASS.
This commit is contained in:
+78
-23
@@ -36,37 +36,91 @@ export function decodeRoutingTag(responseText) {
|
||||
return { directJustified: true, reason: m[1] };
|
||||
}
|
||||
|
||||
export function shouldBlock(tool, state, responseText, options = {}) {
|
||||
const warnOnly = options.warnOnly !== false; // default true
|
||||
if (warnOnly) return false;
|
||||
// §17 exempt set — task types that never trigger the gate (spec §4.4).
|
||||
// Continuation deliberately NOT in this list (D1): a continuation that
|
||||
// inherits a `feature`/`bugfix` classification gets the same enforcement as
|
||||
// the original prompt.
|
||||
const NON_BLOCKING_TASK_TYPES = ['conversation', 'micro', 'manual_override'];
|
||||
|
||||
if (!state.enforcementRequired) return false;
|
||||
if (state.skillInvokedThisTurn) return false;
|
||||
function resolveTaskType(cls) {
|
||||
return cls?.task_type ?? cls?.taskType;
|
||||
}
|
||||
|
||||
function resolveMode(options) {
|
||||
if (typeof options.mode === 'string') return options.mode;
|
||||
// Legacy fallback: warnOnly=false maps to enforce, otherwise warn-only.
|
||||
return options.warnOnly === false ? 'enforce' : 'warn-only';
|
||||
}
|
||||
|
||||
/**
|
||||
* §17 gate decision (spec §4.4, Phase 2 Task 13).
|
||||
*
|
||||
* @returns `false` when the tool call is allowed to proceed, or
|
||||
* `{ block: true, reason: 'direct_in_non_conversation' | 'no_skill_found_block' }`
|
||||
* when the gate decides to block.
|
||||
*
|
||||
* Order of checks:
|
||||
* 1. mode off / warn-only → false (no enforcement)
|
||||
* 2. classification.no_skill_found === true → block(no_skill_found_block)
|
||||
* 3. task_type ∈ NON_BLOCKING_TASK_TYPES → false (§17 exempt set)
|
||||
* 4. skillInvokedThisTurn === true → false (skill already invoked)
|
||||
* 5. routing-tag direct_justified=true with reason → false (escape hatch)
|
||||
* 6. Bash + isReadOnlyBash(cmd) → false (read-only commands)
|
||||
* 7. tool ∉ {Edit, Write, MultiEdit, Bash} → false (not gated)
|
||||
* 8. → block(direct_in_non_conversation)
|
||||
*/
|
||||
export function shouldBlock(tool, state, responseText, options = {}) {
|
||||
const mode = resolveMode(options);
|
||||
if (mode === 'off' || mode === 'warn-only') return false;
|
||||
|
||||
const cls = state?.classification || {};
|
||||
|
||||
if (cls.no_skill_found === true) {
|
||||
return { block: true, reason: 'no_skill_found_block' };
|
||||
}
|
||||
|
||||
const taskType = resolveTaskType(cls);
|
||||
if (NON_BLOCKING_TASK_TYPES.includes(taskType)) return false;
|
||||
if (state?.skillInvokedThisTurn === true) return false;
|
||||
|
||||
const tag = decodeRoutingTag(responseText);
|
||||
if (tag?.directJustified) return false;
|
||||
|
||||
if (tool === 'Bash' && isReadOnlyBash(options.bashCommand || '')) return false;
|
||||
if (!['Edit', 'Write', 'MultiEdit', 'Bash'].includes(tool)) return false;
|
||||
|
||||
const tag = decodeRoutingTag(responseText);
|
||||
if (tag && tag.directJustified) return false;
|
||||
|
||||
return true;
|
||||
return { block: true, reason: 'direct_in_non_conversation' };
|
||||
}
|
||||
|
||||
export function decideDecision(tool, state, responseText, options = {}) {
|
||||
const cls = state.classification || {};
|
||||
if (shouldBlock(tool, state, responseText, options)) {
|
||||
const recommendedNode = cls.recommendedNode || '(unknown)';
|
||||
const recommendedChain = cls.recommendedChain ? ` (chain ${cls.recommendedChain})` : '';
|
||||
const cls = state?.classification || {};
|
||||
const taskType = resolveTaskType(cls);
|
||||
const block = shouldBlock(tool, state, responseText, options);
|
||||
|
||||
if (block && block.block) {
|
||||
const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
|
||||
const recChain = cls.recommendedChain ?? cls.recommended_chain_id;
|
||||
const chainSuf = recChain ? ` (chain ${recChain})` : '';
|
||||
const reasonText = block.reason === 'no_skill_found_block'
|
||||
? `Классификатор не нашёл подходящий узел (no_skill_found). Уточни задачу или дай routing-tag direct_justified. Узел: ${recNode}.`
|
||||
: `Эта задача классифицирована как ${taskType}. Реестр рекомендует узел ${recNode}${chainSuf}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`;
|
||||
return { decision: 'block', reason: reasonText, reason_code: block.reason };
|
||||
}
|
||||
|
||||
const mode = resolveMode(options);
|
||||
if (
|
||||
mode === 'warn-only'
|
||||
&& taskType
|
||||
&& !NON_BLOCKING_TASK_TYPES.includes(taskType)
|
||||
&& cls.no_skill_found !== true
|
||||
&& !state?.skillInvokedThisTurn
|
||||
) {
|
||||
const recNode = cls.recommendedNode ?? cls.recommended_node ?? '(unknown)';
|
||||
return {
|
||||
decision: 'block',
|
||||
reason: `Эта задача классифицирована как ${cls.taskType}. Реестр рекомендует узел ${recommendedNode}${recommendedChain}. Вызови соответствующий навык ПЕРВЫМ, либо начни ответ с <!-- routing: direct_justified=true reason="..." --> с явным обоснованием.`,
|
||||
};
|
||||
}
|
||||
if (options.warnOnly && state.enforcementRequired && !state.skillInvokedThisTurn) {
|
||||
return {
|
||||
warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${cls.recommendedNode}.`,
|
||||
warning: `[router-gate WARN-ONLY] ${tool} would be blocked — recommended ${recNode}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -75,7 +129,9 @@ function gateMode() {
|
||||
if (!existsSync(path)) return 'warn-only';
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(path, 'utf-8'));
|
||||
return data.mode === 'enforce' ? 'enforce' : 'warn-only';
|
||||
if (data.mode === 'enforce') return 'enforce';
|
||||
if (data.mode === 'off') return 'off';
|
||||
return 'warn-only';
|
||||
} catch { return 'warn-only'; }
|
||||
}
|
||||
|
||||
@@ -95,11 +151,10 @@ async function main() {
|
||||
if (!state) { process.stdout.write(JSON.stringify({})); process.exit(0); return; }
|
||||
|
||||
const mode = gateMode();
|
||||
const warnOnly = mode === 'warn-only';
|
||||
const responseText = ''; // PreToolUse event doesn't include response
|
||||
const bashCommand = (event.tool_input || {}).command || '';
|
||||
|
||||
const decision = decideDecision(tool, state, responseText, { warnOnly, bashCommand });
|
||||
const decision = decideDecision(tool, state, responseText, { mode, bashCommand });
|
||||
|
||||
if (decision.warning) process.stderr.write(decision.warning + '\n');
|
||||
process.stdout.write(JSON.stringify(decision.decision ? decision : {}));
|
||||
|
||||
@@ -6,10 +6,14 @@ import {
|
||||
decideDecision,
|
||||
} from './router-tool-gate.mjs';
|
||||
|
||||
const enforcementState = {
|
||||
enforcementRequired: true,
|
||||
const baseState = {
|
||||
skillInvokedThisTurn: false,
|
||||
classification: { taskType: 'feature', recommendedNode: '#19', recommendedChain: 'L1' },
|
||||
classification: {
|
||||
task_type: 'feature',
|
||||
no_skill_found: false,
|
||||
recommendedNode: '#19',
|
||||
recommendedChain: 'L1',
|
||||
},
|
||||
chainProgress: [],
|
||||
};
|
||||
|
||||
@@ -51,51 +55,104 @@ describe('decodeRoutingTag', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldBlock', () => {
|
||||
it('blocks Edit on enforcement state without skill invoked', () => {
|
||||
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: false })).toBe(true);
|
||||
describe('shouldBlock — §17 mode-based (Phase 2 Task 13)', () => {
|
||||
it('mode=off never blocks', () => {
|
||||
expect(shouldBlock('Edit', baseState, '', { mode: 'off' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block when skill invoked this turn', () => {
|
||||
const state = { ...enforcementState, skillInvokedThisTurn: true };
|
||||
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
||||
it('warn-only never blocks (always returns false)', () => {
|
||||
expect(shouldBlock('Edit', baseState, '', { mode: 'warn-only' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block when enforcement not required', () => {
|
||||
const state = { ...enforcementState, enforcementRequired: false };
|
||||
expect(shouldBlock('Edit', state, '', { warnOnly: false })).toBe(false);
|
||||
it('enforce blocks Edit on feature without skill invoked', () => {
|
||||
expect(shouldBlock('Edit', baseState, '', { mode: 'enforce' })).toMatchObject({
|
||||
block: true,
|
||||
reason: 'direct_in_non_conversation',
|
||||
});
|
||||
});
|
||||
|
||||
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('enforce passes conversation task_type (§17 exempt)', () => {
|
||||
const s = { ...baseState, classification: { task_type: 'conversation', no_skill_found: false } };
|
||||
expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT block read-only Bash', () => {
|
||||
expect(shouldBlock('Bash', enforcementState, '', { warnOnly: false, bashCommand: 'ls' })).toBe(false);
|
||||
it('enforce passes micro / manual_override (§17 exempt)', () => {
|
||||
for (const t of ['micro', 'manual_override']) {
|
||||
const s = { ...baseState, classification: { task_type: t, no_skill_found: false } };
|
||||
expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('warn-only mode never blocks (always returns false)', () => {
|
||||
expect(shouldBlock('Edit', enforcementState, '', { warnOnly: true })).toBe(false);
|
||||
it('enforce does NOT block when skill invoked this turn', () => {
|
||||
const s = { ...baseState, skillInvokedThisTurn: true };
|
||||
expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false);
|
||||
});
|
||||
|
||||
it('enforce blocks no_skill_found=true with specific reason', () => {
|
||||
const s = { ...baseState, classification: { task_type: 'feature', no_skill_found: true } };
|
||||
expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toMatchObject({
|
||||
block: true,
|
||||
reason: 'no_skill_found_block',
|
||||
});
|
||||
});
|
||||
|
||||
it('continuation-inherited feature is NOT exempt (D1 — same shape as base)', () => {
|
||||
expect(shouldBlock('Edit', baseState, '', { mode: 'enforce' })).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
it('enforce does NOT block when routing-tag has direct_justified=true with reason', () => {
|
||||
expect(shouldBlock('Edit', baseState, '<!-- routing: direct_justified=true reason="testing" -->', { mode: 'enforce' })).toBe(false);
|
||||
});
|
||||
|
||||
it('enforce does NOT block read-only Bash', () => {
|
||||
expect(shouldBlock('Bash', baseState, '', { mode: 'enforce', bashCommand: 'ls' })).toBe(false);
|
||||
});
|
||||
|
||||
it('enforce does NOT block tools outside whitelist (e.g. Read)', () => {
|
||||
expect(shouldBlock('Read', baseState, '', { mode: 'enforce' })).toBe(false);
|
||||
});
|
||||
|
||||
it('legacy back-compat: warnOnly=false maps to enforce', () => {
|
||||
expect(shouldBlock('Edit', baseState, '', { warnOnly: false })).toMatchObject({ block: true });
|
||||
});
|
||||
|
||||
it('legacy back-compat: taskType (camelCase) still recognised', () => {
|
||||
const s = { ...baseState, classification: { taskType: 'conversation', no_skill_found: false } };
|
||||
expect(shouldBlock('Edit', s, '', { mode: 'enforce' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideDecision', () => {
|
||||
it('returns decision: block with message when shouldBlock=true', () => {
|
||||
const r = decideDecision('Edit', enforcementState, '', { warnOnly: false });
|
||||
describe('decideDecision — §17 mode-based', () => {
|
||||
it('returns decision: block with reason text and reason_code when shouldBlock blocks', () => {
|
||||
const r = decideDecision('Edit', baseState, '', { mode: 'enforce' });
|
||||
expect(r.decision).toBe('block');
|
||||
expect(r.reason).toMatch(/#19/);
|
||||
expect(r.reason_code).toBe('direct_in_non_conversation');
|
||||
});
|
||||
|
||||
it('returns empty (proceed) when shouldBlock=false', () => {
|
||||
const r = decideDecision('Edit', { ...enforcementState, skillInvokedThisTurn: true }, '', { warnOnly: false });
|
||||
it('returns no_skill_found_block reason_code when classifier signalled no match', () => {
|
||||
const s = { ...baseState, classification: { task_type: 'feature', no_skill_found: true, recommendedNode: null } };
|
||||
const r = decideDecision('Edit', s, '', { mode: 'enforce' });
|
||||
expect(r.decision).toBe('block');
|
||||
expect(r.reason_code).toBe('no_skill_found_block');
|
||||
});
|
||||
|
||||
it('returns empty (proceed) when skill invoked', () => {
|
||||
const r = decideDecision('Edit', { ...baseState, skillInvokedThisTurn: true }, '', { mode: 'enforce' });
|
||||
expect(r.decision).toBeUndefined();
|
||||
});
|
||||
|
||||
it('warn-only mode logs to stderr but does not block', () => {
|
||||
const r = decideDecision('Edit', enforcementState, '', { warnOnly: true });
|
||||
it('warn-only mode emits warning string but does not block', () => {
|
||||
const r = decideDecision('Edit', baseState, '', { mode: 'warn-only' });
|
||||
expect(r.decision).toBeUndefined();
|
||||
expect(r.warning).toMatch(/#19/);
|
||||
});
|
||||
|
||||
it('warn-only mode does NOT emit warning when task is exempt (conversation)', () => {
|
||||
const s = { ...baseState, classification: { task_type: 'conversation', no_skill_found: false } };
|
||||
const r = decideDecision('Edit', s, '', { mode: 'warn-only' });
|
||||
expect(r.warning).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UTF-8 cyrillic stdin (regression — Stage 3 fix 1)', () => {
|
||||
|
||||
Reference in New Issue
Block a user