3d7690650e
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>
371 lines
20 KiB
JavaScript
371 lines
20 KiB
JavaScript
#!/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` };
|
||
} |