#!/usr/bin/env node /** * PreToolUse PowerShell gate (router-gate v4 §5.1.2). Зеркало Bash-гейта: * default-deny whitelist + hard-blacklist (keep v3.8 F1 + v4.1 G10) + * injection + path-deny + git через shared classifyGitCommand. Fail-CLOSE. */ import { fileURLToPath } from 'url'; import { defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS, pathDenyOverlay, buildProtectedPatterns, classifyGitCommand, loadApprovedGitOps, matchPsHardBlacklist, PS_HARD_BLACKLIST, } from './shell-content-rules.mjs'; import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; // M7 PS single-source (P-1 зеркало): PS_HARD_BLACKLIST + matchPsHardBlacklist живут в едином доме // shell-content-rules; ре-экспорт для обратной совместимости (тесты + тело гейта зовут локально). export { matchPsHardBlacklist, PS_HARD_BLACKLIST }; // PowerShell — лёгкий сплиттер по ; | && || (без shell-quote: иной синтаксис). export function tokenizePowerShell(command) { const parts = String(command || '').split(/\s*(?:\|\||&&|[;|])\s*/).filter((p) => p.trim() !== ''); return parts.map((p) => { const trimmed = p.trim(); const m = trimmed.match(/^([A-Za-z][\w-]*|\[[^\]]+\]::\w+|\$env:[A-Za-z_]+)/); return { raw: trimmed, cmd: (m ? m[1] : trimmed).toLowerCase() }; }); } // whitelist cmdlets (lowercased) + aliases const PS_READING = new Set([ 'get-childitem', 'gci', 'ls', 'dir', 'select-string', 'sls', 'get-content', 'gc', 'cat', 'type', 'get-item', 'gi', 'get-itemproperty', 'gp', ]); const PS_SAFE = new Set([ 'test-path', 'resolve-path', 'rvpa', 'get-location', 'gl', 'pwd', 'get-process', 'gps', 'ps', 'get-date', 'measure-object', 'sort-object', 'where-object', 'foreach-object', 'select-object', ]); function psPathArgs(raw) { // tokens после команды; убираем флаги (-X), оператор -Path сам по себе тоже флаг const toks = raw.split(/\s+/).slice(1); const out = []; for (const t of toks) { if (t.startsWith('-')) continue; if (t.startsWith('"') || t.startsWith("'") || /[\/\\~.]/.test(t)) out.push(t.replace(/^['"]|['"]$/g, '')); } return out; } export function classifyPowerShellCommand(command, ctx = {}) { const s = String(command || ''); if (s.trim() === '') return { result: 'block', reason: 'пустая команда' }; const hb = matchPsHardBlacklist(s); if (hb) return { result: 'block', reason: hb }; const segs = tokenizePowerShell(s); for (const seg of segs) { if (seg.cmd === 'git') { const git = classifyGitCommand(seg.raw, ctx); if (git && git.result === 'block') return git; if (git) continue; // allowed git segment } if (PS_READING.has(seg.cmd)) { const pd = pathDenyOverlay({ candidatePaths: psPathArgs(seg.raw), pathNormalize: ctx.pathNormalize, protectedPaths: ctx.protectedPaths, }); if (pd.block) return { result: 'block', reason: pd.reason }; continue; } if (PS_SAFE.has(seg.cmd)) continue; return { result: 'block', reason: `cmdlet «${seg.cmd}» не в whitelist — default-deny (§5.1.2)` }; } return { result: 'allow', reason: 'whitelisted PowerShell command(s)' }; } async function resolvePathNormalize() { try { const mod = await import('./path-normalization.mjs'); if (typeof mod.pathNormalize === 'function') return mod.pathNormalize; if (typeof mod.default === 'function') return mod.default; } catch { /* Stream A not merged */ } return defaultPathNormalize; } async function main() { try { const raw = await readStdin(); const event = parseEventJson(raw); if (event.tool_name !== 'PowerShell') { exitDecision({ block: false }); return; } const command = (event.tool_input && event.tool_input.command) || ''; const sessionId = event.session_id || 'unknown'; let protectedPaths = DEFAULT_PROTECTED_PATTERNS; try { const { loadConfig } = await import('./brain-config.mjs'); const cfg = loadConfig(); protectedPaths = buildProtectedPatterns(cfg.protected_paths, cfg.normative_files); } catch { /* дефолт DEFAULT_PROTECTED_PATTERNS */ } const ctx = { approvedGitOps: loadApprovedGitOps(sessionId), pathNormalize: await resolvePathNormalize(), protectedPaths, now: Date.now(), }; const verdict = classifyPowerShellCommand(command, ctx); exitDecision(verdict.result === 'block' ? { block: true, message: `[powershell-gate] ${verdict.reason}` } : { block: false }); } catch { exitDecision({ block: true, message: '[powershell-gate] внутренняя ошибка — fail-CLOSE' }); } } const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]; if (isCli) main();