# 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.