3420f46a59
Shell resets cwd each call so a worktree cd does not persist; pointing git at the worktree dir is the cwd-independent way to commit there. classifyGitCommand now strips the leading working-dir flag before all checks, so the real subcommand is classified and all hard-patterns (hook-bypass, force-push, force-add, config-injection) plus the push-main-guard still apply. TDD: plus 6 tests; full tools suite 2003 GREEN. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
12 KiB
JavaScript
252 lines
12 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';
|
|
|
|
// ── 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
|
|
]);
|
|
// dev-safe (owner-authorized 2026-06-02 re-scope): allow без approval. GIT_HARD_PATTERNS
|
|
// (--no-verify / add -f / -c / force / --output / -o) пре-фильтруют опасные варианты ВЫШЕ.
|
|
const GIT_DEV_SUB = new Set([
|
|
'add', 'commit', 'branch', 'switch', 'checkout', 'stash', 'worktree',
|
|
]);
|
|
const GIT_CONDITIONAL_SUB = new Set([
|
|
'merge', 'rebase', 'reset', 'cherry-pick', 'revert', 'pull', '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) {
|
|
// Skip leading global flags `-c <val>` and `-C <path>`. `git -C <dir> <sub>` is the
|
|
// cwd-independent way to operate on a worktree (the shell resets cwd each call), so the
|
|
// real subcommand must be found after `-C`. `-C` (uppercase, working-dir) is case-distinct
|
|
// from the blocked `-c` config-injection (GIT_HARD_PATTERNS still scans the full command).
|
|
const m = normalizeCommand(command).match(
|
|
/\bgit\s+(?:(?:-c\s+\S+|-C\s+(?:"[^"]*"|'[^']*'|\S+))\s+)*([a-z][\w-]*)/,
|
|
);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
export function classifyGitCommand(command, ctx = {}) {
|
|
// Strip a leading `git -C <path>` (worktree-dir flag) so every rule below sees the real
|
|
// subcommand+flags. Without this, position-anchored hard-patterns (--no-verify / --force /
|
|
// add -f) and the push-main-guard would be bypassed by interposing `-C <dir>`.
|
|
const norm = normalizeCommand(command).replace(/(\bgit)\s+-C\s+(?:"[^"]*"|'[^']*'|\S+)\s+/, '$1 ');
|
|
if (!/\bgit\b/.test(norm)) return null;
|
|
const sub = gitSubcommand(norm);
|
|
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' };
|
|
}
|
|
|
|
// dev-safe git (owner-authorized 2026-06-02 re-scope): GIT_HARD_PATTERNS уже отсеяли
|
|
// опасные варианты (--no-verify / add -f / -c / force / --output / -o) на шаге 1.
|
|
if (GIT_DEV_SUB.has(sub)) return { result: 'allow', reason: `dev-safe git ${sub}` };
|
|
|
|
// push: фичевые ветки — allow; main/master — клик владельца (force уже заблокирован hard).
|
|
if (sub === 'push') {
|
|
if (/\b(?:main|master)\b/.test(norm)) {
|
|
return { result: 'block', reason: 'git push в main/master — клик владельца' };
|
|
}
|
|
return { result: 'allow', reason: 'git push в фичевую ветку' };
|
|
}
|
|
|
|
// 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` };
|
|
} |