22e7ac278b
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>
266 lines
14 KiB
JavaScript
266 lines
14 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 запрещён' },
|
||
// 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();
|