feat: claude-brain — управляющий слой, выделен из Лидерры по ADR-020
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bash tokenizer — обёртка над shell-quote (router-gate v4 §5.1).
|
||||
* Возвращает segments (по control-операторам) + флаг sub-shell.
|
||||
* ParseError / unbalanced quotes → {ok:false} → вызывающий хук fail-CLOSE.
|
||||
*/
|
||||
import { parse } from 'shell-quote';
|
||||
|
||||
const CONTROL_OPS = new Set([';', '&&', '||', '|', '&']);
|
||||
|
||||
function hasUnbalancedQuotes(s) {
|
||||
let single = 0, double = 0, escaped = false;
|
||||
for (const ch of s) {
|
||||
if (escaped) { escaped = false; continue; }
|
||||
if (ch === '\\') { escaped = true; continue; }
|
||||
if (ch === "'" && double % 2 === 0) single++;
|
||||
else if (ch === '"' && single % 2 === 0) double++;
|
||||
}
|
||||
return single % 2 !== 0 || double % 2 !== 0;
|
||||
}
|
||||
|
||||
export function detectSubshell(raw) {
|
||||
const kinds = [];
|
||||
if (/`/.test(raw)) kinds.push('backtick');
|
||||
if (/\$\(/.test(raw)) kinds.push('cmd-subst');
|
||||
if (/<\(/.test(raw)) kinds.push('process-subst-in');
|
||||
if (/>\(/.test(raw)) kinds.push('process-subst-out');
|
||||
if (/<<-?\s*[\w'"]/.test(raw)) kinds.push('heredoc');
|
||||
return { found: kinds.length > 0, kinds };
|
||||
}
|
||||
|
||||
export function tokenizeBash(command) {
|
||||
if (typeof command !== 'string' || command.trim() === '') {
|
||||
return { ok: false, error: 'empty' };
|
||||
}
|
||||
if (hasUnbalancedQuotes(command)) return { ok: false, error: 'parse_error' };
|
||||
|
||||
let parsed;
|
||||
try { parsed = parse(command); } catch { return { ok: false, error: 'parse_error' }; }
|
||||
|
||||
const subshell = detectSubshell(command);
|
||||
const segments = [];
|
||||
let cur = [];
|
||||
for (const e of parsed) {
|
||||
if (typeof e === 'string') { cur.push(e); continue; }
|
||||
if (e && typeof e === 'object' && 'op' in e) {
|
||||
if (e.op === 'glob') { cur.push(e.pattern); continue; }
|
||||
if (CONTROL_OPS.has(e.op)) { segments.push({ tokens: cur, op: e.op }); cur = []; continue; }
|
||||
cur.push(e.op); // redirect or other op kept as token
|
||||
continue;
|
||||
}
|
||||
// comment object {comment} — ignore
|
||||
}
|
||||
if (cur.length) segments.push({ tokens: cur, op: null });
|
||||
return { ok: true, raw: command, hasSubshell: subshell.found, subshellKinds: subshell.kinds, segments };
|
||||
}
|
||||
|
||||
// ── mutating detection (for chain rule §5.1 C13) ──
|
||||
const MUTATING_CMDS = new Set([
|
||||
'rm', 'mv', 'cp', 'chmod', 'chown', 'chgrp', 'dd', 'truncate', 'tee',
|
||||
'mkdir', 'rmdir', 'ln', 'touch', 'sed', 'curl', 'wget', 'nc', 'ncat',
|
||||
'netcat', 'socat', 'kill', 'killall',
|
||||
]);
|
||||
const GIT_MUTATING_SUB = new Set([
|
||||
'commit', 'push', 'merge', 'rebase', 'reset', 'checkout', 'switch',
|
||||
'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'clean', 'add',
|
||||
'rm', 'mv', 'tag', 'apply', 'am',
|
||||
]);
|
||||
const PKG_MUTATING_SUB = new Set(['install', 'update', 'require', 'remove', 'add', 'i']);
|
||||
|
||||
export function isMutatingSegment(tokens) {
|
||||
if (!Array.isArray(tokens) || tokens.length === 0) return false;
|
||||
const cmd = tokens[0];
|
||||
if (MUTATING_CMDS.has(cmd)) return true;
|
||||
if (cmd === 'git' && GIT_MUTATING_SUB.has(tokens[1])) return true;
|
||||
if (['composer', 'npm', 'yarn', 'pnpm'].includes(cmd) && PKG_MUTATING_SUB.has(tokens[1])) return true;
|
||||
// redirect operators present in the segment
|
||||
if (tokens.some((t) => t === '>' || t === '>>')) return true;
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user