Files
brain/tools/shell-content-rules.mjs
Дмитрий 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

371 lines
20 KiB
JavaScript
Raw Permalink 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
/**
* 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';
import { docStem } from './cross-ref-checker.mjs';
// ── 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,
];
/** fail-CLOSED augment (§D2): UNION базовых DEFAULT_PROTECTED_PATTERNS с config-путями.
* База всегда первая и не удаляется; пусто / не-массив → только база (защита байт-в-байт).
* Каждый config-путь экранируется и матчится по сегменту пути (^|/)… (case-insensitive). */
export function buildProtectedPatterns(configPaths = [], normativeFiles = []) {
const esc = (s) => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const extra = Array.isArray(configPaths)
? configPaths
.map((p) => String(p || '').replace(/\\/g, '/').trim())
.filter((p) => p.length > 0)
.map((p) => new RegExp('(^|/)' + esc(p), 'i'))
: [];
// #3-shell: нормативные доки из config → filename-anchored stem-паттерны (greenfield).
// DEFAULT (32-34) сохраняется; augment только ДОБАВЛЯет (защита монотонно растёт).
const stems = Array.isArray(normativeFiles)
? normativeFiles
.map((f) => docStem(f))
.filter((s) => s.length > 0)
.map((s) => new RegExp('(^|/)' + esc(s) + '[^/]*\\.md', 'i'))
: [];
return [...DEFAULT_PROTECTED_PATTERNS, ...extra, ...stems];
}
// 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));
}
// ── Bash hard-blacklist + stderr redirect (C16) — единый дом content-правил (M7 Task 1.0.5, P-1) ──
// Перенесён из enforce-router-gate.mjs: один источник правды, чтобы content-floor (М5) и
// router-gate (М-зоопарк, увольняется в Фазе 8) не расходились подмножествами.
const SAFE_SINKS = new Set(['/dev/null', '&1', '$null', 'nul']);
export function stderrRedirectBlock(cmd) {
// "2>&1 >file": stderr merged into stdout, then stdout redirected to a file → block.
if (/2>&1\s*>\s*[^\s|;&]/.test(cmd)) return 'C16: stderr→stdout с последующим file-redirect';
const RE = /(2>>|2>|&>>|&>|\|&)\s*([^\s|;&]+)?/g;
let m;
while ((m = RE.exec(cmd)) !== null) {
const op = m[1];
const after = cmd.slice(m.index + op.length);
if (/^\s*&\d/.test(after)) continue; // fd-duplication (2>&1, 1>&2) — no file, allow
const target = (m[2] || '').replace(/^['"]|['"]$/g, '');
if (!target) continue; // no file target captured → benign artifact
if (SAFE_SINKS.has(target)) continue;
return `C16: stderr redirect к «${target}» запрещён`;
}
return null;
}
// quote-aware redirect (порт прод-фикса b0cd18d7): `>` / `2>` внутри кавычек — литерал, не
// редирект (напр. `git commit -m "a > b"`). Стрип применяется ТОЛЬКО к redirect-проверке;
// остальной BASH_HARD_BLACKLIST идёт по СЫРОЙ строке (паттерны #4 node-inline-fs и пр. смотрят
// ВНУТРЬ кавычек — стрипать для них нельзя). Массив сохранён целым (инварианты §12 не трогаем).
export function stripQuotedSpans(s) {
return String(s || '').replace(/"[^"]*"|'[^']*'/g, ' ');
}
const STDOUT_REDIRECT_REASON = 'stdout redirect (>/>>) запрещён';
const STDOUT_REDIRECT_RE = /(?:^|[^0-9>&])>{1,2}(?![>&])/;
export const BASH_HARD_BLACKLIST = [
// v3.9 keep
{ re: /(^|\s|;|&&|\|\|)rm\b/, reason: 'rm запрещён' },
{ re: /(^|\s|;|&&|\|\|)mv\b/, reason: 'mv запрещён' },
{ re: /(^|\s|;|&&|\|\|)cp\b/, reason: 'cp запрещён' },
{ re: /(^|\s|;|&&|\|\|)chmod\b/, reason: 'chmod запрещён' },
{ re: /(^|\s|;|&&|\|\|)chown\b/, reason: 'chown запрещён' },
{ re: /(^|\s|;|&&|\|\|)chgrp\b/, reason: 'chgrp запрещён' },
{ re: STDOUT_REDIRECT_RE, reason: STDOUT_REDIRECT_REASON },
{ re: /\b(?:node|nodejs)\s+(?:[^|;]*\s)?(?:-e|--eval|-p|--print)\b/, reason: 'node -e/--eval/-p запрещён' },
{ re: /\bnode\s+(?:[^|;]*\s)?(?:-r|--require|--import|--experimental-loader)\b/, reason: 'node -r/--import запрещён' },
{ re: /\bpython3?\s+-c\b/, reason: 'python -c запрещён' },
{ re: /\b(?:bash|sh)\s+-c\b/, reason: 'bash/sh -c запрещён' },
{ re: /(^|\s|;|&&|\|\|)eval\b/, reason: 'eval запрещён' },
{ re: /\bcomposer\s+(?:install|update|require|remove)\b/, reason: 'composer install/update/require/remove запрещён' },
{ re: /\bnpm\s+(?:install|i|update|remove|uninstall)\b/, reason: 'npm install/update/remove запрещён' },
{ re: /\b(?:yarn|pnpm)\s+(?:add|install|remove)\b/, reason: 'yarn/pnpm add/install/remove запрещён' },
{ re: /\bnpx\s+claude-/, reason: 'npx claude-* запрещён' },
{ re: /\bcurl\b[^|;]*-X\s*(?:POST|PUT|DELETE|PATCH)\b/i, reason: 'curl -X POST/PUT/DELETE/PATCH запрещён' },
// v4.0
{ re: /\bnode\s+[^']*\s+(?:-[ep]\b|--eval|--print)\s+["'][^"']*\bfs\.\w+\b/, reason: '#4: node inline с fs.* запрещён' },
{ re: /\benv\s+(?:-i\s+|[A-Z_]+=\S+\s+)+(?:node|npx|python|php|ruby)\b/, reason: '#21: env-модификатор перед интерпретатором запрещён' },
{ re: /^(?:[A-Z_]+=\S+\s+)+(?:node|npx|python|php|ruby)\b/, reason: '#21: inline env-assign перед интерпретатором запрещён' },
{ re: /\b(?:node|npx|vitest|pest|nodemon)\s+[^|;]*--watch\b/, reason: '#22: --watch (persistent process) запрещён' },
// v4.1 G7/G8
{ re: /\bwget\b/, reason: 'G7: wget запрещён' },
{ re: /(^|\s|;|&&|\|\|)(?:nc|ncat|netcat)\b/, reason: 'G8: nc/ncat/netcat запрещён' },
{ re: /(^|\s|;|&&|\|\|)socat\b/, reason: 'G8: socat запрещён' },
];
export function matchBashHardBlacklist(command) {
const s = String(command || '');
if (hasInjection(s)) return '#34: echo/printf prompt-injection запрещён';
const stripped = stripQuotedSpans(s);
const stderr = stderrRedirectBlock(stripped);
if (stderr) return stderr;
for (const { re, reason } of BASH_HARD_BLACKLIST) {
if (!re.test(s)) continue;
// redirect-паттерн судим по строке без кавычек: `>` внутри кавычек — не редирект
if (reason === STDOUT_REDIRECT_REASON && !STDOUT_REDIRECT_RE.test(stripped)) continue;
return reason;
}
return null;
}
// ── PowerShell hard-blacklist — единый дом (M7 PS single-source, зеркало BASH_HARD_BLACKLIST/P-1) ──
// Перенесён из enforce-powershell-gate.mjs: ОДИН источник для content-floor (М5) и powershell-gate
// (увольняется Фаза 8). +bare-egress (floor НЕ default-deny → egress нужен в blacklist, не только
// в whitelist гейта) +rmdir (был только в floor-psContentBlock).
export const PS_HARD_BLACKLIST = [
// keep v3.8 F1 (+rmdir)
{ re: /\b(?:Remove-Item|ri|del|erase|rd|rmdir|rm)\b/i, reason: 'Remove-Item/del/rm запрещён' },
{ re: /\b(?:Move-Item|mi|move)\b/i, reason: 'Move-Item запрещён' },
{ re: /\b(?:Copy-Item|cpi|copy)\b/i, reason: 'Copy-Item запрещён' },
{ re: /\b(?:Set-Content|sc|Add-Content|ac|Out-File)\b/i, reason: 'Set/Add-Content/Out-File запрещён' },
{ re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'redirect (>/>>) запрещён' },
{ re: /\b(?:Invoke-Expression|iex)\b/i, reason: 'Invoke-Expression/iex запрещён' },
// bare-egress (M7 PS single-source): floor не default-deny → нужен явный blacklist-паттерн.
{ re: /\b(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm|curl|wget)\b/i, reason: 'egress (IWR/IRM/curl/wget) запрещён' },
{ re: /\b(?:Invoke-WebRequest|iwr|curl|wget)\b[^\n]*\|\s*(?:iex|Invoke-Expression)/i, reason: 'IWR | iex запрещён' },
{ re: /\bStart-Process\b/i, reason: 'Start-Process запрещён' },
{ re: /\[System\.IO\.File\]::(?:Delete|WriteAllText|WriteAllBytes|AppendAllText)\b/i, reason: '[IO.File] write/delete запрещён' },
{ re: /\[System\.IO\.Directory\]::(?:Delete|CreateDirectory)\b/i, reason: '[IO.Directory] mutate запрещён' },
{ re: /\b(?:Stop-Process|kill|spps)\b/i, reason: 'Stop-Process/kill запрещён' },
{ re: /\b(?:Stop-Service|Remove-Service|Set-Service|New-Service)\b/i, reason: 'service mutate запрещён' },
{ re: /\bSet-ExecutionPolicy\b/i, reason: 'Set-ExecutionPolicy запрещён' },
{ re: /\bSet-ItemProperty\b/i, reason: 'Set-ItemProperty запрещён' },
{ re: /\b(?:Get-Credential|Export-PSSession)\b/i, reason: 'Get-Credential/Export-PSSession запрещён' },
{ re: /\b(?:Restart-Computer|Stop-Computer)\b/i, reason: 'Restart/Stop-Computer запрещён' },
{ re: /\b(?:Register-ScheduledTask|Set-ScheduledTask)\b/i, reason: 'ScheduledTask mutate запрещён' },
{ re: /\b(?:Set-Acl|icacls)\b/i, reason: 'Set-Acl/icacls запрещён' },
{ re: /\bNew-Item\b[^\n]*-ItemType\s+(?:File|Directory)\b/i, reason: 'New-Item (mutate) запрещён' },
// v4.1 G10
{ re: /\$env:[A-Za-z_]+\s*=/i, reason: 'G10: $env:X = ... запрещён' },
{ re: /\[System\.Environment\]::SetEnvironmentVariable\b/i, reason: 'G10: SetEnvironmentVariable запрещён' },
{ re: /\bSet-Item\s+-Path\s+Env:/i, reason: 'G10: Set-Item Env: запрещён' },
{ re: /\bNew-PSDrive\b/i, reason: 'G10: New-PSDrive запрещён' },
{ re: /\bInvoke-Azure[A-Z]/, reason: 'G10: Azure cmdlet запрещён' },
{ re: /\b(?:Get|New|Set|Remove)-Az[A-Z]/, reason: 'G10: Az cmdlet запрещён' },
{ re: /\b(?:Get|New|Set|Remove)-AWS[A-Z]/, reason: 'G10: AWS cmdlet запрещён' },
{ re: /\bgcloud\s+(?:auth|compute|iam|storage)\b/, reason: 'G10: gcloud запрещён' },
];
export function matchPsHardBlacklist(command) {
const s = String(command || '');
if (hasInjection(s)) return '#34: Write-Output/echo prompt-injection запрещён';
return matchAny(PS_HARD_BLACKLIST, 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 <path> (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` };
}