mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
Merge reviewed PowerShell enforcement fixes for 2.2.1
# Conflicts: # tests/hooks/gateguard-fact-force.test.js
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user