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
@@ -0,0 +1,256 @@
# ECC-039 PowerShell GateGuard and Audit Alignment Plan
## Status
- Ticket: ECC-039
- Size: large
- Priority: critical
- Baseline: `origin/main` at `e04ea0b9`
- Source to salvage: PR #2721 at `4a2e59ba`
- Implementation state: implemented in PR #2961 and under hosted verification
The fix spans the security enforcement path, governance evidence, configured
hook routing, post-tool dispatch, and cross-platform regression coverage. It is
large because the stale PR changes eight files, conflicts with current `main`,
and must establish one consistent policy/evidence contract.
## Objective
Make PowerShell a governed arbitrary-command shell with one destructive-command
classification result shared by pre-execution denial and governance evidence.
Every PowerShell command denied as destructive must produce an
`approval_requested` event when governance capture is enabled.
## Verified Current State
Current `main` has no dedicated PowerShell GateGuard route and excludes
PowerShell from governance capture. PR #2721 adds the route and most of the
detector, but its exact head still has these reproduced mismatches:
| Command class | PR #2721 GateGuard | PR #2721 governance |
|---|---|---|
| Direct recursive `Remove-Item` | deny | approval event |
| Destructive command inside `$()` | allow | approval event |
| Force-only `Remove-Item` | deny | no event |
| Wildcard `Remove-Item` | deny | no event |
| `.NET Directory::Delete` | deny | no event |
| `Clear-Content` | allow | approval event |
| `Format-Volume` | allow | approval event |
| Benign `Get-ChildItem` | allow | no event |
The focused PR-head suites pass with 166 GateGuard tests and 35 governance
tests. Those green suites do not cover the mismatches above. A direct
`merge-tree` check against current `main` reports conflicts in
`scripts/hooks/gateguard-fact-force.js` and `tests/hooks/hooks.test.js`.
Applying the stale PR files wholesale would also discard current-main heredoc
filtering, narrow recovery guidance, valid `.*` hook matchers, post-dispatcher
skill tracking, and newer hook tests.
## Prior Art Review
The implementation was informed by existing and merged alternatives before any
production code was changed:
- PR #2721 supplied the original PowerShell route and detection inventory, but
its conflicted head had GateGuard/governance drift and removed backticks
before parsing, which changes PowerShell escape meaning.
- PRs #1912 and #2495 established the useful bounded executable-body traversal
and parser-focused test patterns. Their Bash parser was not reused because
Bash backslashes and backticks have different semantics from PowerShell.
- PR #2902 showed the safe forward-port pattern used here: retain current-main
heredoc filtering, narrow recovery hints, and valid `.*` matchers while
applying only the feature-specific changes.
- PR #2897 reinforced that quoted delimiters must not terminate executable
ranges and that executable expressions inside double quotes still run.
- PR #2865 and related open work cover separate Bash and hook hardening. Those
changes remain outside ECC-039 and were not absorbed into this patch.
## Design Decision
Add a pure shared module at
`scripts/lib/powershell-destructive-command.js`. It returns stable,
non-sensitive rule IDs for all matches. GateGuard denies when the result is
non-empty, and governance uses the same result to emit approval evidence.
The module owns PowerShell-specific parsing and policy:
- `Remove-Item`, `Remove-ItemProperty`, and built-in aliases
- `-Recurse` and valid unambiguous abbreviations
- `-Force` without recursion
- wildcard targets and opaque splatted parameters
- pipeline-wide recursion evidence
- `.NET` `Directory::Delete` and `File::Delete`
- `cmd /c` recursive deletion
- nested `powershell` and `pwsh -Command`
- `Start-Process` and static nested-shell argument forms
- UTF-16LE `-EncodedCommand`
- `Clear-Content`, `Clear-Disk`, and `Format-Volume`
- static aliases, functions, script blocks, class construction, and common
execution primitives
- fail-closed `powershell.dynamic-execution` evidence when an execution
primitive cannot be resolved safely
- bounded recursion that fails closed after executable nesting exceeds budget
The parser extracts balanced PowerShell `$()` bodies recursively. It treats
subexpressions outside quotes and inside double quotes as executable, ignores
single-quoted literals, respects backtick-escaped dollar signs, and handles
nested parentheses without deleting escape characters before parsing.
GateGuard retains its current Bash classifier. The PowerShell path combines the
existing shell-agnostic destructive classifications with the new shared
PowerShell findings. Governance preserves its current Bash approval behavior
and consumes the shared PowerShell findings for the PowerShell tool.
## Task List
1. Add red classifier and consumer tests.
- Create `tests/lib/powershell-destructive-command.test.js`.
- Add identical destructive and benign command tables to the GateGuard and
governance consumer tests.
- Prove the direct configured PowerShell route denies a recursive delete,
while `$()` and evidence-parity cases fail before implementation.
2. Implement the shared PowerShell classifier.
- Port only the valuable detection behavior from PR #2721.
- Return stable rule IDs instead of raw command text or a bare boolean.
- Add quote-aware, nesting-aware `$()` extraction and recursive scanning.
- Preserve bounded work and conservative failure on opaque executable input.
3. Integrate GateGuard from current `main`.
- Normalize the `PowerShell` tool name.
- Add the PowerShell classifier to the existing shell branch.
- Preserve first-denial and retry state semantics.
- Emit the PowerShell hook ID in routine denial recovery guidance.
- Preserve current heredoc stripping, denial dampening, and narrow recovery
hints.
4. Integrate governance evidence.
- Add PowerShell to the security-relevant tool set.
- Emit one `approval_requested` event from the shared findings.
- Store stable rule IDs and the existing command fingerprint only.
- Preserve secret redaction and avoid raw command text in events.
5. Wire the configured entry points.
- Add one dedicated PowerShell PreToolUse GateGuard route to
`hooks/hooks.json`.
- Add PowerShell to the pre-governance matcher.
- Add PowerShell to post-governance dispatch only, keeping Bash-only post
hooks restricted to Bash.
- Preserve current `.*` matcher syntax and all current-main routes.
6. Exercise the real hook commands.
- Run the exact command read from `hooks/hooks.json` for denial and
governance capture with isolated state and unique sessions.
- Clear ambient GateGuard opt-out variables in fixtures.
- Verify the post-tool dispatcher selects governance for PowerShell.
7. Complete review and verification.
- Run focused unit and hook suites, then the full repository suite and
coverage.
- Run a security review for parser bypasses, quote false positives, command
leakage, recursion-budget behavior, and Bash regressions.
- Resolve every critical or high finding before commit review.
## Acceptance Matrix
| Command class | GateGuard | Governance evidence |
|---|---|---|
| Recursive `Remove-Item` and aliases | deny first attempt | approval event |
| Force-only `Remove-Item` | deny | approval event |
| Wildcard or splatted delete | deny | approval event |
| `.NET Directory::Delete` or `File::Delete` | deny | approval event |
| `Clear-Content`, `Clear-Disk`, `Format-Volume` | deny | approval event |
| Nested `pwsh -Command` or encoded command | deny | approval event |
| Destructive command in unquoted `$()` | deny | approval event |
| Destructive command in double-quoted `$()` | deny | approval event |
| Recursively nested executable `$()` | deny | approval event |
| Same text in a single-quoted literal | no destructive denial | no event |
| Backtick-escaped literal `$()` | no destructive denial | no event |
| Plain `Remove-Item file.txt` | allow under current policy | no event |
| `Get-ChildItem` or `Get-Date` | allow | no event |
| Existing Bash destructive and heredoc cases | unchanged | unchanged |
| Configured PreToolUse route | command denies | event when enabled |
| Configured PostToolUse route | not applicable | reaches governance |
## Verification
Run in this order:
```sh
node tests/lib/powershell-destructive-command.test.js
node tests/hooks/gateguard-fact-force.test.js
node tests/hooks/governance-capture.test.js
node tests/hooks/hooks.test.js
node tests/hooks/posttooluse-dispatcher.test.js
npm test
npm run coverage
git diff --check
```
Hosted acceptance requires the repository security scan, lint, coverage, and
the supported Node and package-manager CI matrix at the exact proposed head.
## Implementation and Verification Results
The implementation is committed in PR #2961. It adds the shared classifier,
dedicated PowerShell hook routes, exact
GateGuard/governance rule parity, redacted evidence, case-insensitive tool
matching, post-tool governance dispatch, and the review-driven hardening needed
for static variables embedded in nested double-quoted command payloads.
- Focused classifier and hook suites: 531 passed, 0 failed.
- Full repository suite: 4,217 passed, 0 failed.
- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.56%
functions, and 89.23% lines.
- Supply-chain IOC scan: passed for all 224 inspected files.
- ESLint, Markdown lint, hook validation, personal-path validation, and
`git diff --check`: passed.
- Independent final security replay: no critical or high findings across 109
destructive cases, 19 benign controls, 9 elevation cases, and 13
GateGuard/governance parity cases.
- The 40,000-container, approximately 840 KB stress input completed well below
the configured five-second hook timeout and preserved the destructive tail
finding.
PowerShell itself is not installed in the local PATH, so the repository's
native `install.ps1` delegation checks were skipped by their existing runtime
guard. Classifier, configured-hook, governance, and dispatcher behavior were
still exercised through the Node hook boundary.
## Risks and Controls
- PowerShell quoting and backtick semantics can cause bypasses or false
positives. Use explicit executable and literal pairs for each parser case.
- Short parameter prefixes can become ambiguous. Test only valid prefixes for
the intended cmdlets and keep rule IDs visible in unit failures.
- Encoded and deeply nested commands can consume unbounded work. Enforce a
shared recursion budget and fail closed only after executable nesting is
observed.
- Dynamic execution can hide a command from static inspection. Resolve common
static forms and return `powershell.dynamic-execution` for unresolved
execution primitives or shell-launch splats.
- Governance records can leak command content. Reuse the existing fingerprint
and summary path and assert that emitted events contain no raw command.
- A stale-PR merge can regress current hardening. Port PowerShell hunks manually
onto `origin/main` and keep current-main regression tests green.
## Roadmap and Scope
This is post-2.2 hardening of the ECC 2 trustworthy substrate. It makes the
policy/evidence seam truthful at configured hook boundaries and prepares for
future evidence contracts while keeping ECC authoritative over policy,
enforcement, canonical evidence, and workflow outcomes.
Out of scope are a general PowerShell parser, exact interpretation of arbitrary
runtime-generated payloads or reflection, broader Bash classifier refactoring,
public API changes, issue #2921 glob semantics, issue #2886 heredoc redesign,
ExecutionCapsule, sandbox tiers, Feature Fleet, Itô, and Nasiko. Unresolved
execution primitives fail closed instead of being interpreted. Current-main
behavior for #2886 remains covered and unchanged.
Known non-bypass residuals are conservative classification of unresolved safe
dynamic execution and `Start-Process` splats, plus whole-class scanning when a
class is activated. Whole-class scanning can flag an uncalled destructive
method when a safe sibling member is invoked. Separating constructor and method
resolution is a precision improvement, not a release-blocking enforcement gap.
+13 -1
View File
@@ -13,6 +13,18 @@
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
"id": "pre:bash:dispatcher"
},
{
"matcher": "PowerShell",
"hooks": [
{
"type": "command",
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:powershell:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
"timeout": 5
}
],
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
"id": "pre:powershell:gateguard-fact-force"
},
{
"matcher": "Write",
"hooks": [
@@ -49,7 +61,7 @@
"id": "pre:observe:continuous-learning"
},
{
"matcher": "Bash|Write|Edit|MultiEdit",
"matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
+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
+187
View File
@@ -105,6 +105,38 @@ function runBashHook(input, env = {}) {
};
}
function runPowerShellHook(input, env = {}) {
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
const result = spawnSync(
'node',
[
runner,
'pre:powershell:gateguard-fact-force',
'scripts/hooks/gateguard-fact-force.js',
'standard,strict'
],
{
input: rawInput,
encoding: 'utf8',
env: {
...process.env,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: TEST_SESSION_ID,
...env
},
timeout: 15000,
stdio: ['pipe', 'pipe', 'pipe']
}
);
return {
code: Number.isInteger(result.status) ? result.status : 1,
stdout: result.stdout || '',
stderr: result.stderr || ''
};
}
function parseOutput(stdout) {
try {
return JSON.parse(stdout);
@@ -2903,6 +2935,161 @@ function runTests() {
})) passed++;
else failed++;
// --- PowerShell tool consumer contract ---
if (
test('normalizes PowerShell tool-name casing before destructive classification', () => {
for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
clearState();
const result = runPowerShellHook({
tool_name: toolName,
tool_input: { command: 'Remove-Item -Force C:/tmp/demo' }
});
assert.strictEqual(result.code, 0, `${toolName} hook should exit 0`);
const output = parseOutput(result.stdout);
assert.ok(output, `${toolName} should produce JSON output`);
assert.strictEqual(
output.hookSpecificOutput?.permissionDecision,
'deny',
`${toolName} should be denied`
);
assert.match(
output.hookSpecificOutput.permissionDecisionReason,
/Destructive command detected/
);
}
})
)
passed++;
else failed++;
if (
test('denies the first routine PowerShell command and allows its retry', () => {
clearState();
const input = {
tool_name: 'PowerShell',
tool_input: { command: 'Get-Date' }
};
const first = runPowerShellHook(input);
assert.strictEqual(first.code, 0, 'first PowerShell hook should exit 0');
const firstOutput = parseOutput(first.stdout);
assert.ok(firstOutput, 'first PowerShell attempt should produce JSON output');
assert.strictEqual(
firstOutput.hookSpecificOutput?.permissionDecision,
'deny',
'first routine PowerShell command should be denied'
);
assert.match(
firstOutput.hookSpecificOutput.permissionDecisionReason,
/pre:powershell:gateguard-fact-force/,
'recovery guidance should name the independently configurable PowerShell hook ID'
);
const retry = runPowerShellHook(input);
assert.strictEqual(retry.code, 0, 'PowerShell retry should exit 0');
const retryOutput = parseOutput(retry.stdout);
assert.ok(retryOutput, 'PowerShell retry should produce JSON output');
if (retryOutput.hookSpecificOutput) {
assert.notStrictEqual(
retryOutput.hookSpecificOutput.permissionDecision,
'deny',
'routine PowerShell retry should be allowed'
);
} else {
assert.strictEqual(retryOutput.tool_name, 'PowerShell');
}
})
)
passed++;
else failed++;
if (
test('denies direct and nested destructive PowerShell commands', () => {
const encodedPayload = Buffer.from(
'Remove-Item -Force C:/tmp/demo',
'utf16le'
).toString('base64');
const commands = [
'Remove-Item -Recurse C:/tmp/demo',
'rp -Force HKCU:/Software/Demo -Name setting',
'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
'pwsh -Command "Remove-Item -Force C:/tmp/demo"',
'pwsh -Command:"Remove-Item -Force C:/tmp/demo"',
`pwsh -EncodedCommand:${encodedPayload}`,
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
"$payload='Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
'pwsh -Command "Write-Output ready; $runtimePayload"',
'pwsh -Command $runtimePayload -Force C:/tmp/demo',
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
'@(Remove-Item -Force C:/tmp/demo)',
'cmd /c "rd /s /q C:/tmp/demo"',
'Remove-Item `\n-Force C:/tmp/demo',
'# (\nRemove-Item -Force C:/tmp/demo',
'<# ignored <# #> Remove-Item -Force C:/tmp/demo',
'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }',
'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"',
'@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@',
'& Remove-Item -Force C:/tmp/demo',
'Invoke-Expression $runtimeValue',
'pwsh -Command "$payload"; $payload = "Write-Output ok"'
];
for (const command of commands) {
clearState();
const result = runPowerShellHook({
tool_name: 'PowerShell',
tool_input: { command }
});
assert.strictEqual(result.code, 0, `${command} hook should exit 0`);
const output = parseOutput(result.stdout);
assert.ok(output, `${command} should produce JSON output`);
assert.strictEqual(
output.hookSpecificOutput?.permissionDecision,
'deny',
`${command} should be denied`
);
assert.match(
output.hookSpecificOutput.permissionDecisionReason,
/Destructive command detected/
);
}
})
)
passed++;
else failed++;
if (
test('allows benign PowerShell after the shared routine shell gate is satisfied', () => {
clearState();
writeState({ checked: ['__bash_session__'], last_active: Date.now() });
for (const command of ['Get-ChildItem C:/tmp', 'Remove-Item C:/tmp/notes.txt']) {
const result = runPowerShellHook({
tool_name: 'PowerShell',
tool_input: { command }
});
assert.strictEqual(result.code, 0, `${command} hook should exit 0`);
const output = parseOutput(result.stdout);
assert.ok(output, `${command} should produce JSON output`);
if (output.hookSpecificOutput) {
assert.notStrictEqual(
output.hookSpecificOutput.permissionDecision,
'deny',
`${command} should not receive a destructive denial`
);
} else {
assert.strictEqual(output.tool_name, 'PowerShell');
}
}
})
)
passed++;
else failed++;
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
+251
View File
@@ -185,6 +185,257 @@ async function runTests() {
assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint');
assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text');
})) passed += 1; else failed += 1;
if (await test('PowerShell approval events contain exact destructive rule IDs without raw commands', async () => {
const encodedPayload = Buffer.from(
'Remove-Item C:/private/encoded-command-sentinel/*',
'utf16le'
).toString('base64');
const cases = [
{
command: 'Remove-Item -Recurse -Force C:/private/remove-command-sentinel',
expectedRules: [
'powershell.remove-item.recurse',
'powershell.remove-item.force',
],
},
{
command: 'Remove-Item C:/private/wildcard-command-sentinel/*',
expectedRules: ['powershell.remove-item.wildcard'],
},
{
command: 'Remove-Item @deleteParams',
expectedRules: ['powershell.remove-item.splat'],
},
{
command: 'Get-ChildItem C:/private/pipeline-command-sentinel -Recurse | Remove-Item',
expectedRules: ['powershell.remove-item.pipeline-recurse'],
},
{
command: 'Clear-Content C:/private/clear-command-sentinel.txt',
expectedRules: ['powershell.clear-content'],
},
{
command: 'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
expectedRules: ['powershell.clear-disk'],
},
{
command: 'Format-Volume -DriveLetter D -Force',
expectedRules: ['powershell.format-volume'],
},
{
command: "[System.IO.Directory]::Delete('C:/private/dotnet-command-sentinel', $true)",
expectedRules: ['powershell.dotnet.directory-delete'],
},
{
command: "[IO.File]::Delete('C:/private/file-command-sentinel.txt')",
expectedRules: ['powershell.dotnet.file-delete'],
},
{
command: 'cmd /c rd /s /q C:/private/cmd-command-sentinel',
expectedRules: ['powershell.cmd.recursive-delete'],
},
{
command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"',
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'pwsh -Command:"Remove-Item -Force C:/private/inline-command-sentinel"',
expectedRules: ['powershell.remove-item.force'],
},
{
command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"",
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'pwsh -Command "$payload"; $payload = "Write-Output ok"',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'pwsh -Command $runtimePayload -Force C:/private/runtime-command-sentinel',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: `pwsh -EncodedCommand ${encodedPayload}`,
expectedRules: ['powershell.remove-item.wildcard'],
},
{
command: `pwsh -EncodedCommand:${encodedPayload}`,
expectedRules: ['powershell.remove-item.wildcard'],
},
{
command: 'Write-Output "$(Remove-Item -Force C:/private/subexpression-command-sentinel)"',
expectedRules: ['powershell.remove-item.force'],
},
{
command: '<# ignored <# #> Remove-Item -Force C:/private/comment-command-sentinel',
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'function cleanup { Remove-Item -Force C:/private/function-command-sentinel }; $(cleanup)',
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'cmd /c pwsh -Command "Remove-Item -Force C:/private/cmd-pwsh-sentinel"',
expectedRules: ['powershell.remove-item.force'],
},
{
command: 'Invoke-Expression $runtimeValue',
expectedRules: ['powershell.dynamic-execution'],
},
{
command: 'git switch --discard-changes',
expectedRules: ['gateguard.bash-compatible-destructive'],
},
];
for (const { command, expectedRules } of cases) {
const events = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command },
}, {
hookPhase: 'pre',
});
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
assert.ok(approvalEvent, `${command} should raise approval_requested`);
assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell');
assert.deepStrictEqual(
[...approvalEvent.payload.matchedPatterns].sort(),
[...expectedRules].sort(),
`${command} should preserve exact classifier rule IDs`
);
assert.ok(
/^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint),
'Expected short command fingerprint'
);
assert.ok(
!Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'),
'Should not store raw command text'
);
assert.ok(
!JSON.stringify(approvalEvent).includes(JSON.stringify(command).slice(1, -1)),
'Serialized governance evidence should not leak the raw command'
);
}
})) passed += 1; else failed += 1;
if (await test('PowerShell governance ignores literal and benign delete text', async () => {
const commands = [
'Get-ChildItem C:/tmp',
'Get-Date',
'Remove-Item C:/tmp/notes.txt',
"Write-Output '$(Remove-Item -Force C:/tmp/demo)'",
'Write-Output "`$(Remove-Item -Force C:/tmp/demo)"',
];
for (const command of commands) {
const events = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command },
}, {
hookPhase: 'pre',
});
assert.ok(
!events.some(event => event.eventType === 'approval_requested'),
`${command} should not raise approval_requested`
);
}
})) passed += 1; else failed += 1;
if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => {
const command = "$label='governance-private-marker'; Remove-Item -Force C:/tmp/demo";
for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
const events = analyzeForGovernanceEvents({
tool_name: toolName,
tool_input: { command },
}, {
hookPhase: 'pre',
});
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
assert.ok(approvalEvent, `${toolName} should raise approval_requested`);
assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell');
assert.strictEqual(approvalEvent.payload.commandName, null);
assert.ok(!JSON.stringify(events).includes('governance-private-marker'));
}
})) passed += 1; else failed += 1;
if (await test('PowerShell governance redacts quoted expression prefixes', async () => {
const command = "'quoted-private-marker' ; Remove-Item -Force C:/tmp/demo";
const events = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command },
}, {
hookPhase: 'pre',
});
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
assert.ok(approvalEvent, 'quoted prefix should still raise approval_requested');
assert.strictEqual(approvalEvent.payload.commandName, null);
assert.ok(!JSON.stringify(events).includes('quoted-private-marker'));
})) passed += 1; else failed += 1;
if (await test('PowerShell elevation events are captured without raw command leakage', async () => {
const commands = [
'Start-Process -Verb RunAs cmd -ArgumentList elevation-command-sentinel',
'Start-Process Verb RunAs cmd',
'Start-Process -Verb $("RunAs") cmd',
'Start-Process -Verb ("RunAs") cmd',
'saps pwsh -Verb RunAs',
'start pwsh -Verb RunAs',
'runas.exe /user:Administrator cmd',
'sudo chmod 600 C:/private/native-elevation-sentinel',
'$script:aclResult = Set-Acl -Path C:/private/scoped-assignment-sentinel -AclObject $acl',
'Set-Acl -Path C:/private/acl-command-sentinel -AclObject $acl',
'takeown /f C:/private/ownership-command-sentinel',
"& 'Set-Acl' -Path C:/private/call-operator-sentinel -AclObject $acl",
'Microsoft.PowerShell.Security\\Set-Acl -Path C:/private/module-sentinel -AclObject $acl',
'Set`-Acl -Path C:/private/backtick-sentinel -AclObject $acl',
'Write-Output $(Set-Acl -Path C:/private/subexpression-sentinel -AclObject $acl)',
];
for (const command of commands) {
const events = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command },
}, {
hookPhase: 'post',
});
const securityEvent = events.find(event => event.eventType === 'security_finding');
assert.ok(securityEvent, `${command} should raise a security_finding`);
assert.strictEqual(securityEvent.payload.toolName, 'PowerShell');
assert.strictEqual(securityEvent.payload.reason, 'elevated_privilege_command');
assert.ok(
/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint),
'Expected short command fingerprint'
);
assert.ok(
!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'),
'Should not store raw command text'
);
assert.ok(
!JSON.stringify(securityEvent).includes(JSON.stringify(command).slice(1, -1)),
'Serialized governance evidence should not leak the raw command'
);
}
const literalEvents = analyzeForGovernanceEvents({
tool_name: 'PowerShell',
tool_input: { command: "Write-Output 'Start-Process -Verb RunAs cmd'" },
}, {
hookPhase: 'post',
});
assert.ok(
!literalEvents.some(event => event.eventType === 'security_finding'),
'quoted elevation prose should not raise a security finding'
);
})) passed += 1; else failed += 1;
if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => {
const events = analyzeForGovernanceEvents({
tool_name: 'Edit',
+101
View File
@@ -2599,6 +2599,107 @@ async function runTests() {
passed++;
else failed++;
if (
test('hooks.json gives PowerShell dedicated GateGuard and governance routes', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
const powerShellRoutes = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'PowerShell');
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
assert.strictEqual(
powerShellRoutes.length,
1,
'Should have exactly one dedicated PreToolUse PowerShell route'
);
assert.strictEqual(
powerShellRoutes[0].id,
'pre:powershell:gateguard-fact-force',
'PowerShell should use its independently configurable GateGuard hook ID'
);
assert.ok(
powerShellRoutes[0].hooks[0].command.includes('pre:powershell:gateguard-fact-force'),
'Configured command should preserve the PowerShell GateGuard hook ID'
);
assert.ok(
powerShellRoutes[0].hooks[0].command.includes('scripts/hooks/gateguard-fact-force.js'),
'PowerShell route should invoke GateGuard without Bash-only preflight hooks'
);
assert.ok(governanceRoute, 'PreToolUse governance route should exist');
assert.ok(
governanceRoute.matcher.split('|').includes('PowerShell'),
'PreToolUse governance matcher should include PowerShell'
);
assert.ok(
hooks.hooks.PostToolUse.every(entry => entry.matcher === '.*'),
'Top-level PostToolUse dispatchers should preserve current-main wildcard matchers'
);
})
)
passed++;
else failed++;
if (
test('configured PowerShell routes enforce denial and emit redacted governance evidence', () => {
const root = path.join(__dirname, '..', '..');
const hooks = JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
const gateRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:powershell:gateguard-fact-force');
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
const stateDir = createTestDir();
const command = 'Remove-Item -Force C:/private/configured-route-sentinel';
const payload = JSON.stringify({
tool_name: 'PowerShell',
tool_input: { command }
});
const env = {
...process.env,
CLAUDE_PLUGIN_ROOT: root,
ECC_HOOK_PROFILE: 'standard',
GATEGUARD_STATE_DIR: stateDir,
CLAUDE_SESSION_ID: 'ecc039-configured-route-test'
};
for (const key of ['ECC_GATEGUARD', 'GATEGUARD_DISABLED', 'GATEGUARD_BASH_ROUTINE_DISABLED', 'ECC_DISABLED_HOOKS']) {
delete env[key];
}
try {
const gated = spawnSync(gateRoute.hooks[0].command, {
cwd: root,
env,
input: payload,
encoding: 'utf8',
shell: true,
timeout: 15000
});
assert.strictEqual(gated.status, 0, gated.stderr);
assert.strictEqual(
JSON.parse(gated.stdout).hookSpecificOutput?.permissionDecision,
'deny',
'exact configured GateGuard command should deny destructive PowerShell'
);
const governed = spawnSync(governanceRoute.hooks[0].command, {
cwd: root,
env: {
...env,
ECC_GOVERNANCE_CAPTURE: '1',
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse'
},
input: payload,
encoding: 'utf8',
shell: true,
timeout: 15000
});
assert.strictEqual(governed.status, 0, governed.stderr);
assert.ok(governed.stderr.includes('powershell.remove-item.force'));
assert.ok(!governed.stderr.includes(command), 'governance evidence should omit raw command text');
} finally {
cleanupTestDir(stateDir);
}
})
)
passed++;
else failed++;
if (
test('all string hook matchers are valid regular expressions', () => {
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
+13 -1
View File
@@ -30,7 +30,9 @@ function runDispatcher(mode, toolName, env = {}) {
const raw = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: toolName,
tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
tool_input: ['Bash', 'PowerShell'].includes(toolName)
? { command: 'true' }
: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
tool_response: {}
});
@@ -126,6 +128,16 @@ function runTests() {
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:bash:dispatcher', 'post:observe:continuous-learning']
},
{
tool: 'PowerShell',
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:observe:continuous-learning']
},
{
tool: 'powershell',
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
async: ['post:observe:continuous-learning']
},
{
tool: 'Read',
sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
@@ -0,0 +1,760 @@
'use strict';
const assert = require('assert');
const {
classifyPowerShellDestructiveCommand,
} = require('../../scripts/lib/powershell-destructive-command');
const RULES = Object.freeze({
REMOVE_RECURSE: 'powershell.remove-item.recurse',
REMOVE_FORCE: 'powershell.remove-item.force',
REMOVE_WILDCARD: 'powershell.remove-item.wildcard',
REMOVE_SPLAT: 'powershell.remove-item.splat',
PIPELINE_RECURSE: 'powershell.remove-item.pipeline-recurse',
CLEAR_CONTENT: 'powershell.clear-content',
CLEAR_DISK: 'powershell.clear-disk',
FORMAT_VOLUME: 'powershell.format-volume',
DOTNET_DIRECTORY_DELETE: 'powershell.dotnet.directory-delete',
DOTNET_FILE_DELETE: 'powershell.dotnet.file-delete',
CMD_RECURSIVE_DELETE: 'powershell.cmd.recursive-delete',
DYNAMIC_EXECUTION: 'powershell.dynamic-execution',
SCAN_DEPTH_EXCEEDED: 'powershell.scan-depth-exceeded',
});
console.log('=== Testing powershell-destructive-command.js ===\n');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(` PASS ${name}`);
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` ${error.message}`);
failed += 1;
}
}
function classify(command) {
const findings = classifyPowerShellDestructiveCommand(command);
assert.ok(Array.isArray(findings), 'classifier must return an array');
assert.ok(
findings.every(ruleId => typeof ruleId === 'string' && ruleId.length > 0),
'every finding must be a non-empty rule-id string'
);
assert.strictEqual(
new Set(findings).size,
findings.length,
`findings must be unique: ${JSON.stringify(findings)}`
);
return findings;
}
function expectRules(command, expected) {
const actual = classify(command);
assert.deepStrictEqual(
[...actual].sort(),
[...expected].sort(),
`unexpected findings for ${JSON.stringify(command)}`
);
}
function expectSafe(command) {
expectRules(command, []);
}
console.log('Remove-Item forms:');
test('classifies recursive and force parameters independently', () => {
expectRules('Remove-Item -Recurse -Force C:/tmp/demo', [
RULES.REMOVE_RECURSE,
RULES.REMOVE_FORCE,
]);
expectRules('Remove-Item -Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
expectRules('Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('classifies PowerShell parameter abbreviations case-insensitively', () => {
expectRules('REMOVE-ITEM -Rec -Fo C:/tmp/demo', [
RULES.REMOVE_RECURSE,
RULES.REMOVE_FORCE,
]);
});
test('normalizes every PowerShell command-parameter dash character', () => {
expectRules('Remove-Item Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('Remove-Item —Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
expectRules('Remove-Item ―Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('normalizes PowerShell backtick obfuscation after finding executable ranges', () => {
expectRules('Rem`ove-Item -Rec`urse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
});
test('classifies recursive Remove-Item aliases', () => {
for (const alias of ['ri', 'rm', 'rmdir', 'rd', 'del', 'erase']) {
expectRules(`${alias} -Recurse C:/tmp/demo`, [RULES.REMOVE_RECURSE]);
}
expectRules('rp -Force HKCU:/Software/Demo -Name setting', [RULES.REMOVE_FORCE]);
expectRules('Remove-ItemProperty -Force HKCU:/Software/Demo -Name setting', [
RULES.REMOVE_FORCE,
]);
});
test('classifies wildcard targets, including quoted provider paths', () => {
expectRules('Remove-Item C:/build/*', [RULES.REMOVE_WILDCARD]);
expectRules('Remove-Item "C:/build/file?.tmp"', [RULES.REMOVE_WILDCARD]);
});
test('classifies splatted Remove-Item parameters', () => {
expectRules('Remove-Item @deleteParams', [RULES.REMOVE_SPLAT]);
});
test('returns deterministic, unique rule IDs when a rule matches repeatedly', () => {
const command = 'Remove-Item -Force C:/one; Remove-Item -Force C:/two';
const first = classify(command);
const second = classify(command);
assert.deepStrictEqual(first, second);
assert.deepStrictEqual(first, [RULES.REMOVE_FORCE]);
});
console.log('\nAdditional destructive APIs:');
test('classifies Clear-Content, Clear-Disk, and Format-Volume', () => {
expectRules('Clear-Content C:/tmp/log.txt', [RULES.CLEAR_CONTENT]);
expectRules('Clear-Disk -Number 2 -RemoveData -Confirm:$false', [RULES.CLEAR_DISK]);
expectRules('Format-Volume -DriveLetter D -Force', [RULES.FORMAT_VOLUME]);
});
test('classifies .NET directory and file deletion', () => {
expectRules("[System.IO.Directory]::Delete('C:/tmp/demo', $true)", [
RULES.DOTNET_DIRECTORY_DELETE,
]);
expectRules("[IO.File]::Delete('C:/tmp/demo.txt')", [
RULES.DOTNET_FILE_DELETE,
]);
expectRules("[IO.Fi`le]::Delete('C:/tmp/demo.txt')", [
RULES.DOTNET_FILE_DELETE,
]);
});
test('classifies recursive cmd.exe deletion reached through PowerShell', () => {
expectRules('cmd /c rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd.exe /c del /s /q C:/tmp/demo/*', [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules('cmd /c "rd /s /q C:/tmp/demo"', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c @rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c --% rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c if exist C:/tmp/demo rd /s /q C:/tmp/demo', [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules('cmd /c "(rd /s /q C:/tmp/demo)"', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c (rd /s /q C:/tmp/demo)', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c if /i "x"=="x" rd /s /q C:/tmp/demo', [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules('cmd /c for %i in (1) do rd /s /q C:/tmp/demo', [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules('cmd /c call rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
expectRules('cmd /c start /wait rd /s /q C:/tmp/demo', [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules('cmd /c if exist C:/never echo safe else rd /s /q C:/tmp/demo', [
RULES.CMD_RECURSIVE_DELETE,
]);
for (const command of [
'cmd /c if exist C:/never echo safe else if exist C:/never echo safe else rd /s /q C:/tmp/demo',
'cmd /c for %i in (1) do if exist C:/never echo safe else rd /s /q C:/tmp/demo',
'cmd /c call call rd /s /q C:/tmp/demo',
'cmd /c start "job" /wait cmd /c rd /s /q C:/tmp/demo',
'cmd /c >nul rd /s /q C:/tmp/demo',
'cmd /c if /i "x" EQU "x" rd /s /q C:/tmp/demo',
'cmd /c if 1 NEQ 2 rd /s /q C:/tmp/demo',
'cmd /c if /i "x" EQU "x" if 1 NEQ 2 rd /s /q C:/tmp/demo',
]) {
expectRules(command, [RULES.CMD_RECURSIVE_DELETE]);
}
});
test('classifies pipeline recursion evidence upstream of Remove-Item', () => {
expectRules('Get-ChildItem C:/tmp -Recurse | Remove-Item', [
RULES.PIPELINE_RECURSE,
]);
});
console.log('\nNested shell payloads:');
test('does not resolve earlier invocations from later scalar assignments', () => {
for (const invocation of [
'pwsh -Command "$payload"',
'pwsh -Command:$payload',
'pwsh -EncodedCommand:$payload',
'Invoke-Expression $payload',
'& $payload',
]) {
expectRules(`${invocation}; $payload = 'Write-Output ok'`, [RULES.DYNAMIC_EXECUTION]);
}
expectRules('pwsh -Command "$payload"; $payload = "Remove-Item -Force C:/tmp/demo"', [
RULES.DYNAMIC_EXECUTION,
]);
expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"');
});
test('classifies powershell and pwsh command payloads recursively', () => {
expectRules(
'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',
[RULES.REMOVE_RECURSE]
);
expectRules(
"pwsh -c 'Remove-Item -Force C:/tmp/demo'",
[RULES.REMOVE_FORCE]
);
expectRules(
'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"',
[RULES.REMOVE_FORCE]
);
expectRules(
"'Remove-Item -Force C:/tmp/demo' | pwsh -Command -",
[RULES.REMOVE_FORCE]
);
expectRules(
"Write-Output 'Remove-Item -Force C:/tmp/demo' | pwsh -Command -",
[RULES.REMOVE_FORCE]
);
expectRules("@('Remove-Item -Force C:/tmp/demo') | pwsh -Command -", [
RULES.REMOVE_FORCE,
]);
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -Command -", [
RULES.REMOVE_FORCE,
]);
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -NoProfile -Command -", [
RULES.REMOVE_FORCE,
]);
expectRules(
"Write-Output \"[IO.File]::Delete('C:/tmp/demo')\" | pwsh -Command -",
[RULES.DOTNET_FILE_DELETE]
);
expectRules('pwsh -CommandWithArgs "Remove-Item -Force C:/tmp/demo"', [
RULES.REMOVE_FORCE,
]);
expectRules('pwsh -cwa "Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
expectRules('pwsh -Command:"Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
expectRules('pwsh -Command:Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules(
"Start-Process pwsh -ArgumentList '-NoProfile -Command \"Remove-Item -Force C:/tmp/demo\"'",
[RULES.REMOVE_FORCE]
);
for (const command of [
"Start-Process pwsh -ArgumentList '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'",
"Start-Process -FilePath pwsh -ArgumentList '-NoProfile', '-Command', 'Remove-Item -Force C:/tmp/demo'",
"saps pwsh -ArgumentList '-NoProfile','-c','Remove-Item -Force C:/tmp/demo'",
"Start-Process pwsh -ArgumentList @('-NoProfile','-Command','Remove-Item -Force C:/tmp/demo')",
"Start-Process pwsh '-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process pwsh -Args '-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process -FilePath:pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process pwsh -ArgumentList:'-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process -Fi:pwsh -Arg:'-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"' -FilePath pwsh",
"Start-Process -WindowStyle Hidden pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process -WorkingDirectory C:/tmp pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
"Start-Process pwsh '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'",
"Start-Process pwsh -ArgumentList @('-NoProfile',('-Command'),('Remove-Item -Force C:/tmp/demo'))",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
expectRules("Start-Process cmd -ArgumentList '/c rd /s /q C:/tmp/demo'", [
RULES.CMD_RECURSIVE_DELETE,
]);
expectRules(
"$params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @params",
[RULES.DYNAMIC_EXECUTION]
);
expectRules(
"$global:params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @global:params",
[RULES.DYNAMIC_EXECUTION]
);
expectRules(
"$shell='pwsh'; 'Remove-Item -Force C:/tmp/demo' | & $shell -Command -",
[RULES.REMOVE_FORCE]
);
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | & pwsh -Command -", [
RULES.REMOVE_FORCE,
]);
});
test('classifies UTF-16LE EncodedCommand payloads', () => {
const payload = Buffer.from(
'Remove-Item C:/tmp/demo/*',
'utf16le'
).toString('base64');
expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_WILDCARD]);
expectRules(`pwsh -EncodedCommand:${payload}`, [RULES.REMOVE_WILDCARD]);
expectRules(`$payload='${payload}'; pwsh -EncodedCommand:$payload`, [
RULES.REMOVE_WILDCARD,
]);
expectRules('pwsh -EncodedCommand $runtimePayload', [RULES.DYNAMIC_EXECUTION]);
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:\`$payload`);
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:'$payload'`);
});
test('ignores an invalid EncodedCommand payload without throwing', () => {
assert.doesNotThrow(() => classify('pwsh -EncodedCommand %%%not-base64%%%'));
expectSafe('pwsh -EncodedCommand %%%not-base64%%%');
});
test('bounds deeply nested encoded commands and reports conservative evidence', () => {
let command = 'Remove-Item -Recurse C:/tmp/demo';
for (let depth = 0; depth < 8; depth += 1) {
const payload = Buffer.from(command, 'utf16le').toString('base64');
command = `pwsh -EncodedCommand ${payload}`;
}
expectRules(command, [RULES.SCAN_DEPTH_EXCEEDED]);
});
test('classifies destructive commands in executable PowerShell containers', () => {
const commands = [
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
'ForEach-Object { Remove-Item -Force C:/tmp/demo }',
'@(Remove-Item -Force C:/tmp/demo)',
'(Remove-Item -Force C:/tmp/demo)',
'pwsh -Command "& { Remove-Item -Force C:/tmp/demo }"',
'pwsh -Command { Remove-Item -Force C:/tmp/demo }',
'switch ($x) { default { Remove-Item -Force C:/tmp/demo } }',
"switch ($x) { 'match' { Remove-Item -Force C:/tmp/demo } }",
'& ({ Remove-Item -Force C:/tmp/demo })',
'& $( { Remove-Item -Force C:/tmp/demo } )',
'Invoke-Command -ScriptBlock ({ Remove-Item -Force C:/tmp/demo })',
'ForEach-Object -Process ({ Remove-Item -Force C:/tmp/demo })',
'function cleanup { Remove-Item -Force C:/tmp/demo }; cleanup',
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
});
test('preserves executable context through spacing and nested grouping', () => {
const commands = [
`&${' '.repeat(300)}{ Remove-Item -Force C:/tmp/demo }`,
'& (({ Remove-Item -Force C:/tmp/demo }))',
'pwsh -Command (({ Remove-Item -Force C:/tmp/demo }))',
'{ Remove-Item -Force C:/tmp/demo }.Invoke()',
'{ Remove-Item -Force C:/tmp/demo }.InvokeReturnAsIs()',
'{ Remove-Item -Force C:/tmp/demo }.Inv`oke()',
"{ Remove-Item -Force C:/tmp/demo }.'Invoke'()",
'{ Remove-Item -Force C:/tmp/demo }.InvokeWithContext($null, $null, @())',
'{ Remove-Item -Force C:/tmp/demo } `\n.Invoke()',
'{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().Invoke()',
'{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().GetNewClosure().Invoke()',
"{ Remove-Item -Force C:/tmp/demo }.'GetNewClosure'().Invoke()",
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
});
test('classifies invoked functions and filters across executable containers', () => {
const commands = [
'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }',
'function cleanup { Remove-Item -Force C:/tmp/demo }; $(cleanup)',
'filter cleanup { Remove-Item -Force C:/tmp/demo }; 1 | cleanup',
'1 | foreach { Remove-Item -Force C:/tmp/demo }',
'1 | where { Remove-Item -Force C:/tmp/demo; $true }',
'1 | Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item -Force C:/tmp/demo }',
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
});
test('classifies invoked static script-block variables but leaves assignments inert', () => {
expectSafe('$cleanup = { Remove-Item -Force C:/tmp/demo }');
expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; & $cleanup', [
RULES.REMOVE_FORCE,
]);
expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.Invoke()', [
RULES.REMOVE_FORCE,
]);
expectRules('${cleanup} = { Remove-Item -Force C:/tmp/demo }; & ${cleanup}', [
RULES.REMOVE_FORCE,
]);
for (const command of [
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Process $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job -ScriptBlock $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command -Expression $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Register-EngineEvent x -Action $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.GetNewClosure().Invoke()',
'${cleanup} = { Remove-Item -Force C:/tmp/demo }; ${cleanup}.GetNewClosure().Invoke()',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; icm -ScriptBlock $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; sajb -ScriptBlock $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Trace-Command demo -Expression $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -NoNewScope $cleanup',
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Begin {} $cleanup',
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
});
test('classifies static command results reached through the call operator', () => {
expectRules("& ('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
expectRules("& $('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
expectRules("& (('Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
expectRules("& $(( 'Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
});
test('classifies assignments, hashtables, and multiline executable blocks', () => {
const commands = [
'$x = Remove-Item -Force C:/tmp/demo',
'$h = @{ x = $(Remove-Item -Force C:/tmp/demo) }',
'if ($true)\n{ Remove-Item -Force C:/tmp/demo }',
'switch ($x)\n{ default { Remove-Item -Force C:/tmp/demo } }',
'function cleanup\n{ Remove-Item -Force C:/tmp/demo }; cleanup',
'if ($true) `\n{ Remove-Item -Force C:/tmp/demo }',
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
expectRules('$null = Clear-Disk -Number 2 -RemoveData -Confirm:$false', [
RULES.CLEAR_DISK,
]);
});
test('classifies compact, scoped, indexed, property, and return execution', () => {
const commands = [
'$result=Remove-Item -Force C:/tmp/demo',
'[object]$result=Remove-Item -Force C:/tmp/demo',
'$script:x = Remove-Item -Force C:/tmp/demo',
'${x} = Remove-Item -Force C:/tmp/demo',
'$x[0] = Remove-Item -Force C:/tmp/demo',
'$x.Value = Remove-Item -Force C:/tmp/demo',
'$x,$y = Remove-Item -Force C:/tmp/demo',
'return Remove-Item -Force C:/tmp/demo',
'$script:cleanup = { Remove-Item -Force C:/tmp/demo }; & $script:cleanup',
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
});
test('classifies named function blocks and sibling consumer blocks', () => {
const commands = [
'function cleanup { begin { Remove-Item -Force C:/tmp/demo } }; cleanup',
'function cleanup { process { Remove-Item -Force C:/tmp/demo } }; 1 | cleanup',
'workflow cleanup { Remove-Item -Force C:/tmp/demo }; cleanup',
'1 | ForEach-Object { Write-Output safe } { Remove-Item -Force C:/tmp/demo }',
'Trace-Command demo -Expression { Remove-Item -Force C:/tmp/demo }',
'Register-EngineEvent demo -Action { Remove-Item -Force C:/tmp/demo }',
'Register-EngineEvent demo -Action:{ Remove-Item -Force C:/tmp/demo }',
'class Cleanup { static [void] Run() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::Run()',
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::new()',
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object -TypeName Cleanup',
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object Cleanup',
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ([Cleanup])',
"class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ('Cleanup')",
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Activator]::CreateInstance([Cleanup])',
"$type = 'Cleanup'; class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object $type",
];
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
});
test('classifies static execution primitives', () => {
expectRules("iex 'Remove-Item -Force C:/tmp/demo'", [RULES.REMOVE_FORCE]);
expectRules("Invoke-Expression 'Remove-Item -Force C:/tmp/demo'", [
RULES.REMOVE_FORCE,
]);
expectRules("& ([scriptblock]::Create('Remove-Item -Force C:/tmp/demo'))", [
RULES.REMOVE_FORCE,
]);
expectRules("Invoke-Expression @'\nRemove-Item -Force C:/tmp/demo\n'@", [
RULES.REMOVE_FORCE,
]);
expectRules("[scriptblock]::Create(@'\nRemove-Item -Force C:/tmp/demo\n'@).Invoke()", [
RULES.REMOVE_FORCE,
]);
expectRules("& (@'\nRemove-Item\n'@) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
for (const command of [
"$cmd = 'Remove-Item -Force C:/tmp/demo'; Invoke-Expression $cmd",
"$cmd='Remove-Item -Force C:/tmp/demo'; iex $cmd",
"$cmd = 'Remove-Item -Force C:/tmp/demo'; & ([scriptblock]::Create($cmd))",
"$name = 'Remove-Item'; & $name -Force C:/tmp/demo",
"$args = '-Command \"Remove-Item -Force C:/tmp/demo\"'; Start-Process pwsh -ArgumentList $args",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $($payload)\"",
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:$payload",
"$payload = 'Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
"$payload = \"Remove-Item `\n-Force C:/tmp/demo\"; pwsh -Command $payload",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
expectRules('pwsh -Command $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
expectRules('pwsh -Command "Write-Output ready; $runtimeValue"', [
RULES.DYNAMIC_EXECUTION,
]);
expectRules('pwsh -Command "Write-Output ready; $($runtimeValue)"', [
RULES.DYNAMIC_EXECUTION,
]);
expectRules('pwsh -Command $runtimeValue -Force C:/tmp/demo', [
RULES.DYNAMIC_EXECUTION,
]);
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'");
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output `$payload\"");
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:`$payload");
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:'$payload'");
expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]);
expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [
RULES.DYNAMIC_EXECUTION,
]);
expectRules(
'$verb=\'Remove\'; $cmd="${verb}-Item"; & $cmd -Force C:/tmp/demo',
[RULES.DYNAMIC_EXECUTION]
);
expectRules('& (Get-Command Remove-Item) -Force C:/tmp/demo', [
RULES.DYNAMIC_EXECUTION,
]);
expectRules("iex ('Remove-'+'Item -Force C:/tmp/demo')", [
RULES.DYNAMIC_EXECUTION,
]);
expectRules("iex ('{0}-Item -Force C:/tmp/demo' -f 'Remove')", [
RULES.DYNAMIC_EXECUTION,
]);
expectRules(
'$cleanup={ Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock (Get-Variable cleanup -ValueOnly)',
[RULES.DYNAMIC_EXECUTION]
);
expectRules('Set-Alias zap Remove-Item; zap -Force C:/tmp/demo', [
RULES.REMOVE_FORCE,
]);
expectRules('New-Alias -Name zap -Value Remove-Item; zap -Force C:/tmp/demo', [
RULES.REMOVE_FORCE,
]);
expectRules(
"$ExecutionContext.InvokeCommand.InvokeScript('Remove-Item -Force C:/tmp/demo')",
[RULES.REMOVE_FORCE]
);
expectRules(
"${ExecutionContext}.InvokeCommand.InvokeScript('Remove-Item -Force C:/tmp/demo')",
[RULES.REMOVE_FORCE]
);
expectRules(
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\nRemove-Item -Force C:/tmp/demo\")",
[RULES.REMOVE_FORCE]
);
expectRules(
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\rRemove-Item -Force C:/tmp/demo\")",
[RULES.REMOVE_FORCE]
);
});
test('scans malformed InvokeScript string arguments in bounded time', () => {
const command = `$ExecutionContext.InvokeCommand.InvokeScript("${'`!'.repeat(10000)}`;
const assignment = '$payload = "' + '`!'.repeat(10000);
const startedAt = Date.now();
expectRules(command, [RULES.DYNAMIC_EXECUTION]);
expectSafe(assignment);
assert.ok(Date.now() - startedAt < 4000, 'malformed string scan should remain below hook timeout');
});
test('classifies command names composed from static subexpression output', () => {
expectRules('Remove-$(Write-Output Item) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('Clear-$(echo Disk) -Number 2', [RULES.CLEAR_DISK]);
expectRules('Format-$(echo Volume) -DriveLetter D', [RULES.FORMAT_VOLUME]);
expectRules('r$(echo m) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('classifies executable containers inside EncodedCommand payloads', () => {
const payload = Buffer.from(
'& { Remove-Item -Force C:/tmp/demo }',
'utf16le'
).toString('base64');
expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_FORCE]);
});
console.log('\nPowerShell subexpressions:');
test('classifies destructive commands in unquoted subexpressions', () => {
expectRules('Write-Output $(Remove-Item -Force C:/tmp/demo)', [
RULES.REMOVE_FORCE,
]);
});
test('classifies destructive commands in double-quoted subexpressions', () => {
expectRules('Write-Output "$(Remove-Item -Recurse C:/tmp/demo)"', [
RULES.REMOVE_RECURSE,
]);
});
test('classifies recursively nested subexpressions', () => {
expectRules(
'Write-Output "$(Write-Output $(Remove-Item -Force C:/tmp/demo))"',
[RULES.REMOVE_FORCE]
);
});
test('classifies sibling subexpressions without duplicating rule IDs', () => {
expectRules(
'Write-Output $(Remove-Item -Force C:/one) $(Remove-Item -Force C:/two)',
[RULES.REMOVE_FORCE]
);
});
test('keeps quoted delimiters inside subexpressions from splitting commands', () => {
expectRules(
'Write-Output $(Write-Output "safe;|&"; Remove-Item -Force "C:/tmp/a;b/*")',
[RULES.REMOVE_FORCE, RULES.REMOVE_WILDCARD]
);
});
test('keeps a quoted closing parenthesis inside a subexpression body', () => {
expectRules(
'Write-Output $(Write-Output ")"; Remove-Item -Force C:/tmp/demo)',
[RULES.REMOVE_FORCE]
);
});
test('keeps double-quoted apostrophes from suppressing executable subexpressions', () => {
expectRules(
'Write-Output "it\'s $(Remove-Item -Force C:/tmp/demo)"',
[RULES.REMOVE_FORCE]
);
});
test('treats subexpression text inside single quotes as literal', () => {
expectSafe("Write-Output '$(Remove-Item -Force C:/tmp/demo)'");
});
test('treats a backtick-escaped subexpression inside double quotes as literal', () => {
expectSafe('Write-Output "`$(Remove-Item -Force C:/tmp/demo)"');
});
test('respects literal and expandable PowerShell here-strings', () => {
expectSafe("@'\nliteral's Remove-Item -Force C:/tmp/demo\n'@");
expectSafe('Write-Output "@\'\nRemove-Item -Force C:/tmp/demo\n\'@"');
expectRules('@"\n$(Remove-Item -Force C:/tmp/demo)\n"@', [
RULES.REMOVE_FORCE,
]);
expectRules('@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@', [
RULES.REMOVE_FORCE,
]);
expectSafe('@"\n" # literal Remove-Item -Force C:/tmp/demo\n"@');
});
test('normalizes PowerShell smart quotes before lexical analysis', () => {
expectRules('& Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('& Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('& Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('& „Remove-Item„ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectSafe('Write-Output $(Remove-Item -Force C:/tmp/demo)');
expectSafe('Write-Output $(Remove-Item -Force C:/tmp/demo)');
});
test('does not treat a backslash as an escape for an executable subexpression', () => {
expectRules('Write-Output \\$(Remove-Item -Force C:/tmp/demo)', [
RULES.REMOVE_FORCE,
]);
});
test('handles backtick line continuations before destructive parameters', () => {
expectRules('Remove-Item `\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('Remove-Item `\r\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('ignores comment syntax without letting it poison following parser state', () => {
expectRules('# (\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('<# ( #>\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('<# ignored <# #> Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectSafe('Write-Output safe # ; Remove-Item -Force C:/tmp/demo');
expectSafe('Write-Output safe# | Remove-Item -Force C:/tmp/demo');
expectSafe("# [IO.File]::Delete('C:/tmp/demo')");
expectRules('# @"\nRemove-Item -Force C:/tmp/demo\n"@', [RULES.REMOVE_FORCE]);
expectRules('${a#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
expectRules('${a<#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
});
test('scans large comments and unmatched openers in bounded time', () => {
const started = Date.now();
expectRules(`<# ${'$('.repeat(40000)} #>\nRemove-Item -Force C:/tmp/demo`, [
RULES.REMOVE_FORCE,
]);
expectSafe('$('.repeat(40000));
expectSafe('()'.repeat(10000));
assert.ok(Date.now() - started < 2000, 'large malformed input should remain bounded');
});
test('resolves long invoked-function chains within the hook time budget', () => {
const definitions = [];
for (let index = 0; index < 20001; index += 1) {
const body = index === 20000
? 'Remove-Item -Force C:/tmp/demo'
: `f${index + 1}`;
definitions.push(`function f${index} { ${body} }`);
}
const started = Date.now();
expectRules(`${definitions.join('; ')}; f0`, [RULES.REMOVE_FORCE]);
assert.ok(Date.now() - started < 4000, 'function resolution should remain below hook timeout');
});
test('scans many sibling executable containers within the hook time budget', () => {
const command = Array.from(
{ length: 40000 },
(_, index) => index === 39999
? '$(Remove-Item -Force C:/tmp/demo)'
: '$(Write-Output safe)'
).join(' ');
const started = Date.now();
expectRules(command, [RULES.REMOVE_FORCE]);
assert.ok(Date.now() - started < 4000, 'sibling containers should remain bounded');
});
console.log('\nBenign controls:');
test('allows plain non-recursive, non-forced, non-wildcard Remove-Item', () => {
expectSafe('Remove-Item C:/tmp/notes.txt');
});
test('allows benign PowerShell and non-recursive cmd commands', () => {
expectSafe('Get-ChildItem C:/tmp');
expectSafe('Get-Date');
expectSafe('cmd /c del C:/tmp/notes.txt');
expectSafe('cmd /c echo rd /s /q C:/tmp/demo');
expectSafe('cmd /c "echo safe ^& rd /s /q C:/tmp/demo"');
expectSafe("function cleanup { Remove-Item -Force C:/tmp/demo }; 'cleanup'");
expectSafe('Write-Output safe`nRemove-Item -Force C:/tmp/demo');
});
test('allows explicitly false destructive switches and inert script blocks', () => {
expectSafe('Remove-Item -Force:$false C:/tmp/demo');
expectSafe('Remove-Item -Force:$null C:/tmp/demo');
expectSafe('Remove-Item -Recurse:$false C:/tmp/demo');
expectSafe("Remove-Item '-Force'");
expectSafe("Remove-Item -LiteralPath 'C:/tmp/file*.txt'");
expectSafe('{ Remove-Item -Force C:/tmp/demo }');
expectSafe('function cleanup { Remove-Item -Force C:/tmp/demo }');
});
test('treats backticks literally inside single-quoted strings', () => {
expectRules("Write-Output 'safe`'; Remove-Item -Force C:/tmp/demo", [
RULES.REMOVE_FORCE,
]);
});
test('handles empty and non-string commands', () => {
expectSafe('');
expectSafe(null);
expectSafe(undefined);
});
test('handles a trailing backtick without throwing or inventing a finding', () => {
assert.doesNotThrow(() => classify('Write-Output safe`'));
expectSafe('Write-Output safe`');
});
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
if (failed > 0) {
process.exit(1);
}