Merge reviewed PowerShell enforcement fixes for 2.2.1

# Conflicts:
#	tests/hooks/gateguard-fact-force.test.js
This commit is contained in:
haelyra
2026-09-07 16:38:41 -04:00
11 changed files with 3698 additions and 26 deletions
+44 -14
View File
@@ -10,8 +10,8 @@
*
* Gates:
* - Edit/Write: list importers, affected API, verify data schemas, quote instruction
* - Bash (destructive): list targets, rollback plan, quote instruction
* - Bash (routine): quote current instruction (once per session)
* - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction
* - Bash/PowerShell (routine): quote current instruction (once per session)
*
* Compatible with run-with-flags.js via module.exports.run().
* Cross-platform (Windows, macOS, Linux).
@@ -26,6 +26,7 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command');
const { stripHeredocBodies } = require('./gateguard-heredoc');
// Session state — scoped per session to avoid cross-session races.
@@ -42,10 +43,13 @@ const MAX_SESSION_KEYS = 50;
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
const POWERSHELL_HOOK_ID = 'pre:powershell:gateguard-fact-force';
const EDIT_WRITE_NARROW_RECOVERY_HINT =
'Narrow recovery: add a matching path glob to `GATEGUARD_EXEMPT_GLOBS` to skip first-touch Edit/Write checks without disabling destructive Bash checks.';
const ROUTINE_BASH_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash checks remain active.';
const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash and PowerShell checks remain active.';
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
@@ -737,6 +741,27 @@ function isDestructiveBash(command) {
return false;
}
/**
* Return the stable, non-sensitive rule IDs that drive the destructive gate.
* PowerShell also passes through the existing Bash-compatible classifier so
* shell-agnostic git, SQL, and operator-configured rules retain coverage.
* Governance consumes this exact decision for PowerShell approval evidence.
*
* @param {string} toolName
* @param {string} command
* @returns {string[]}
*/
function classifyDestructiveCommand(toolName, command) {
const normalizedTool = String(toolName || '').toLowerCase();
if (normalizedTool !== 'bash' && normalizedTool !== 'powershell') return [];
const findings = [
...(isDestructiveBash(command) ? ['gateguard.bash-compatible-destructive'] : []),
...(normalizedTool === 'powershell' ? classifyPowerShellDestructiveCommand(command) : []),
];
return [...new Set(findings)];
}
// --- State management (per-session, atomic writes, bounded) ---
function normalizeEnvValue(value) {
@@ -915,8 +940,8 @@ function markChecked(key) {
// 3); afterwards emit a condensed single-line denial that carries the
// denial ordinal, so consecutive denials are structurally different and
// never textually identical. True retries of an already-gated target are
// unaffected (they were always allowed). Destructive-Bash and routine-Bash
// gates are unchanged.
// unaffected (they were always allowed). Destructive shell and routine shell
// gates are not denial-dampened.
const DEFAULT_FULL_DENIALS = 3;
@@ -1136,11 +1161,12 @@ function destructiveBashMsg() {
].join('\n');
}
function routineBashMsg() {
function routineShellMsg(toolName) {
const shellName = toolName === 'PowerShell' ? 'PowerShell' : 'Bash';
return [
'[Fact-Forcing Gate]',
'',
'Before the first Bash command this session, present these facts:',
`Before the first ${shellName} command this session, present these facts:`,
'',
'1. The current user request in one sentence',
'2. What this specific command verifies or produces',
@@ -1217,7 +1243,7 @@ function run(rawInput) {
const rawToolName = data.tool_name || '';
const toolInput = data.tool_input || {};
// Normalize: case-insensitive matching via lookup map
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash' };
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash', powershell: 'PowerShell' };
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
const inSubagent = isSubagentInvocation(data);
@@ -1272,13 +1298,13 @@ function run(rawInput) {
return rawInput; // allow
}
if (toolName === 'Bash') {
if (toolName === 'Bash' || toolName === 'PowerShell') {
const command = toolInput.command || '';
if (isReadOnlyGitIntrospection(command)) {
return rawInput;
}
if (isDestructiveBash(command)) {
if (classifyDestructiveCommand(toolName, command).length > 0) {
// Gate destructive commands on first attempt; allow retry after facts presented
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
if (!isChecked(key)) {
@@ -1290,7 +1316,7 @@ function run(rawInput) {
return rawInput; // allow retry after facts presented
}
// Operator opt-out: skip the routine-bash gate entirely. The destructive
// Operator opt-out: skip the routine shell gate entirely. The destructive
// gate above still fires. This is the documented escape hatch for hosts
// (Cursor, OpenCode, etc.) where the once-per-session routine gate is
// friction without signal.
@@ -1302,9 +1328,13 @@ function run(rawInput) {
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
return allowWithStateWarning();
}
return denyResult(routineBashMsg(), {
hookIds: [BASH_HOOK_ID],
narrowRecoveryHint: ROUTINE_BASH_NARROW_RECOVERY_HINT
const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID;
const narrowRecoveryHint = toolName === 'PowerShell'
? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT
: ROUTINE_BASH_NARROW_RECOVERY_HINT;
return denyResult(routineShellMsg(toolName), {
hookIds: [hookId],
narrowRecoveryHint
});
}
@@ -1314,4 +1344,4 @@ function run(rawInput) {
return rawInput; // allow
}
module.exports = { run };
module.exports = { classifyDestructiveCommand, run };
+47 -8
View File
@@ -19,8 +19,17 @@
'use strict';
const crypto = require('crypto');
const { isElevatedPowerShellCommand } = require('../lib/powershell-destructive-command');
const MAX_STDIN = 1024 * 1024;
let destructiveCommandClassifier = null;
function classifyDestructiveCommand(toolName, command) {
if (!destructiveCommandClassifier) {
destructiveCommandClassifier = require('./gateguard-fact-force').classifyDestructiveCommand;
}
return destructiveCommandClassifier(toolName, command);
}
// Patterns that indicate potential hardcoded secrets
const SECRET_PATTERNS = [
@@ -34,6 +43,7 @@ const SECRET_PATTERNS = [
// Tool names that represent security-relevant operations
const SECURITY_RELEVANT_TOOLS = new Set([
'Bash', // Could execute arbitrary commands
'PowerShell',
]);
// Commands that require governance approval
@@ -123,8 +133,27 @@ function summarizeCommand(command) {
};
}
if (trimmed.startsWith("'") || trimmed.startsWith('"')) {
return {
commandName: null,
commandFingerprint: fingerprintCommand(trimmed),
};
}
const firstToken = trimmed.split(/\s+/)[0] || '';
// Static method invocations can attach their arguments to the first token,
// for example `[IO.File]::Delete('private-path')`. Keep the operation name
// while excluding attached argument content from governance evidence.
const operation = firstToken.split('(', 1)[0].replace(/^['"]|['"]$/g, '');
let commandName = null;
if (/^\[(?:[A-Za-z_][\w]*\.)*[A-Za-z_][\w]*\]::[A-Za-z_][\w-]*$/.test(operation)) {
commandName = operation;
} else if (/^[A-Za-z_][A-Za-z0-9_.:\\/-]*$/.test(operation)) {
commandName = operation.split(/[\\/]/).pop() || null;
}
return {
commandName: trimmed.split(/\s+/)[0] || null,
commandName,
commandFingerprint: fingerprintCommand(trimmed),
};
}
@@ -142,7 +171,11 @@ function emitGovernanceEvent(event) {
*/
function analyzeForGovernanceEvents(input, context = {}) {
const events = [];
const toolName = input.tool_name || '';
const rawToolName = input.tool_name || '';
const normalizedToolName = String(rawToolName).toLowerCase();
const toolName = normalizedToolName === 'powershell'
? 'PowerShell'
: normalizedToolName === 'bash' ? 'Bash' : rawToolName;
const toolInput = input.tool_input || {};
const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : '';
const sessionId = context.sessionId || null;
@@ -174,13 +207,17 @@ function analyzeForGovernanceEvents(input, context = {}) {
});
}
// 2. Approval-required commands (Bash only)
if (toolName === 'Bash') {
// 2. Approval-required commands. Bash retains its existing approval
// patterns. PowerShell consumes the exact classifier result used by
// GateGuard so denial and governance evidence cannot drift apart.
if (toolName === 'Bash' || toolName === 'PowerShell') {
const command = toolInput.command || '';
const approvalFindings = detectApprovalRequired(command);
const matchedPatterns = toolName === 'PowerShell'
? classifyDestructiveCommand(toolName, command)
: detectApprovalRequired(command).map(finding => finding.pattern);
const commandSummary = summarizeCommand(command);
if (approvalFindings.length > 0) {
if (matchedPatterns.length > 0) {
events.push({
id: generateEventId(),
sessionId,
@@ -189,7 +226,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
toolName,
hookPhase,
...commandSummary,
matchedPatterns: approvalFindings.map(f => f.pattern),
matchedPatterns,
severity: 'high',
},
resolvedAt: null,
@@ -220,7 +257,9 @@ function analyzeForGovernanceEvents(input, context = {}) {
// 4. Security-relevant tool usage tracking
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
const command = toolInput.command || '';
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const hasElevated = toolName === 'PowerShell'
? isElevatedPowerShellCommand(command)
: /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
const commandSummary = summarizeCommand(command);
if (hasElevated) {
+3 -2
View File
@@ -27,7 +27,7 @@ const SYNC_HOOKS = [
{ id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck },
{ id: 'post:edit:accumulator', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-accumulator.js', run: runPostEditAccumulator },
{ id: 'post:edit:console-warn', matcher: 'Edit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-console-warn.js', run: runConsoleWarn },
{ id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture },
{ id: 'post:governance-capture', matcher: 'Bash|PowerShell|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture },
{ id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker },
{ id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge },
{ id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor }
@@ -55,13 +55,14 @@ function getPluginRoot(env = process.env) {
}
function matchesTool(matcher, toolName) {
const normalizedToolName = String(toolName || '').toLowerCase();
return (
matcher === '*' ||
String(matcher || '')
.split('|')
.map(value => value.trim())
.filter(Boolean)
.includes(String(toolName || ''))
.some(value => value.toLowerCase() === normalizedToolName)
);
}
File diff suppressed because it is too large Load Diff