Files
brain/tools/enforce-router-gate.mjs
T
Дмитрий b47a71c66b feat: D2 — канал коммита под ревью (агент коммитит под commit:<hash>)
Опечатанный ревью-план (GO наставника+судьи, judge_mode=live-block) + одно
согласие владельца `FLOOR-ESCAPE: commit:<plan-hash>` → агент делает git
add/commit/push без терминала владельца. Гейт ПРИСУТСТВИЯ (router-gate
git-approval) отходит; гейты КАЧЕСТВА (criterion-gate/verify-gate) НЕ тронуты —
код-коммит всё равно требует по-критерийный GREEN и свежую расписку. Согласия
деплоя (ops-runbook:) и коммита (commit:) — раздельные кнопки.

- escape-grant: обобщён plan-scoped загрузчик (loadPlanScopedGrants/
  planScopedGrantOpen, окно = существование плана); D1 ops-runbook стал тонкой
  обёрткой; добавлены commit: COMMIT_GRANT_PREFIX/loadCommitGrants/commitGrantOpen.
- commit-grant (новый мост план↔router-gate): commitGrantOpenForSession —
  открыт ли commit:<hash> на валидный sealed live-block план сессии.
- shell-content-rules classifyGitCommand: conditional-git пускается при
  ctx.commitGrantOpen; GIT_HARD (force-push/--no-verify/-c) блокирует ПЕРВЫМ
  (качество/безопасность не ослаблены).
- enforce-router-gate: main кладёт ctx.commitGrantOpen (gated через мост).

План: docs/superpowers/plans/2026-06-18-agent-commit-channel-plan.md
Спека: docs/superpowers/specs/2026-06-18-agent-commit-channel-design.md §3.1-3.2.

ОТЛОЖЕНО (требует решения владельца, в хвосте плана):
- §3.3 docs/ops без criterion/verify: .md уже пропускается; расширение на
  не-.md ops-артефакты конфликтует с CLAUDE.md §13 v2.40 — нужен явный список.
- §3.4 десинк push-последним-шагом: рискованная правка снятия печати стены.

+22 теста, свод 4319 passed / 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:58:05 +03:00

218 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 */ }
// D2 (канал коммита под ревью): открыт ли commit:<plan-hash> на валидный sealed live-block план.
// Мост commit-grant грузит план/гранты ТОЛЬКО при наличии commit-гранта; сбой → false (fail-safe:
// нет послабления гейта присутствия). Гейты КАЧЕСТВА (criterion/verify) — отдельные хуки, не здесь.
let commitGrantOpen = false;
try { const { commitGrantOpenForSession } = await import('./commit-grant.mjs'); commitGrantOpen = commitGrantOpenForSession(sessionId); } catch { commitGrantOpen = false; }
const ctx = {
approvedGitOps: loadApprovedGitOps(sessionId),
editedFiles: readEditedFiles(sessionId),
pathNormalize,
protectedPaths,
commitGrantOpen,
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();