208 lines
9.7 KiB
JavaScript
208 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* PreToolUse Bash gate (router-gate v4 §5.1).
|
||
* Default-deny: команда не в whitelist → block. Hard-blacklist + sub-shell
|
||
* sweep + chain-mutating + git (shared classifyGitCommand) + path-deny + watcher.
|
||
* ParseError → fail-CLOSE.
|
||
*/
|
||
import { fileURLToPath } from 'url';
|
||
import { readFileSync, existsSync } from 'fs';
|
||
import { join } from 'path';
|
||
import { homedir } from 'os';
|
||
import { tokenizeBash, isMutatingSegment } from './bash-tokenizer.mjs';
|
||
import {
|
||
defaultPathNormalize,
|
||
DEFAULT_PROTECTED_PATTERNS,
|
||
pathDenyOverlay,
|
||
extractPathArgs,
|
||
matchAny,
|
||
hasInjection,
|
||
classifyGitCommand,
|
||
loadApprovedGitOps,
|
||
} from './shell-content-rules.mjs';
|
||
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
|
||
|
||
// ── stderr redirect (C16) ──
|
||
const SAFE_SINKS = new Set(['/dev/null', '&1', '$null', 'nul']);
|
||
|
||
function stderrRedirectBlock(cmd) {
|
||
// "2>&1 >file": stderr merged into stdout, then stdout redirected to a file → block.
|
||
if (/2>&1\s*>\s*[^\s|;&]/.test(cmd)) return 'C16: stderr→stdout с последующим file-redirect';
|
||
const RE = /(2>>|2>|&>>|&>|\|&)\s*([^\s|;&]+)?/g;
|
||
let m;
|
||
while ((m = RE.exec(cmd)) !== null) {
|
||
const op = m[1];
|
||
const after = cmd.slice(m.index + op.length);
|
||
if (/^\s*&\d/.test(after)) continue; // fd-duplication (2>&1, 1>&2) — no file, allow
|
||
const target = (m[2] || '').replace(/^['"]|['"]$/g, '');
|
||
if (!target) continue; // no file target captured → benign artifact
|
||
if (SAFE_SINKS.has(target)) continue;
|
||
return `C16: stderr redirect к «${target}» запрещён`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export const BASH_HARD_BLACKLIST = [
|
||
// v3.9 keep
|
||
{ re: /(^|\s|;|&&|\|\|)rm\b/, reason: 'rm запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)mv\b/, reason: 'mv запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)cp\b/, reason: 'cp запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)chmod\b/, reason: 'chmod запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)chown\b/, reason: 'chown запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)chgrp\b/, reason: 'chgrp запрещён' },
|
||
{ re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'stdout redirect (>/>>) запрещён' },
|
||
{ re: /\b(?:node|nodejs)\s+(?:[^|;]*\s)?(?:-e|--eval|-p|--print)\b/, reason: 'node -e/--eval/-p запрещён' },
|
||
{ re: /\bnode\s+(?:[^|;]*\s)?(?:-r|--require|--import|--experimental-loader)\b/, reason: 'node -r/--import запрещён' },
|
||
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
|
||
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
|
||
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
|
||
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
|
||
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
|
||
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
|
||
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
|
||
// v4.0
|
||
{ re: /\bnode\s+[^']*\s+(?:-[ep]\b|--eval|--print)\s+["'][^"']*\bfs\.\w+\b/, reason: '#4: node inline с fs.* запрещён' },
|
||
{ re: /\benv\s+(?:-i\s+|[A-Z_]+=\S+\s+)+(?:node|npx|python|php|ruby)\b/, reason: '#21: env-модификатор перед интерпретатором запрещён' },
|
||
{ re: /^(?:[A-Z_]+=\S+\s+)+(?:node|npx|python|php|ruby)\b/, reason: '#21: inline env-assign перед интерпретатором запрещён' },
|
||
{ re: /\b(?:node|npx|vitest|pest|nodemon)\s+[^|;]*--watch\b/, reason: '#22: --watch (persistent process) запрещён' },
|
||
// v4.1 G7/G8
|
||
{ re: /\bwget\b/, reason: 'G7: wget запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)(?:nc|ncat|netcat)\b/, reason: 'G8: nc/ncat/netcat запрещён' },
|
||
{ re: /(^|\s|;|&&|\|\|)socat\b/, reason: 'G8: socat запрещён' },
|
||
];
|
||
|
||
export function matchBashHardBlacklist(command) {
|
||
const s = String(command || '');
|
||
if (hasInjection(s)) return '#34: echo/printf prompt-injection запрещён';
|
||
const stderr = stderrRedirectBlock(s);
|
||
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 []; }
|
||
}
|
||
|
||
export function classifyBashCommand(command, ctx = {}) {
|
||
const tok = tokenizeBash(command);
|
||
if (!tok.ok) return { result: 'block', reason: 'invalid shell syntax — переформулируй команду' };
|
||
if (tok.hasSubshell) return { result: 'block', reason: `sub-shell construct (${tok.subshellKinds.join(', ')}) — hard-blocked (§5.1)` };
|
||
|
||
// 1. raw hard-blacklist (redirects, C16, #4/#21/#22/#34, G7/G8, rm/composer/npm/...)
|
||
const hb = matchBashHardBlacklist(command);
|
||
if (hb) return { result: 'block', reason: hb };
|
||
|
||
// 2. chain (>1 segment) where ANY part mutating → block (C13)
|
||
if (tok.segments.length > 1 && tok.segments.some((s) => isMutatingSegment(s.tokens))) {
|
||
return { result: 'block', reason: 'chain (;/&&/||/|) с мутирующей частью — hard-blocked (C13)' };
|
||
}
|
||
|
||
// 3. single git command → shared git classifier
|
||
if (tok.segments.length === 1 && tok.segments[0].tokens[0] === 'git') {
|
||
const git = classifyGitCommand(command, ctx);
|
||
if (git) return git;
|
||
}
|
||
|
||
// 4. whitelist + path-deny + watcher
|
||
const wl = classifyWhitelist(tok.segments);
|
||
if (wl) {
|
||
if (wl.kind === 'reading') {
|
||
const pd = pathDenyOverlay({
|
||
candidatePaths: wl.paths,
|
||
pathNormalize: ctx.pathNormalize,
|
||
protectedPaths: ctx.protectedPaths,
|
||
});
|
||
if (pd.block) return { result: 'block', reason: pd.reason };
|
||
}
|
||
const sw = scriptWatcherCheck(tok.segments, ctx.editedFiles, ctx.pathNormalize || defaultPathNormalize);
|
||
if (sw.block) return { result: 'block', reason: sw.reason };
|
||
return { result: 'allow', reason: wl.reason };
|
||
}
|
||
|
||
// 5. default-deny
|
||
return { result: 'block', reason: 'команда не в whitelist — default-deny (§5.1)' };
|
||
}
|
||
|
||
// Re-export для Stream A decide() (bashContentClassify interface, master plan §4).
|
||
export { classifyBashCommand as bashContentClassify };
|
||
|
||
// Swap-at-merge: пытаемся подтянуть реальный normalize Stream A; иначе fallback.
|
||
export async function resolvePathNormalize() {
|
||
try {
|
||
const mod = await import('./path-normalization.mjs');
|
||
if (typeof mod.pathNormalize === 'function') return mod.pathNormalize;
|
||
if (typeof mod.default === 'function') return mod.default;
|
||
} catch { /* Stream A not merged yet */ }
|
||
return defaultPathNormalize;
|
||
}
|
||
|
||
async function main() {
|
||
try {
|
||
const raw = await readStdin();
|
||
const event = parseEventJson(raw);
|
||
if (event.tool_name !== 'Bash') { exitDecision({ block: false }); return; }
|
||
const command = (event.tool_input && event.tool_input.command) || '';
|
||
const sessionId = event.session_id || 'unknown';
|
||
const pathNormalize = await resolvePathNormalize();
|
||
const ctx = {
|
||
approvedGitOps: loadApprovedGitOps(sessionId),
|
||
editedFiles: readEditedFiles(sessionId),
|
||
pathNormalize,
|
||
protectedPaths: DEFAULT_PROTECTED_PATTERNS,
|
||
now: Date.now(),
|
||
};
|
||
const verdict = classifyBashCommand(command, ctx);
|
||
exitDecision(verdict.result === 'block' ? { block: true, message: `[router-gate] ${verdict.reason}` } : { block: false });
|
||
} catch {
|
||
// fail-CLOSE: внутренняя ошибка гейта → блок (безопасный дефолт для security-хука)
|
||
exitDecision({ block: true, message: '[router-gate] внутренняя ошибка гейта — fail-CLOSE' });
|
||
}
|
||
}
|
||
|
||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||
if (isCli) main();
|