feat(router-gate): bash whitelist + script-execution file-watcher

This commit is contained in:
Дмитрий
2026-05-29 20:06:04 +03:00
parent 76ef10ec26
commit f05a8a66b2
2 changed files with 77 additions and 0 deletions
+47
View File
@@ -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 []; }
}
+30
View File
@@ -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);
});
});