#!/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; }