Files
brain/tools/enforce-router-gate.mjs
T
Дмитрий 3d7690650e feat(brain-config): shell-content защита config-driven (greenfield #3 shell)
buildProtectedPatterns 2-й параметр normativeFiles даёт anchored .md stem-паттерны; оба гейта в main строят protectedPaths из loadConfig (try/catch fallback DEFAULT). DEFAULT 32-34 сохранён (backward-compat); augment только добавляет защиту. shell-content-rules импортирует docStem из cross-ref-checker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:41:58 +03:00

212 lines
11 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,
buildProtectedPatterns,
extractPathArgs,
matchBashHardBlacklist,
BASH_HARD_BLACKLIST,
classifyGitCommand,
loadApprovedGitOps,
} from './shell-content-rules.mjs';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
// M7 Task 1.0.5 (P-1): content-blacklist + матчер + stderr-redirect-страж переехали
// в единый дом shell-content-rules.mjs (один источник правды для content-floor М5 и этого
// гейта; порт-дрейф невозможен). Ре-экспорт для обратной совместимости — тесты импортируют
// matchBashHardBlacklist отсюда, и тело гейта (ниже) зовёт его локально.
export { matchBashHardBlacklist, BASH_HARD_BLACKLIST };
// ── whitelist ──
export 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 — НЕ в SAFE_EXACT (Пакет 4.2, Δ4): обрабатывается отдельной ветвью
// classifyWhitelist через nodeScriptAllowed — сужение с «любой node-файл» до
// allowlist (плоский tools/<file>.mjs + vitest-runner + --version). Анти-казуальная
// мера (§7): остаток «контроллер commit'ит разрешённый скрипт» честно принят (Δ4),
// file-watcher (scriptWatcherCheck) дополнительно блокирует session-edited до commit.
// 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`.
// Машина 5 Пакет 2.4 (C4): migrate:fresh/refresh/reset УБРАНЫ из whitelist — это floor-набор
// (необратимый дроп БД, classify-destructive.mjs). Теперь они → default-deny router-gate'ом
// даже при незарегистрированном enforce-floor (защита-в-глубину SPOF). Остаются bare migrate
// (миграции вперёд) + migrate:rollback (обратимо).
/^php\s+artisan\s+(?:test|migrate:rollback|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$/,
];
// ── node-whitelist (Пакет 4.2, Δ4) ──
// Сужение: `node <script>` допускается ТОЛЬКО если script — плоский tools/<file>.{mjs,cjs,js}
// (свои гейты/тесты) ИЛИ vitest-runner (тест-чейн не ломается). Иначе default-deny.
// Инспектируем ТОКЕН пути (shell-quote сохраняет бэкслеши Windows-пути — инвариант в тесте),
// нормализуем \ → /. Бэкслеш-обход и пробелы в пути учтены. `node --version`/`-v` (без скрипта)
// — безвредная проверка версии. §7: анти-казуальная мера, не полный close (Δ4).
const NODE_SCRIPT_ALLOW_RE = [
/(?:^|\/)tools\/[^/]+\.(?:mjs|cjs|js)$/,
/(?:^|\/)node_modules\/vitest\/vitest\.mjs$/,
];
export function nodeScriptAllowed(tokens) {
const args = Array.isArray(tokens) ? tokens.slice(1) : [];
const script = args.find((t) => typeof t === 'string' && !t.startsWith('-'));
if (script === undefined) {
// нет позиционного скрипта → разрешаем только безвредную проверку версии
return args.some((t) => t === '--version' || t === '-v');
}
const norm = script.replace(/\\/g, '/');
return NODE_SCRIPT_ALLOW_RE.some((re) => re.test(norm));
}
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; }
if (cmd === 'node') {
if (nodeScriptAllowed(seg.tokens)) continue;
return null; // node-скрипт вне allowlist → default-deny
}
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();
let protectedPaths = DEFAULT_PROTECTED_PATTERNS;
try {
const { loadConfig } = await import('./brain-config.mjs');
const cfg = loadConfig();
protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files);
} catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
editedFiles: readEditedFiles(sessionId),
pathNormalize,
protectedPaths,
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();