#!/usr/bin/env node /** * Shared shell content rules для router-gate v4 (§5.1 + §5.1.2). * Используется Bash-гейтом (enforce-router-gate.mjs) и PowerShell-гейтом * (enforce-powershell-gate.mjs). Без хук-I/O — чистые функции + чтение * approve-решений из ~/.claude/runtime. */ import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; // ── Path normalization (Stream A заглушка; реальная — path-normalization.mjs) ── export function defaultPathNormalize(target) { if (typeof target !== 'string') return ''; let t = target.trim().replace(/^['"]|['"]$/g, ''); t = t.replace(/\\/g, '/'); const home = homedir().replace(/\\/g, '/'); t = t.replace(/^~(?=\/|$)/, home); return t; } // Минимальный protected-list (полный — gate-config.json, Stream C/G). export const DEFAULT_PROTECTED_PATTERNS = [ /(^|\/)\.claude\/runtime(\/|$)/i, /(^|\/)\.claude\/settings(\.local)?\.json$/i, // Smoke 5 emergency fix (2026-05-30) — transcript JSONL hard-deny (spec §3.1 was declared, not implemented). // Prevents self-exfil of parent context across sessions via Bash cat / PowerShell Get-Content / Read tool. /(^|\/)\.claude\/projects(\/|$)/i, /(^|\/)\.env(\.|$)/i, /(^|\/)node_modules\//i, /(^|\/)CLAUDE\.md$/i, /Pravila_raboty_Claude/i, /Plugin_stack_rules/i, /Tooling_v8_3/i, /(^|\/)memory\//i, /(^|\/)tools\/dep-checksums\.json$/i, /(^|\/)\.git\/hooks\//i, /(^|\/)lefthook\.ya?ml$/i, /(^|\/)\.gitleaks/i, /(^|\/)\.npmrc$/i, ]; // Read-tool deny list — narrower than DEFAULT_PROTECTED_PATTERNS (over-block fix 2026-05-31). // Smoke 5 reused the full protected-list for the Read tool, which blocked Read of // CLAUDE.md, the normative docs and the memory/ index — breaking the legit // claude-md-management / memory-sync workflow (harness Edit requires a prior Read). // Read of those files has NO exfil value: CLAUDE.md / Pravila / PSR / Tooling are // public-in-repo, memory/ is the controller's own index. The genuine Read-exfil // targets are cross-session transcripts (.jsonl), runtime side-channels, settings // and secrets — those stay blocked here. The Bash/PowerShell read gate (cat / // Get-Content) and the Write gate keep using the full DEFAULT_PROTECTED_PATTERNS, // so CLAUDE.md / memory remain protected against shell-read and overwrite. // NB: `.claude/projects/.*\.jsonl$` matches transcripts but NOT the `memory/` // subdirectory (memory files are *.md), so MEMORY.md stays readable. export const READ_DENY_PATTERNS = [ /(^|\/)\.claude\/projects\/.*\.jsonl$/i, // cross-session transcripts (parent-context exfil) /(^|\/)\.claude\/runtime(\/|$)/i, // runtime side-channels (approve files, sentinels, state) /(^|\/)\.claude\/settings(\.local)?\.json$/i, // harness/hook config /(^|\/)\.env(\.|$)/i, // secrets ]; export function isProtectedPath(p, pathNormalize = defaultPathNormalize, patterns = DEFAULT_PROTECTED_PATTERNS) { const n = pathNormalize(p); if (!n) return false; return patterns.some((re) => re.test(n)); } // ── generic helpers ── export function normalizeCommand(cmd) { return String(cmd || '').replace(/\s+/g, ' ').trim(); } export function matchAny(patterns, str) { for (const { re, reason } of patterns) { if (re.test(str)) return reason; } return null; } export function extractPathArgs(tokens) { if (!Array.isArray(tokens)) return []; const out = []; for (let i = 1; i < tokens.length; i++) { const t = tokens[i]; if (typeof t !== 'string') continue; if (t === '>' || t === '>>' || t === '<' || t === '|') continue; // --flag=VALUE form if (t.startsWith('-')) { const eq = t.indexOf('='); if (eq > 0) { const v = t.slice(eq + 1); if (v && !looksLikeUrl(v)) out.push(v); } continue; } // key=value form (dd-style) const kv = t.match(/^([a-zA-Z_][\w-]*)=(.+)$/); if (kv) { const v = kv[2]; if (v && !looksLikeUrl(v)) out.push(v); continue; } if (!looksLikeUrl(t)) out.push(t); } return out; } function looksLikeUrl(s) { return /^https?:\/\//i.test(s) || /^ftp:\/\//i.test(s) || /^ssh:\/\//i.test(s); } export function pathDenyOverlay({ candidatePaths = [], pathNormalize = defaultPathNormalize, protectedPaths = DEFAULT_PROTECTED_PATTERNS, } = {}) { for (const p of candidatePaths) { if (isProtectedPath(p, pathNormalize, protectedPaths)) { return { block: true, reason: `path-deny: доступ к защищённому пути «${pathNormalize(p)}» запрещён (§3.1)`, path: pathNormalize(p) }; } } return { block: false }; } // ── #34 prompt-injection через echo/printf/Write-Output ── export const INJECTION_PATTERNS = [ /\b(?:echo|printf|Write-Output|Write-Host)\s+["'][^"']*(?:делай|вызови|напиши Claude|скажи Claude|в следующем сообщении|следующий prompt|next prompt|ignore previous|игнорируй)/iu, ]; export function hasInjection(cmd) { const s = String(cmd || ''); return INJECTION_PATTERNS.some((re) => re.test(s)); } // ── approve_git_operation (Stream E пишет; мы читаем) ── const APPROVE_WINDOW_MS = 5 * 60 * 1000; export function isApproved(command, approvedGitOps, now = Date.now()) { if (!Array.isArray(approvedGitOps) || approvedGitOps.length === 0) return false; const target = normalizeCommand(command); return approvedGitOps.some( (op) => normalizeCommand(op.command) === target && typeof op.ts === 'number' && now - op.ts <= APPROVE_WINDOW_MS, ); } export function loadApprovedGitOps(sessionId, now = Date.now()) { const path = join(homedir(), '.claude', 'runtime', `askuser-decisions-${sessionId || 'unknown'}.jsonl`); if (!existsSync(path)) return []; const out = []; try { const lines = readFileSync(path, 'utf-8').split(/\r?\n/); for (const line of lines) { if (!line.trim()) continue; let rec; try { rec = JSON.parse(line); } catch { continue; } if (rec && rec.type === 'approve_git_operation' && typeof rec.command === 'string') { out.push({ command: rec.command, ts: typeof rec.ts === 'number' ? rec.ts : 0 }); } } } catch { return []; } return out.filter((op) => now - op.ts <= APPROVE_WINDOW_MS); } // ── git classification (shared Bash + PowerShell) ── const GIT_READONLY_SUB = new Set([ 'status', 'log', 'show', 'diff', 'blame', 'format-patch', 'rev-parse', 'merge-base', 'remote', 'stash', // stash list/show resolved below 'fetch', 'ls-remote', // ref-only, no working-tree mutation — Stream H pre-flight requires §15.2 sync ]); const GIT_CONDITIONAL_SUB = new Set([ 'add', 'commit', 'merge', 'rebase', 'reset', 'checkout', 'switch', 'branch', 'stash', 'cherry-pick', 'revert', 'pull', 'push', 'clean', ]); // G5/G6 + force-push + add -f → always block (даже если "approved"). const GIT_HARD_PATTERNS = [ { re: /\bgit\s+(?:commit|push|tag|merge|rebase|cherry-pick|revert)\b[^\n]*--no-verify\b/, reason: 'G5: git --no-verify (обход хуков) запрещён' }, { re: /\bgit\s+-c\s+(?:commit|tag)\.gpgsign\s*=\s*false\b/, reason: 'G6: обход gpg-подписи запрещён' }, { re: /\bgit\s+commit\b[^\n]*--no-gpg-sign\b/, reason: 'G6: --no-gpg-sign запрещён' }, { re: /\bgit\s+push\b[^\n]*(?:--force\b|--force-with-lease\b|\s-f\b)/, reason: 'git push --force запрещён' }, { re: /\bgit\s+add\b[^\n]*\s-f\b/, reason: 'git add -f (форс gitignored) запрещён' }, { re: /\bgit\s+-c\b/, reason: 'git -c config-injection (core.pager/sshCommand/diff.external RCE) запрещён' }, { re: /\bgit\b[^\n]*\s(?:--exec\b|--upload-pack\b|--receive-pack\b|--ext-diff\b)/, reason: 'git --exec/--ext-diff/--upload-pack/--receive-pack запрещён' }, { re: /\bgit\b[^\n]*\s(?:--output|--file)=/, reason: 'git --output=/--file= (write) запрещён' }, { re: /\bgit\b[^\n]*\s-o\s+\S/, reason: 'git -o (write) запрещён' }, ]; function gitSubcommand(command) { const m = normalizeCommand(command).match(/\bgit\s+(?:-c\s+\S+\s+)*([a-z][\w-]*)/); return m ? m[1] : null; } export function classifyGitCommand(command, ctx = {}) { const norm = normalizeCommand(command); if (!/\bgit\b/.test(norm)) return null; const sub = gitSubcommand(command); if (!sub) return null; // 1. git-hard — block безусловно const hard = matchAny(GIT_HARD_PATTERNS, norm); if (hard) return { result: 'block', reason: hard }; // 2. stash/remote: list/show readonly; pop/apply/drop/clear/push/save conditional if (sub === 'stash') { if (/\bgit\s+stash\s+(?:list|show)\b/.test(norm)) return { result: 'allow', reason: 'readonly git stash' }; // fallthrough → conditional } if (sub === 'branch') { if (/\bgit\s+branch\s+(?:--show-current|-a|-r|--list)\b/.test(norm) || /\bgit\s+branch\s*$/.test(norm)) return { result: 'allow', reason: 'readonly git branch' }; // fallthrough → conditional } if (sub === 'remote') { if (/\bgit\s+remote\s+(?:-v\b|show\b|$)/.test(norm)) return { result: 'allow', reason: 'readonly git remote' }; return { result: 'block', reason: 'git remote (мутация) требует AskUser approval' }; } // 3. conditional → approve check if (GIT_CONDITIONAL_SUB.has(sub)) { const approved = isApproved(command, ctx.approvedGitOps, ctx.now ?? Date.now()); if (approved) return { result: 'allow', reason: `git ${sub}: подтверждено approve_git_operation` }; return { result: 'block', reason: `git ${sub} требует AskUser approval (approve_git_operation). Запросите подтверждение и повторите.` }; } // 4. readonly if (GIT_READONLY_SUB.has(sub)) return { result: 'allow', reason: `readonly git ${sub}` }; // 5. unknown git subcommand → default-deny return { result: 'block', reason: `git ${sub} не в whitelist — default-deny` }; }