b47a71c66b
Опечатанный ревью-план (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>
218 lines
11 KiB
JavaScript
218 lines
11 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,
|
||
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();
|