Files
portal/tools/enforce-router-gate.mjs
T
Дмитрий 22e7ac278b feat(router-gate): allow cd into project worktree dirs for worktree dev
PR #41 re-scope enabled 'git worktree' creation but not working inside worktrees: only 'cd app' was whitelisted, so pest/git could not run in a worktree. Add a SAFE_EXACT rule allowing cd into a path with a worktree-/v4-stream- segment, excluding .. and protected segments (.claude/.ssh/.env/runtime/.git) so the cwd-shift read-bypass stays contained. TDD: +6 tests; full tools suite 1997 GREEN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 13:04:15 +03:00

266 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 запрещён' },
// stdout redirect (>/>>) — quote-aware проверка в matchBashHardBlacklist (STDOUT_REDIRECT_RE), не здесь (quirk 2, 2026-05-31)
{ 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 запрещён' },
// composer/npm перенесены в whitelist (dev-allow, 2026-06-02 re-scope) — это локальные
// инструменты разработки, не боевой контур. yarn/pnpm остаются заблокированы (проект на npm).
{ 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 запрещён' },
];
// stdout redirect operator: `>`/`>>` не после цифры/>/& (исключает fd-dup 1>&2)
// и не перед >/& (так `>>` — один матч, `1>&2`/`2>&1` не ловятся).
const STDOUT_REDIRECT_RE = /(?:^|[^0-9>&])>{1,2}(?![>&])/;
/**
* Бланкует нутро одинарно/двойно-кавыченных участков (сохраняя сами кавычки,
* длину и всё вне кавычек). Обратный слэш экранирует следующий символ (значит
* экранированная кавычка НЕ открывает участок). Нужно для quote-aware детекции
* редиректа (quirk 2): `>` внутри кавыченного аргумента (текст коммита, <email>)
* — не shell-редирект; настоящий оператор редиректа стоит ВНЕ кавычек и
* переживает бланковку.
*/
export function stripQuotedSpans(command) {
const s = String(command || '');
let out = '';
let quote = null;
let escaped = false;
for (const ch of s) {
if (escaped) { out += ch; escaped = false; continue; }
if (ch === '\\') { out += ch; escaped = true; continue; }
if (quote) {
if (ch === quote) { out += ch; quote = null; } else out += ' ';
continue;
}
if (ch === "'" || ch === '"') { out += ch; quote = ch; continue; }
out += ch;
}
return out;
}
export function matchBashHardBlacklist(command) {
const s = String(command || '');
if (hasInjection(s)) return '#34: echo/printf prompt-injection запрещён';
// Quote-aware redirect detection (quirk 2): `>` / `2>` ВНУТРИ кавычек (текст
// коммита с <email> или "2>1") — не редирект. Сначала бланкуем кавыченное;
// настоящие операторы редиректа вне кавычек — переживают.
const stripped = stripQuotedSpans(s);
const stderr = stderrRedirectBlock(stripped);
if (stderr) return stderr;
if (STDOUT_REDIRECT_RE.test(stripped)) return 'stdout redirect (>/>>) запрещён';
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/,
/^npm\s+(?:install|i|ci)\b/, // dev-allow 2026-06-02 re-scope
/^npm\s+run\s+[\w:-]+/, // dev-allow 2026-06-02 re-scope (любой npm-скрипт)
/^php\s+artisan\s+(?:list|route:list|migrate:status)\b/,
/^composer\s+(?:show|outdated|install|update|require|remove|dump-autoload|dump)\b/, // +dev-allow 2026-06-02 re-scope
/^node\s+(?!.*(?:-e|--eval|-p|--print|-r|--require|--import|--experimental-loader)\b)/,
// Laravel dev workflow (2026-05-30) — exclude tinker (REPL = arbitrary PHP exec risk).
// Hard-blacklist (composer install/update/require/remove) remains the first check, unaffected.
// `migrate(?=\s|$)` lookahead prevents `migrate:install` / `migrate:<unknown>` from matching bare `migrate`.
/^php\s+artisan\s+(?:test|migrate:fresh|migrate:rollback|migrate:refresh|migrate:reset|migrate(?=\s|$)|db:seed|cache:clear|config:clear|view:clear|route:clear|optimize:clear)\b/,
/^composer\s+(?:test|pint|stan|insights|rector)\b/,
/^(?:\.\/)?vendor\/bin\/pest\b/,
/^pest\b/,
// Narrow `cd app` (2026-05-31, owner-authorized) — enter the Laravel project dir
// so already-whitelisted commands (pest, php artisan test) run from app/.
// Scope deliberately limited to the literal `app` dir: `cd` into any other path
// (incl. protected .claude/runtime, memory/, transcripts) stays default-deny, so
// the cwd-shift read-bypass is contained. Mutations remain caught at the
// hard-blacklist + chain-mutating rule (both run before the whitelist), and each
// chain segment after `cd app &&` must still be independently whitelisted.
/^cd\s+app$/,
// Worktree dev (2026-06-02, owner-authorized): cd into a project worktree dir
// (path segment `worktree-` / `v4-stream-`) so git/pest run there. Quoted absolute
// path required; `..` and protected segments (.claude/.ssh/.env/runtime/.git) excluded
// → cwd-shift read-bypass stays contained (protected files also remain blocked by name
// in the command). cd into Документация/system/protected dirs → default-deny.
/^cd\s+(?=.*[\\/](?:worktree-|v4-stream-))(?!.*(?:\.\.|\.claude|\.ssh|\.env|runtime|\.git)).+$/,
];
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();