// tools/skill-scope-verifier.mjs /** * Skill scope verification — router-gate v4 spec §3.7 + v4.1 (Direction 2). * Pure: трекинг tools после Skill match, off-scope ratio, content-level scope. * v4.1: hard-block at 30% off-scope (was 50%), content-level file-path check. */ import { globMatch } from './path-normalization.mjs'; export const V4_1_SCOPE_THRESHOLDS = Object.freeze({ off_scope_warn_ratio: 0.15, off_scope_hard_ratio: 0.30, content_level_scope_check: true, min_tools_for_evaluation: 3, }); const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']); export function initTracker({ skillInvoked, turnId, toolUseIdOfSkill, scopeConfig, tsSkillInvokedIso }) { return { skill_invoked: skillInvoked, turn_id: turnId, tool_use_id_of_skill: toolUseIdOfSkill, scope_config: scopeConfig, tool_count_since_skill: 0, off_scope_count: 0, expected_output_written: false, ts_skill_invoked: tsSkillInvokedIso, }; } export function updateTracker(tracker, toolName, toolInput = {}) { const cfg = tracker.scope_config || {}; const expected = cfg.expected_tools || []; const offScope = expected.length > 0 && !expected.includes(toolName); let outputWritten = tracker.expected_output_written; if (WRITE_TOOLS.has(toolName) && cfg.expected_output_glob && toolInput.file_path) { if (globMatch(toolInput.file_path, cfg.expected_output_glob)) outputWritten = true; } return { ...tracker, tool_count_since_skill: tracker.tool_count_since_skill + 1, off_scope_count: tracker.off_scope_count + (offScope ? 1 : 0), expected_output_written: outputWritten, }; } export function evaluateScope(tracker, thresholds = V4_1_SCOPE_THRESHOLDS) { if (tracker.tool_count_since_skill < thresholds.min_tools_for_evaluation) { return { action: 'allow' }; } const ratio = tracker.off_scope_count / tracker.tool_count_since_skill; if (ratio >= thresholds.off_scope_hard_ratio) { return { action: 'hard_block', reason: 'Skill scope hard-block (v4.1): off-scope tools >30%. Skill mismatch — invoke matching Skill или AskUser approval для scope-broadening.', }; } if (ratio >= thresholds.off_scope_warn_ratio) { return { action: 'soft_flag', surface_in_next_prompt: true }; } return { action: 'allow' }; } export function contentScopeCheck(toolName, filePath, scopeConfig) { if (!WRITE_TOOLS.has(toolName)) return { action: 'allow' }; const cs = scopeConfig?.content_scope?.[toolName]; if (!cs || !cs.allowed_globs) return { action: 'allow' }; if (!filePath) return { action: 'allow' }; const ok = cs.allowed_globs.some((g) => globMatch(filePath, g)); if (ok) return { action: 'allow' }; return { action: 'hard_block', reason: 'v4.1 content-level scope: file path не соответствует Skill expected output domain', }; } export function maxCallsCheck(tracker) { const max = tracker.scope_config?.max_tool_calls_post_skill; if (max && tracker.tool_count_since_skill >= max && !tracker.expected_output_written) { return { action: 'hard_block', reason: `Skill scope exceeded (>=${max} tools post-skill без expected output). Требуется new Skill match ИЛИ AskUser approval.`, }; } return { action: 'allow' }; }