Files
portal/tools/shell-content-rules.mjs
T
Дмитрий 1c251d2592 refactor(m7-floor): matchBashHardBlacklist -> shell-content-rules (единый дом content-правил, P-1)
Task 1.0.5 Фазы 1 М7. Перенос BASH_HARD_BLACKLIST + stderrRedirectBlock +
matchBashHardBlacklist из enforce-router-gate.mjs в постоянный дом
shell-content-rules.mjs (там уже живут hasInjection + matchAny). router-gate
ре-экспортирует их для обратной совместимости (тесты + тело гейта).

Единый источник правды устраняет port-дрейф content-floor (М5) по конструкции:
content-block пола (Task 1.1/1.3) импортирует ТОТ ЖЕ матчер, а не ручную копию.

Тесты: +describe single-source identity (router-gate BASH_HARD_BLACKLIST ===
shell-content-rules ссылка) + matchBashHardBlacklist hosted-in-SCR. 233 GREEN.
Чистый рефактор-перенос, 0 изменений семантики.
2026-06-08 08:51:31 +03:00

287 lines
14 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
/**
* 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));
}
// ── 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;
}
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: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'stdout redirect (>/>>) запрещён' },
{ 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 stderr = stderrRedirectBlock(s);
if (stderr) return stderr;
return matchAny(BASH_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` };
}