Files
portal/tools/enforce-router-gate.mjs
T

216 lines
11 KiB
JavaScript
Raw Normal View History

#!/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,
matchBashHardBlacklist,
BASH_HARD_BLACKLIST,
classifyGitCommand,
loadApprovedGitOps,
} from './shell-content-rules.mjs';
import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs';
// Owner-authorized 2026-06-18: строго read-only удалённый доступ к боевому порталу
// (ssh liderra-prod '<read>' / gh GET) — разрешитель в remote-read-allow.mjs.
import { classifyRemoteReadCommand } from './remote-read-allow.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;
}
// 3.5 remote read-only (owner-authorized 2026-06-18, conversational-mode prod read):
// ssh liderra-prod '<одна read-команда>' / gh <GET>. Строгий разборщик
// (remote-read-allow.mjs) уже после hard-blacklist (шаг 1) и chain-mutating (шаг 2),
// так что rm/mv/редирект в удалённой строке отсекаются раньше. Любое отклонение
// от формы → rr.ok=false → падает в whitelist/default-deny ниже.
const rr = classifyRemoteReadCommand(command);
if (rr.ok) return { result: 'allow', reason: `remote read-only ${rr.kind} (ssh liderra-prod / gh GET) — reading` };
// 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();