feat(router-gate): bash whitelist + script-execution file-watcher
This commit is contained in:
@@ -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 []; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user