Files
portal/tools/shell-content-rules.mjs
T

111 lines
4.1 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,
/(^|\/)\.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,
];
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 [];
return tokens.slice(1).filter((t) => typeof t === 'string' && !t.startsWith('-') && t !== '>' && t !== '>>');
}
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);
}