diff --git a/tools/enforce-router-gate.mjs b/tools/enforce-router-gate.mjs index f8989ae6..5f9a2967 100644 --- a/tools/enforce-router-gate.mjs +++ b/tools/enforce-router-gate.mjs @@ -78,3 +78,50 @@ export function matchBashHardBlacklist(command) { if (stderr) return stderr; return matchAny(BASH_HARD_BLACKLIST, s); } + +// ── whitelist ── +const READING_CMDS = new Set(['ls', 'pwd', 'wc', 'head', 'tail', 'file', 'stat', 'grep', 'egrep', 'fgrep', 'cat', 'less', 'more']); +const SAFE_EXACT = [ + /^npx\s+vitest\s+(?:run|--version)\b/, + /^npm\s+(?:test|run\s+test|run\s+lint(?::[\w-]+)?)\b/, + /^php\s+artisan\s+(?:list|route:list|migrate:status)\b/, + /^composer\s+(?:show|outdated)\b/, + /^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/, +]; + +export function classifyWhitelist(segments) { + const reading = []; + let anyReading = false; + for (const seg of segments) { + const cmd = seg.tokens[0]; + if (READING_CMDS.has(cmd)) { anyReading = true; reading.push(...extractPathArgs(seg.tokens)); continue; } + const joined = seg.tokens.join(' '); + if (SAFE_EXACT.some((re) => re.test(joined))) continue; + return null; // segment not whitelisted + } + if (anyReading) return { kind: 'reading', paths: reading, reason: 'whitelisted reading command(s)' }; + return { kind: 'safe', paths: [], reason: 'whitelisted safe command(s)' }; +} + +// ── file-watcher: script execution of edited file ── +export function scriptWatcherCheck(segments, editedFiles = [], pathNormalize = defaultPathNormalize) { + const editedSet = new Set(editedFiles.map((f) => pathNormalize(f))); + for (const seg of segments) { + if (seg.tokens[0] !== 'node') continue; + for (const arg of extractPathArgs(seg.tokens)) { + if (/\.(mjs|js|cjs|ts)$/.test(arg) && editedSet.has(pathNormalize(arg))) { + return { block: true, reason: `file-watcher: запуск отредактированного в сессии скрипта «${arg}» запрещён до commit+GREEN (§5.1)` }; + } + } + } + return { block: false }; +} + +function readEditedFiles(sessionId) { + const path = join(homedir(), '.claude', 'runtime', `edited-files-${sessionId || 'unknown'}.json`); + if (!existsSync(path)) return []; + try { + const data = JSON.parse(readFileSync(path, 'utf-8')); + return Array.isArray(data) ? data : Array.isArray(data.files) ? data.files : []; + } catch { return []; } +} diff --git a/tools/enforce-router-gate.test.mjs b/tools/enforce-router-gate.test.mjs index 11baa0ac..5700715a 100644 --- a/tools/enforce-router-gate.test.mjs +++ b/tools/enforce-router-gate.test.mjs @@ -57,3 +57,33 @@ describe('matchBashHardBlacklist — allows benign', () => { }, ); }); + +import { classifyWhitelist, scriptWatcherCheck } from './enforce-router-gate.mjs'; + +describe('classifyWhitelist', () => { + it('marks reading commands', () => { + expect(classifyWhitelist([{ tokens: ['cat', 'app/x.php'], op: null }])).toMatchObject({ kind: 'reading' }); + }); + it('marks safe commands', () => { + expect(classifyWhitelist([{ tokens: ['npx', 'vitest', 'run'], op: null }])).toMatchObject({ kind: 'safe' }); + }); + it('returns null for non-whitelisted', () => { + expect(classifyWhitelist([{ tokens: ['foobar'], op: null }])).toBe(null); + }); + it('allows pipe of readers', () => { + const segs = [{ tokens: ['cat', 'a'], op: '|' }, { tokens: ['grep', 'x'], op: null }]; + expect(classifyWhitelist(segs)).not.toBe(null); + }); +}); + +describe('scriptWatcherCheck', () => { + it('blocks node execution of an edited file', () => { + const segs = [{ tokens: ['node', 'tools/evil.mjs'], op: null }]; + const r = scriptWatcherCheck(segs, ['tools/evil.mjs'], (p) => p); + expect(r.block).toBe(true); + }); + it('allows node execution of a non-edited file', () => { + const segs = [{ tokens: ['node', 'tools/ok.mjs'], op: null }]; + expect(scriptWatcherCheck(segs, ['tools/other.mjs'], (p) => p).block).toBe(false); + }); +});