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>
121 lines
4.8 KiB
JavaScript
121 lines
4.8 KiB
JavaScript
#!/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();
|