diff --git a/.pi/README.md b/.pi/README.md index 98f1640b6..ec888ff13 100644 --- a/.pi/README.md +++ b/.pi/README.md @@ -90,8 +90,9 @@ The `extensions/index.ts` file handles: 4. **Context injection** — Parses `hookSpecificOutput.additionalContext` from the SessionStart hook and appends it to the system prompt on the next `before_agent_start`, wrapped in an `` block. Non-JSON hook output is tolerated, not treated as an error -5. **Hook isolation** — Failing, missing, or slow hooks degrade to a warning and never - terminate the Pi session. Hook execution is bounded by a timeout and an output limit +5. **Hook isolation** — Failing, missing, slow, or misconfigured hooks degrade to + a warning and never terminate the Pi session. Hook execution is bounded by a + timeout and an output limit 6. **Package resolution** — Resolves hook scripts from the installed package via `__dirname`, never from `process.cwd()`, so a global install works from any project directory. Hooks still *run* in the user's project directory, so project detection stays correct @@ -99,6 +100,13 @@ The `extensions/index.ts` file handles: All hook execution is non-shell (`execFile` without shell interpretation), so paths containing spaces, tabs, or shell metacharacters are safe. +Hook runtime selection uses the host `process.execPath` only under Node. +Without an override, compiled OMP/Bun falls back to `node` instead of +recursively launching the OMP binary as a hook runner. Set `ECC_HOOK_NODE` to +an explicit absolute Node executable path when `node` is not available on +`PATH`. +Relative values are rejected when the hook runs and surfaced as a warning. + ## Scope Intentionally **out of scope** for this first adapter (to be added independently): diff --git a/.pi/extensions/hook-runtime.js b/.pi/extensions/hook-runtime.js new file mode 100644 index 000000000..16de23540 --- /dev/null +++ b/.pi/extensions/hook-runtime.js @@ -0,0 +1,35 @@ +const path = require("node:path") + +/** + * Select a real Node executable for hook scripts. + * + * Compiled OMP may report `process.release.name` as `node` even though its + * `process.execPath` points to the OMP launcher. Bun is detected separately via + * `process.versions.bun`; both fall back to `node` unless `ECC_HOOK_NODE` + * supplies an explicit absolute path. + * + * @param options - Runtime metadata and an optional absolute Node override. + * @returns The executable path to use for hook scripts. + * @throws {Error} If the hook runtime override is non-empty and relative. + */ +function resolveHookRuntime({ + execPath = process.execPath, + releaseName = process.release?.name, + bunVersion = process.versions?.bun, + override = process.env.ECC_HOOK_NODE, +} = {}) { + const isNodeRuntime = + releaseName === "node" && + !bunVersion && + /^(?:node|nodejs)(?:\.exe)?$/i.test(path.basename(execPath)) + const overridePath = override?.trim() + if (overridePath) { + if (!path.isAbsolute(overridePath)) { + throw new Error("ECC_HOOK_NODE must be an absolute path: " + overridePath) + } + return overridePath + } + return isNodeRuntime ? execPath : "node" +} + +module.exports = { resolveHookRuntime } diff --git a/.pi/extensions/index.ts b/.pi/extensions/index.ts index 411791d72..f8310a8d5 100644 --- a/.pi/extensions/index.ts +++ b/.pi/extensions/index.ts @@ -15,16 +15,20 @@ * Design constraints (see .pi/README.md): * - Hooks resolve relative to THIS file, never `process.cwd()`, so a global * `pi install` works from any project directory. - * - Hooks execute via `execFile(process.execPath, [...])` with no shell, so - * paths containing spaces or shell metacharacters are safe. - * - Hook failures are isolated: a broken, missing, or slow hook degrades to a - * warning and never terminates the Pi session. + * - Hooks execute via `execFile(hookRuntime, [...])` with no shell, so paths + * containing spaces or shell metacharacters are safe. The hook runtime is + * selected separately because compiled OMP may report `process.release.name` + * as `node` while `process.execPath` points back to `omp`; Bun is detected + * separately via `process.versions.bun`. + * - Hook failures are isolated: a broken, missing, slow, or misconfigured hook + * degrades to a warning and never terminates the Pi session. */ import { execFile } from "node:child_process" import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" +import { resolveHookRuntime } from "./hook-runtime.js" /** * Minimal structural types mirroring `@earendil-works/pi-coding-agent`. @@ -175,8 +179,9 @@ interface HookResult { /** * Run an ECC hook through ECC's own runner. * - * Never rejects: a missing runner, a non-zero exit, a timeout, or a spawn error - * all resolve to a `failure` string that the caller surfaces as a warning. + * Never rejects: an invalid runtime override, a missing runner, a non-zero exit, + * a timeout, or a spawn error all resolve to a `failure` string that the caller + * surfaces as a warning. */ function runEccHook( spec: HookSpec, @@ -189,9 +194,19 @@ function runEccHook( resolve({ stdout: "", failure: `hook runner not found at ${HOOK_RUNNER}` }) return } + let hookRuntime: string + try { + hookRuntime = resolveHookRuntime() + } catch (error) { + resolve({ + stdout: "", + failure: `${spec.id}: ${(error as Error).message}`, + }) + return + } const child = execFile( - process.execPath, + hookRuntime, [HOOK_RUNNER, spec.id, spec.script, spec.profiles], { // Hooks inspect the user's project, so they run there. Only the script diff --git a/README.md b/README.md index 7734a0c77..8edd498d8 100644 --- a/README.md +++ b/README.md @@ -555,11 +555,14 @@ Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~ bash ./install.sh --target claude --modules hooks-runtime --enable-hooks ``` -That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched. +That installs the hook scripts under `~/.claude/` and registers the resolved +hook entries in `~/.claude/settings.json`. Existing user settings and hooks are +preserved; ECC-owned entries are tracked by stable ID for idempotent updates +and safe uninstall. If you installed ECC via `/plugin install`, do not copy those hooks into `settings.json`. Claude Code v2.1+ already auto-loads plugin `hooks/hooks.json`, and duplicating them in `settings.json` causes duplicate execution and cross-platform hook conflicts. -On Windows, Claude's config root is `%USERPROFILE%\\.claude`; install the hook runtime with: +On Windows, Claude's config root is `%USERPROFILE%\.claude`; install the hook runtime with: ```powershell pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md new file mode 100644 index 000000000..0dd5488bd --- /dev/null +++ b/docs/releases/2.2.1/patch-execution.md @@ -0,0 +1,143 @@ +# ECC 2.2.1 bug and security patch execution + +Status: in progress, 2026-09-07. Ticket: ECC-031. + +## Outcome and authority + +The user authorized reviewing, repairing, and merging critical bug and security +PRs, followed by publishing ECC 2.2.1. This advances the M0 distribution and +release-evidence contract. ECC retains policy, canonical state, and release +authority. New feature platforms, ECC 3 contracts, and broad refactoring remain +outside this patch. + +## Integration sequence + +1. Independently review and merge the verified PowerShell security fix #2961. +2. Repair installer ownership and uninstall dry-run data-loss reports #2964 and + #2952. Exercise install, upgrade, dry-run, and uninstall on disposable roots. +3. Repair hook JSON truncation #2924, Pi/OMP recursive process spawning #2909, + and project-scoped GateGuard exemptions #2921 without weakening denials. +4. Review manual Claude hook activation #2982 and plugin dependency loading + #2822. Include complete, verified fixes; document any remaining limitation. +5. Verify memory MCP compatibility and existing heredoc fixes in current source + and the actual packed artifact. Avoid duplicating already merged repairs. +6. Review the integrated diff, run focused and full tests, lint, coverage, + security checks, and hosted platform and packed-lifecycle checks. +7. Update release notes to actual merged behavior. Verify exact current main, + tag/version availability, signing identity, and registry publishing path. +8. Push the verified signed tag, watch the existing staged publication workflow, + and verify public registry integrity, release, and install lifecycle. + +## Working rules + +- Independent reviews and fixes use separate worktrees. One integration owner + serializes merges and checks the final combined result. +- Preserve contributor attribution. Consolidated or superseded PRs are linked + to the actual merged fix; PR closure alone is not repair evidence. +- Hosted checks must correspond to the source being merged or released. Failed + checks are diagnosed before a rerun. +- Never run lifecycle tests against real user homes. Never include credentials + in logs, source, release notes, or dashboard records. +- Keep v2.2.0 immutable and publish only the single tested 2.2.1 artifact through + the existing release workflow, with registry readback before latest promotion. + +## Initial evidence + +- Base: e04ea0b9cc8248686edf5ac751cadff550e162b8. +- Current GitHub account: haelyra, repository write permission verified. +- Repository NPM_TOKEN secret is configured; validity still needs publication. +- No remote v2.2.1 tag; registry lookup returns E404 for ecc-universal@2.2.1. +- Registry latest is 2.2.0. No local GPG private signing key or loaded SSH agent + identity was available in the initial check. Signing remains an open gate. +- Independent review found that a later scalar assignment could mask an earlier + unresolved PowerShell invocation in #2961. Commit bf0ac4e4 closes that bypass; + 52 classifier cases and 253 hook cases pass. Updated hosted checks are pending. + +## Reviewed integration candidates + +| Area | Source | Verification and scope | +| --- | --- | --- | +| Hook truncation | #2925, #2924 | 37 direct-entrypoint cases, 16 MiB bounded input, existing production limits preserved | +| Pi recursive spawning | #2911, #2909 | 28 adapter and 7 actual adapter-boundary tests, never launches compiled OMP as Node | +| GateGuard exemptions | #2979, #2921 | 192 cases; relative globs constrained to project, explicit absolute globs retained | +| Plugin dependency loading | #2994, #2822 | 10 cases; help/list paths need no third-party modules, required dependency failures are explicit | +| Yarn dependency security | Dependabot alert #62 | toml 4.3.0 matches npm lock; immutable Yarn install and recursive audit pass | +| PowerShell security | #2961 | 52 classifier cases, combined governance and GateGuard regressions; late-assignment bypass repaired | +| Manual Claude hooks | #2992, #2982 | 36 settings, 66 lifecycle, 42 install-apply cases; concurrent-edit and observed parent-swap tests | +| Installer data protection | #2980, #2981, #2956 | 23 ownership, 13 uninstall cases; all 15 target collision checks and failed-checkpoint regressions | +| Observer retention | #2971, #2673 | Merged cf065358 after 45 green hosted checks and independent review | +| Harness setup instructions | #2977, #2958, #2957 | 4 regressions; documented CLI, pinned real optional memory package, no fabricated scheduling server | + +Plugin dependency handling does not bundle or automatically install modules. +Database and schema-validation features still require declared runtime packages. +The installer, PowerShell, and manual Claude registration fixes are now combined +and independently reviewed. Conflict resolutions preserve both project-scoped +exemptions and PowerShell enforcement, plus Claude settings locking and installer +ownership/checkpoint protections. Focused combined suites pass. + +Claude settings pathname checks detect observed parent swaps and concurrent +edits; they are not a native filesystem isolation boundary. The residual race +between a final check and rename remains a follow-up, not a race-free claim. +Successful managed-file upgrades retain their existing replacement semantics. + +## Completion evidence + +First batch 82bfd225 passed 4,215/4,215 tests and lint. The first combined run +at 8cc31f1e passed 4,370/4,372 tests, with 89.27% line and 81.52% branch coverage. +Its two failures exposed guided setup reporting success after a late collision +was filtered. Full-preview revalidation fixes that interaction; all 22 guided +setup tests now pass, including initially identical unowned files before later +writes. Final full-suite and hosted validation are pending. + +Windows hosted checks exposed fixture-owned descriptor cleanup and directory +rename assumptions in two new settings tests. The repaired fixtures preserve +Windows OS-refusal assertions and ECC parent-identity checks. CodeQL findings +338-341 were confined to test-source patterns; minimal assertion/interception +changes preserve coverage without alert dismissals. Hosted rescanning remains +required. + +The first combined packed artifact passed the isolated macOS lifecycle, 13 +memory MCP regressions, 12 actual Codex/Hermes protocol sessions, and 196 +GateGuard cases including quoted, unquoted, and tab-stripped heredocs. Package +helpers, public CLI aliases, and dry-run entrypoints were exercised from the +installed archive, not just the source checkout. Final source must be repacked +after the guided-setup integration repair. Signing remains unavailable locally. + +Pending final hosted validation, signed tag, publication, registry +integrity readback, and clean lifecycle canaries. This document does not claim +that 2.2.1 has shipped. + +## Resumed verification, September 7 + +The secure GitHub gateway authenticated as an authorized repository maintainer. +All GitHub API requests in this continuation use that gateway. No local +credential inspection or signing-key discovery is part of this continuation. +The v2.2.1 tag and release are absent; npm returns E404 for 2.2.1 and still +reports latest 2.2.0. + +The ba3a64a2 hosted run passed coverage, lint, CodeQL, and Linux tests, but nine +Windows test jobs failed. Gateway downloads for both job logs and test artifacts +returned HTTP 401 from redirected storage. Check metadata confirms failures +occur during tests after successful dependency installation. Failed-suite +annotations now expose bounded diagnostic context through the checks API. +The runner also counts subprocess failure when a suite prints `Failed: 0`. +Eight isolated runner regressions pass. + +Follow-up review reproduced additional release defects. Ordered JSON merges +to one Kimi destination were collapsed by destination-only preview indexing; +operation-specific previews preserve the supported merge sequence (24 focused +tests pass). Array-form Claude commands now receive the same plugin-root +materialization as strings, including rejection of unresolved reads (seven new +and 36 existing settings tests pass). Static PowerShell alias and stdin values +are resolved conservatively, with independent review covering mixed named and +positional alias arguments. Hosted verification on the final patch remains +required before merge or release. + +Run 34164970113 on 14e731c6 exposed the Windows failure through the new check +annotations: the Antigravity ownership fixture searched a native Windows source +path using a POSIX-only literal, then dereferenced a missing operation. The +fixture now normalizes separators and asserts both planned operations exist; +all 23 ownership tests pass locally. The diagnostic matcher also uses escaped +Unicode literals to satisfy the repository's Unicode gate, and excludes passing +error-handling case names from failure excerpts. Fresh hosted validation must +confirm these final fixture and diagnostic corrections. diff --git a/docs/releases/2.2.1/release-notes.md b/docs/releases/2.2.1/release-notes.md index b8d7d5795..4d05b3c3d 100644 --- a/docs/releases/2.2.1/release-notes.md +++ b/docs/releases/2.2.1/release-notes.md @@ -1,8 +1,56 @@ # ECC 2.2.1 -ECC 2.2.1 is the signed ECC 2.2 patch release. It keeps the published `v2.2.0` -history immutable while shipping the reviewed release-surface hardening that -landed after the original 2.2.0 tag. +ECC 2.2.1 is a bug and security patch for ECC 2.2. It keeps the published +`v2.2.0` history immutable. These notes describe the prepared patch; publication +and signing evidence are tracked separately in the release checklist. + +## Security and data protection + +- GateGuard and governance capture recognize destructive PowerShell commands, + including the native PowerShell tool path. Dynamic command handling prevents + later variable assignments from concealing earlier unresolved invocations + ([#2961](https://github.com/affaan-m/ECC/pull/2961)). +- Relative GateGuard exemption globs stay within the project root. Explicit + absolute exemptions remain supported + ([#2921](https://github.com/affaan-m/ECC/issues/2921)). +- Installer writes reject collisions with untracked user-owned files. Failed + installs refresh ownership hashes only for files they actually wrote, preserving the previous + ownership hashes of untouched managed files + ([#2964](https://github.com/affaan-m/ECC/issues/2964)). +- Guided setup revalidates its preview before ownership filtering, so files + appearing between preview and apply cause a clear retry instead of a false + success. Existing identical user files stay outside ECC ownership. +- Uninstall respects `ECC_DRY_RUN=1`, including legacy Codex paths, and rejects + invalid dry-run values instead of silently allowing deletion + ([#2952](https://github.com/affaan-m/ECC/issues/2952)). +- Observer analysis retains observations on unsuccessful or unconfirmed + processing. Exit code zero alone no longer permits archival + ([#2971](https://github.com/affaan-m/ECC/pull/2971)). +- The Yarn lockfile updates `toml` to 4.3.0, matching the npm lockfile and + removing the affected older resolution. + +## Hooks and installation + +- Manual Claude installs register ECC-owned hook entries in Claude settings. + Repair, consent changes, and uninstall reconcile those entries while + preserving unrelated settings. Atomic settings updates check directory + identity and retry detected concurrent edits + ([#2992](https://github.com/affaan-m/ECC/pull/2992)). +- Direct hook entrypoints handle larger JSON payloads with bounded, UTF-8-safe + reads instead of silently truncating valid inputs. Existing production + wrapper limits remain unchanged + ([#2924](https://github.com/affaan-m/ECC/issues/2924)). +- The Pi adapter selects an actual Node runtime instead of recursively + executing a compiled OMP host as Node + ([#2909](https://github.com/affaan-m/ECC/issues/2909)). +- Installer listing and control-pane help avoid eager third-party dependency + loading. Features that require absent runtime packages report the missing + dependency explicitly + ([#2994](https://github.com/affaan-m/ECC/pull/2994)). +- Autonomous harness setup documentation replaces nonexistent package names + and unsupported CLI flags with documented interfaces, and distinguishes + session scheduling from a durable external scheduler + ([#2957](https://github.com/affaan-m/ECC/issues/2957)). ## Installer and release-surface hardening @@ -32,10 +80,22 @@ landed after the original 2.2.0 tag. - `v2.2.0` remains the immutable historical unsigned exception. Do not move, recreate, or reuse that tag. +## Scope and limitations + +- Plugin dependency handling does not bundle or automatically install missing + modules. Database and schema-validation features require their declared + runtime dependencies. +- Ownership protection covers untracked collisions and failed-install + checkpoints. Successful upgrades retain the existing contract for replacing + previously managed files. Back up intentional edits before upgrading. +- This patch does not introduce new harness platforms or claim that every + open community issue is resolved. + ## Upgrade -Install or update the published package, then run the same ECC command path you -already use: +After the release workflow publishes 2.2.1 and verifies registry integrity, +install or update the package, then run the same ECC command path you already +use. Until publication completes, the exact-version command below returns E404. ```bash npm install -g ecc-universal@2.2.1 diff --git a/docs/security/ecc-039-powershell-gateguard-plan.md b/docs/security/ecc-039-powershell-gateguard-plan.md new file mode 100644 index 000000000..03c971b02 --- /dev/null +++ b/docs/security/ecc-039-powershell-gateguard-plan.md @@ -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. diff --git a/hooks/README.md b/hooks/README.md index 510dfa755..144bc89ad 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -33,7 +33,11 @@ bash ./install.sh --target claude --modules hooks-runtime --enable-hooks pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks ``` -That installs resolved hooks to `~/.claude/hooks/hooks.json`. On Windows, the Claude config root is `%USERPROFILE%\\.claude`. +That installs the hook scripts under `~/.claude/` and registers the resolved +hook entries in `~/.claude/settings.json`. Existing user settings and hook +entries are preserved, while ECC-owned entries are tracked by stable ID for +idempotent updates and safe uninstall. On Windows, the Claude config root is +`%USERPROFILE%\.claude`. ### PreToolUse Hooks diff --git a/hooks/hooks.json b/hooks/hooks.json index f1c82b515..62053904e 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -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 { - console.error(`[control-pane] ${error.message}`); + console.error(`[control-pane] ${describeMissingDependencyError(error) || error.message}`); process.exit(1); }); } diff --git a/scripts/hooks/check-console-log.js b/scripts/hooks/check-console-log.js index 94e60a152..28f3ac6db 100755 --- a/scripts/hooks/check-console-log.js +++ b/scripts/hooks/check-console-log.js @@ -26,29 +26,31 @@ const EXCLUDED_PATTERNS = [ /__mocks__\//, ]; -const MAX_STDIN = 1024 * 1024; // 1MB limit +const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; let data = ''; -let truncated = false; +let stdinBytes = 0; +let oversized = false; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); - if (chunk.length > remaining) truncated = true; - } else { - truncated = true; + if (oversized) return; + stdinBytes += Buffer.byteLength(chunk, 'utf8'); + if (stdinBytes > MAX_DIRECT_STDIN_BYTES) { + data = ''; + oversized = true; + return; } + data += chunk; }); /** * Echo stdin back (ECC pass-through convention), then exit once the pipe has - * flushed. Truncated stdin is never echoed: a JSON document cut mid-stream is - * reported by the harness as a Stop hook JSON validation failure (#2090). + * flushed. Direct/legacy entrypoints preserve complete supported payloads up + * to 16MiB; the production runner applies its stricter bounded-input policy. */ function passThroughAndExit() { - if (truncated) { - log('[Hook] check-console-log: stdin exceeded 1MB; suppressing pass-through (fail-open)'); + if (oversized) { + log('[Hook] check-console-log: direct stdin exceeded 16MiB; suppressing pass-through'); process.exit(0); } if (!data) { @@ -85,6 +87,6 @@ process.stdin.on('end', () => { log(`[Hook] check-console-log error: ${err.message}`); } - // Always output the original data (unless truncated) + // Always output the complete original data. passThroughAndExit(); }); diff --git a/scripts/hooks/doc-file-warning.js b/scripts/hooks/doc-file-warning.js index 40d0282ab..d26a9fa80 100644 --- a/scripts/hooks/doc-file-warning.js +++ b/scripts/hooks/doc-file-warning.js @@ -16,7 +16,7 @@ const path = require('path'); const { buildPreToolUseAdditionalContext } = require('./pretooluse-visible-output'); -const MAX_STDIN = 1024 * 1024; +const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; // Known ad-hoc filenames that indicate impulse/scratch files (case-sensitive, uppercase only) const ADHOC_FILENAMES = /^(NOTES|TODO|SCRATCH|TEMP|DRAFT|BRAINSTORM|SPIKE|DEBUG|WIP)\.(md|txt)$/; @@ -71,21 +71,29 @@ function run(inputOrRaw, _options = {}) { /** * Stdin entrypoint for direct/spawnSync execution: reads the hook payload from - * stdin (capped at MAX_STDIN), runs the policy, and writes the PreToolUse result - * to stdout. Must only run when invoked directly, never on require(), so the - * stdin listeners are not leaked into a parent that loads this hook in-process. + * stdin, runs the policy, and writes the PreToolUse result to stdout. Direct + * and legacy entrypoints preserve complete supported payloads up to 16MiB; + * the production runner applies its stricter bounded-input policy. Must only + * run when invoked directly so stdin listeners are not leaked into a parent. */ function main() { let data = ''; + let stdinBytes = 0; + let oversized = false; process.stdin.setEncoding('utf8'); process.stdin.on('data', c => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += c.substring(0, remaining); + if (oversized) return; + stdinBytes += Buffer.byteLength(c, 'utf8'); + if (stdinBytes > MAX_DIRECT_STDIN_BYTES) { + data = ''; + oversized = true; + return; } + data += c; }); process.stdin.on('end', () => { + if (oversized) return; const result = run(data); if (result.stderr) { diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js index bf91eb78a..bfd2b11c6 100644 --- a/scripts/hooks/gateguard-fact-force.js +++ b/scripts/hooks/gateguard-fact-force.js @@ -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']); @@ -100,11 +104,12 @@ function getExtraDestructiveRegex() { } // Operator-supplied path exemptions. Comma-separated globs (`GATEGUARD_EXEMPT_GLOBS`) -// matched against the normalized (forward-slash, lowercased) file path. First-touch +// matched against the normalized project-relative path (or full path for an +// explicitly absolute glob). First-touch // fact-forcing is skipped for a matching Edit/Write/MultiEdit target — intended for // low-import-value trees (tests, generated artifacts, scratch dirs) where "who imports -// this / what schema" carries no signal. Memoized on the env value; fail-open (a -// malformed pattern is dropped, never throws). `*` matches within a path segment, +// this / what schema" carries no signal. Memoized on the env value; malformed +// patterns are dropped without granting exemptions. `*` matches within a path segment, // `**` across segments, `?` a single char. let exemptCacheKey = null; let exemptCacheRegexes = null; @@ -116,16 +121,24 @@ function getExemptMatchers() { exemptCacheKey = raw; exemptCacheRegexes = raw .split(',') - .map(s => s.trim()) + .map(s => normalizeForMatch(s.trim())) .filter(Boolean) .map(glob => { - const source = glob - .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex metachars, keep * and ? - .split('**') // ** boundaries (cross-segment) - .map(part => part.replace(/\*/g, '[^/]*').replace(/\?/g, '.')) - .join('.*'); // ** -> across segments + let source = ''; + for (let index = 0; index < glob.length; index++) { + const char = glob[index]; + if (char === '*' && glob[index + 1] === '*') { + index++; + if (glob[index + 1] === '/') { + source += '(?:.*/)?'; + index++; + } else source += '.*'; + } else if (char === '*') source += '[^/]*'; + else if (char === '?') source += '[^/]'; + else source += char.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } try { - return new RegExp(source); + return { regex: new RegExp(`^${source}$`), absolute: path.posix.isAbsolute(glob) || path.win32.isAbsolute(glob) }; } catch (_) { return null; } @@ -134,9 +147,17 @@ function getExemptMatchers() { return exemptCacheRegexes; } -function isExemptPath(filePath) { - const norm = normalizeForMatch(filePath); - return getExemptMatchers().some(re => re.test(norm)); +function isExemptPath(filePath, data) { + const projectRoot = process.env.CLAUDE_PROJECT_DIR || data.cwd || process.cwd(); + if (typeof projectRoot !== 'string' || typeof filePath !== 'string') return false; + const paths = /^[a-z]:[\\/]|^\\\\/i.test(projectRoot) ? path.win32 : path.posix; + if (!paths.isAbsolute(projectRoot)) return false; + const target = paths.resolve(projectRoot, filePath); + const relative = paths.relative(projectRoot, target); + const contained = relative !== '..' && !relative.startsWith(`..${paths.sep}`) && !paths.isAbsolute(relative); + return getExemptMatchers().some(({ regex, absolute }) => + absolute ? regex.test(normalizeForMatch(target)) : contained && regex.test(normalizeForMatch(relative)) + ); } function isRoutineBashGateDisabled() { @@ -720,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) { @@ -898,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; @@ -1119,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', @@ -1200,13 +1243,13 @@ 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); if (toolName === 'Edit' || toolName === 'Write') { const filePath = toolInput.file_path || ''; - if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath)) { + if (!filePath || isClaudeSettingsPath(filePath) || isExemptPath(filePath, data)) { return rawInput; // allow } @@ -1239,7 +1282,7 @@ function run(rawInput) { const edits = toolInput.edits || []; for (const edit of edits) { const filePath = edit.file_path || ''; - if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath) && !isChecked(filePath)) { + if (filePath && !isClaudeSettingsPath(filePath) && !isExemptPath(filePath, data) && !isChecked(filePath)) { const { ok, denials } = markCheckedAndCountDenial(filePath); if (!ok) { return allowWithStateWarning(); @@ -1255,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)) { @@ -1273,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. @@ -1285,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 }); } @@ -1297,4 +1344,4 @@ function run(rawInput) { return rawInput; // allow } -module.exports = { run }; +module.exports = { classifyDestructiveCommand, run }; diff --git a/scripts/hooks/governance-capture.js b/scripts/hooks/governance-capture.js index b38187c27..5f75baeaf 100644 --- a/scripts/hooks/governance-capture.js +++ b/scripts/hooks/governance-capture.js @@ -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) { diff --git a/scripts/hooks/post-edit-console-warn.js b/scripts/hooks/post-edit-console-warn.js index 8002beb93..f2ce096c2 100644 --- a/scripts/hooks/post-edit-console-warn.js +++ b/scripts/hooks/post-edit-console-warn.js @@ -11,7 +11,7 @@ const { readFile } = require('../lib/utils'); -const MAX_STDIN = 1024 * 1024; // 1MB limit +const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; function run(data) { const warnings = []; try { @@ -47,14 +47,24 @@ function run(data) { if (require.main === module) { let data = ''; + let stdinBytes = 0; + let oversized = false; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); + if (oversized) return; + stdinBytes += Buffer.byteLength(chunk, 'utf8'); + if (stdinBytes > MAX_DIRECT_STDIN_BYTES) { + data = ''; + oversized = true; + return; } + data += chunk; }); process.stdin.on('end', () => { + if (oversized) { + process.exitCode = 0; + return; + } const result = run(data); if (result.stderr) process.stderr.write(`${result.stderr}\n`); process.stdout.write(result.stdout); diff --git a/scripts/hooks/post-edit-format.js b/scripts/hooks/post-edit-format.js index 26a79f939..d79409841 100644 --- a/scripts/hooks/post-edit-format.js +++ b/scripts/hooks/post-edit-format.js @@ -25,7 +25,7 @@ const UNSAFE_PATH_CHARS = /[&|<>^%!;`()$]/; const { findProjectRoot, detectFormatter, resolveFormatterBin } = require('../lib/resolve-formatter'); -const MAX_STDIN = 1024 * 1024; // 1MB limit +const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; /** * Core logic — exported so run-with-flags.js can call directly @@ -90,19 +90,28 @@ function run(rawInput) { // ── stdin entry point (backwards-compatible) ──────────────────── if (require.main === module) { let data = ''; + let stdinBytes = 0; + let oversized = false; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); + if (oversized) return; + stdinBytes += Buffer.byteLength(chunk, 'utf8'); + if (stdinBytes > MAX_DIRECT_STDIN_BYTES) { + data = ''; + oversized = true; + return; } + data += chunk; }); process.stdin.on('end', () => { + if (oversized) { + process.exit(0); + return; + } data = run(data); - process.stdout.write(data); - process.exit(0); + process.stdout.write(data, () => process.exit(0)); }); } diff --git a/scripts/hooks/post-edit-typecheck.js b/scripts/hooks/post-edit-typecheck.js index 18f03b7d0..28640c0ac 100644 --- a/scripts/hooks/post-edit-typecheck.js +++ b/scripts/hooks/post-edit-typecheck.js @@ -13,18 +13,28 @@ const { execFileSync } = require("child_process"); const fs = require("fs"); const path = require("path"); -const MAX_STDIN = 1024 * 1024; // 1MB limit +const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024; let data = ""; +let stdinBytes = 0; +let oversized = false; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { - if (data.length < MAX_STDIN) { - const remaining = MAX_STDIN - data.length; - data += chunk.substring(0, remaining); + if (oversized) return; + stdinBytes += Buffer.byteLength(chunk, "utf8"); + if (stdinBytes > MAX_DIRECT_STDIN_BYTES) { + data = ""; + oversized = true; + return; } + data += chunk; }); process.stdin.on("end", () => { + if (oversized) { + process.exit(0); + return; + } try { const input = JSON.parse(data); const filePath = input.tool_input?.file_path; @@ -32,8 +42,8 @@ process.stdin.on("end", () => { if (filePath && /\.(ts|tsx)$/.test(filePath)) { const resolvedPath = path.resolve(filePath); if (!fs.existsSync(resolvedPath)) { - process.stdout.write(data); - process.exit(0); + process.stdout.write(data, () => process.exit(0)); + return; } // Find nearest tsconfig.json by walking up (max 20 levels to prevent infinite loop) let dir = path.dirname(resolvedPath); @@ -91,6 +101,5 @@ process.stdin.on("end", () => { // Invalid input — pass through } - process.stdout.write(data); - process.exit(0); + process.stdout.write(data, () => process.exit(0)); }); diff --git a/scripts/hooks/posttooluse-dispatcher.js b/scripts/hooks/posttooluse-dispatcher.js index ac4345afc..fcffeb400 100644 --- a/scripts/hooks/posttooluse-dispatcher.js +++ b/scripts/hooks/posttooluse-dispatcher.js @@ -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) ); } diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 40b8c7993..1435d2ff6 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -19,6 +19,7 @@ const { } = require('./lib/install/request'); const { getComputeSponsorCopy } = require('./lib/compute-sponsor'); const { stripAnsi } = require('./lib/utils'); +const { describeMissingDependencyError } = require('./lib/missing-dependency'); function getHelpText() { const languages = listLegacyCompatibilityLanguages(); @@ -200,7 +201,12 @@ async function main() { printHumanPlan(result, false); } } catch (error) { - process.stderr.write(`Error: ${error.message}${getHelpText()}`); + const missingDependencyMessage = describeMissingDependencyError(error); + process.stderr.write( + missingDependencyMessage + ? `Error: ${missingDependencyMessage}\n` + : `Error: ${error.message}${getHelpText()}` + ); process.exit(1); } } diff --git a/scripts/install-plan.js b/scripts/install-plan.js index 0be25bc14..e2d5fc653 100644 --- a/scripts/install-plan.js +++ b/scripts/install-plan.js @@ -14,6 +14,7 @@ const { loadInstallConfig, } = require('./lib/install/config'); const { normalizeInstallRequest } = require('./lib/install/request'); +const { describeMissingDependencyError } = require('./lib/missing-dependency'); function showHelp() { console.log(` @@ -268,7 +269,7 @@ function main() { printPlan(plan); } } catch (error) { - console.error(`Error: ${error.message}`); + console.error(`Error: ${describeMissingDependencyError(error) || error.message}`); process.exit(1); } } diff --git a/scripts/lib/atomic-write.js b/scripts/lib/atomic-write.js index e3d41df0d..9e9e524fe 100644 --- a/scripts/lib/atomic-write.js +++ b/scripts/lib/atomic-write.js @@ -13,21 +13,34 @@ function writeFileAtomic(filePath, content, options = {}) { ); const mode = options.mode || 0o600; + if (options.validateParent) options.validateParent(); fs.mkdirSync(parentDir, { recursive: true }); let descriptor; try { + if (options.validateParent) options.validateParent(); descriptor = fs.openSync(tempPath, 'wx', mode); + if (options.validateParent) options.validateParent(); fs.writeFileSync(descriptor, content, { encoding: options.encoding || 'utf8' }); fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined; + if (options.validateParent) options.validateParent(); + if (options.beforeRename) options.beforeRename(); fs.renameSync(tempPath, resolvedPath); } catch (error) { if (descriptor !== undefined) { fs.closeSync(descriptor); } - fs.rmSync(tempPath, { force: true }); + // If the parent was replaced, this pathname may now name somebody else's + // file. Leave the private staging file in its original directory. + let parentUnchanged = true; + try { + if (options.validateParent) options.validateParent(); + } catch (_error) { + parentUnchanged = false; + } + if (parentUnchanged) fs.rmSync(tempPath, { force: true }); throw error; } diff --git a/scripts/lib/control-pane/state.js b/scripts/lib/control-pane/state.js index b6c41d056..9827443c5 100644 --- a/scripts/lib/control-pane/state.js +++ b/scripts/lib/control-pane/state.js @@ -4,9 +4,6 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const initSqlJs = require('sql.js'); -const toml = require('@iarna/toml'); - const { buildControlPaneActions } = require('./actions'); const SNAPSHOT_SCHEMA_VERSION = 'ecc.control-pane.snapshot.v1'; @@ -85,6 +82,10 @@ function normalizeConfig(rawConfig = {}, options = {}) { } function readTomlConfig(configPath) { + // @iarna/toml is required lazily so commands that never resolve a config + // file (e.g. `--help`, or a first run before any ecc2.toml exists) don't + // need it on the require path. + const toml = require('@iarna/toml'); const raw = fs.readFileSync(configPath, 'utf8'); return toml.parse(raw); } @@ -113,6 +114,10 @@ function resolveControlPaneConfig(options = {}) { async function openSqlDatabase(dbPath) { if (!dbPath || !fs.existsSync(dbPath)) return null; + // sql.js is required lazily so commands that never open an existing + // ecc2.db (e.g. `--help`, or a first run before any db exists) don't need + // it on the require path. + const initSqlJs = require('sql.js'); const SQL = await initSqlJs(); const buffer = fs.readFileSync(dbPath); return new SQL.Database(buffer); diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index c10b1cfe3..99ec19614 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -3,6 +3,7 @@ const fs = require('fs'); const { execFileSync } = require('child_process'); const os = require('os'); const path = require('path'); +const { isDeepStrictEqual } = require('util'); const { loadInstallManifests } = require('./install-manifests'); const { readInstallState, validateInstallState } = require('./install-state'); @@ -20,6 +21,17 @@ const { getLegacyOpencodeLocation, inspectLegacyOpencodeState, } = require('./install/opencode-legacy-migration'); +const { + acquireSettingsLock, + assertClaudeSettingsPath, + getClaudeSettingsPath, + inspectManagedHooks, + materializeManagedHooks, + repairManagedHooks, + uninstallManagedHooks, + updateSettingsAtomic, + validateManagedHooks, +} = require('./install/claude-settings'); const { adaptAntigravityAgent } = require('./install/antigravity-agent'); const { buildInstallIndex, rewriteRelativeLinks } = require('./install/link-rewrite'); const { getInstallTargetAdapter, listInstallTargetAdapters } = require('./install-targets/registry'); @@ -523,6 +535,13 @@ function readJsonNoFollow(filePath) { return JSON.parse(readFileNoFollow(filePath, 'utf8')); } +function assertClaudeSettingsDestination(operation, trustedRoot, target = null) { + if (target && target !== 'claude' && target !== 'claude-project') { + throw new Error('Refusing to manage Claude hooks for a non-Claude target.'); + } + assertClaudeSettingsPath(operation.destinationPath, trustedRoot); +} + function writeContainedFile(destinationPath, content, trustedRoot, action, mode) { const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action); const finalDestination = getManagedDestination( @@ -687,8 +706,26 @@ function deepRemoveJsonSubset(currentValue, managedValue) { return currentValue === managedValue ? JSON_REMOVE_SENTINEL : currentValue; } -function hydrateRecordedOperations(repoRoot, operations) { +function hydrateRecordedOperations(repoRoot, operations, trustedRoot) { return operations.map(operation => { + if (operation.kind === 'update-claude-settings') { + const sourcePath = resolveOperationSourcePath(repoRoot, operation); + if (!sourcePath || !fs.existsSync(sourcePath)) { + throw new Error( + `Missing source file for repair: ${sourcePath || operation.sourceRelativePath}` + ); + } + return { + ...operation, + sourcePath, + previousManagedHooks: operation.managedHooks, + managedHooks: materializeManagedHooks( + readJsonNoFollow(sourcePath), + trustedRoot + ), + }; + } + if (operation.kind !== 'copy-file') { return { ...operation }; } @@ -717,7 +754,14 @@ function shouldRepairFromRecordedOperations(state) { return getManagedOperations(state).some(operation => operation.kind !== 'copy-file'); } -function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = null) { +function executeRepairOperation( + repoRoot, + operation, + trustedRoot, + linkIndex = null, + target = null, + settingsLockHeld = false +) { // Install-state is attacker-controllable; never write/delete outside the // adapter-derived trusted root, regardless of what the state file claims // (GHSA-hfpv-w6mp-5g95). @@ -770,6 +814,35 @@ function executeRepairOperation(repoRoot, operation, trustedRoot, linkIndex = nu return operation.destinationPath; } + if (operation.kind === 'update-claude-settings') { + assertClaudeSettingsDestination(operation, trustedRoot, target); + const managedHooks = validateManagedHooks(operation.managedHooks); + const previousManagedHooks = operation.previousManagedHooks + ? validateManagedHooks(operation.previousManagedHooks, 'previous managed hooks') + : null; + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'repair' + ); + const settingsPath = existingDestination + ? getManagedDestination(existingDestination, trustedRoot, 'repair').managedPath + : prepareContainedWriteDestination(operation.destinationPath, trustedRoot, 'repair'); + updateSettingsAtomic( + settingsPath, + currentSettings => repairManagedHooks(currentSettings, managedHooks, { + previousManagedHooks, + }), + { + lockHeld: settingsLockHeld, + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'repair'); + }, + } + ); + return operation.destinationPath; + } + if (operation.kind === 'remove') { const removedPath = removeContainedPath( operation.destinationPath, @@ -938,6 +1011,45 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) { }; } + if (operation.kind === 'update-claude-settings') { + assertClaudeSettingsDestination(operation, trustedRoot, options.target); + const existingDestination = getContainedExistingPath( + operation.destinationPath, + trustedRoot, + 'uninstall' + ); + if (!existingDestination) { + return { + removedPaths: [], + cleanupTargets: [] + }; + } + + const settingsPath = getManagedDestination( + existingDestination, + trustedRoot, + 'uninstall' + ).managedPath; + const uninstalled = updateSettingsAtomic( + settingsPath, + currentSettings => uninstallManagedHooks(currentSettings, operation.managedHooks), + { + lockHeld: Boolean(options.settingsLockHeld), + beforeCommit() { + getManagedDestination(settingsPath, trustedRoot, 'uninstall'); + }, + } + ); + + return { + removedPaths: [], + cleanupTargets: [], + retainedPaths: uninstalled.retained.length > 0 + ? [operation.destinationPath] + : [] + }; + } + if (operation.kind === 'remove') { const previousContent = getOperationPreviousContent(operation); if (previousContent !== null) { @@ -966,7 +1078,7 @@ function executeUninstallOperation(operation, trustedRoot, options = {}) { throw new Error(`Unsupported uninstall operation kind: ${operation.kind}`); } -function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null) { +function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = null, target = null) { const destinationPath = operation.destinationPath; if (!destinationPath) { return { @@ -1147,6 +1259,49 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n }; } + if (operation.kind === 'update-claude-settings') { + try { + assertClaudeSettingsDestination(operation, trustedRoot, target); + } catch (_error) { + return { + status: 'unsafe-destination', + operation, + destinationPath, + reason: 'non-canonical-claude-settings' + }; + } + let managedHooks; + try { + managedHooks = validateManagedHooks(operation.managedHooks); + } catch (_error) { + return { + status: 'unverified', + operation, + destinationPath + }; + } + + try { + const inspection = inspectManagedHooks( + readJsonNoFollow(inspectedPath), + managedHooks + ); + return { + status: inspection.status, + operation, + destinationPath, + managedHookInspection: inspection + }; + } catch (error) { + return { + status: 'invalid-settings', + operation, + destinationPath, + error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}` + }; + } + } + return { status: 'unverified', operation, @@ -1154,11 +1309,17 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n }; } -function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) { +function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, target = null) { const linkIndex = buildLinkIndexForOperations(operations, trustedRoot); return operations.reduce( (summary, operation) => { - const inspection = inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex); + const inspection = inspectManagedOperation( + repoRoot, + trustedRoot, + operation, + linkIndex, + target + ); if (inspection.status === 'missing') { summary.missing.push(inspection); } else if (inspection.status === 'drifted') { @@ -1169,6 +1330,8 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) { summary.unsafeSource.push(inspection); } else if (inspection.status === 'unsafe-destination') { summary.unsafeDestination.push(inspection); + } else if (inspection.status === 'invalid-settings') { + summary.invalidSettings.push(inspection); } else if (inspection.status === 'unverified' || inspection.status === 'invalid-destination') { summary.unverified.push(inspection); } @@ -1180,6 +1343,7 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) { missingSource: [], unsafeSource: [], unsafeDestination: [], + invalidSettings: [], unverified: [] } ); @@ -1200,7 +1364,9 @@ function getUnsafeOperationResult(record, operationHealth) { ? getUnsafeManagedDestinationError(operationHealth) : operationHealth.unsafeSource.length > 0 ? createUnsafeRepairSourceError().message - : null; + : operationHealth.invalidSettings.length > 0 + ? operationHealth.invalidSettings[0].error + : null; if (!error) { return null; } @@ -1467,7 +1633,8 @@ function analyzeRecord(record, context) { const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, - managedOperations + managedOperations, + record.adapter.target ); const missingManagedOperations = operationHealth.missing; @@ -1491,6 +1658,17 @@ function analyzeRecord(record, context) { ); } + if (operationHealth.invalidSettings.length > 0) { + issues.push( + buildIssue( + 'error', + 'invalid-claude-settings', + operationHealth.invalidSettings[0].error, + { paths: operationHealth.invalidSettings.map(entry => entry.destinationPath) } + ) + ); + } + if (missingManagedOperations.length > 0) { issues.push( buildIssue('error', 'missing-managed-files', `${missingManagedOperations.length} managed file(s) are missing`, { @@ -1614,7 +1792,11 @@ function createRepairPlanFromRecord(record, context, options = {}) { record.legacyLayout !== 'opencode' && (state.request.legacyMode || shouldRepairFromRecordedOperations(state)) ) { - const operations = hydrateRecordedOperations(context.repoRoot, getManagedOperations(state)); + const operations = hydrateRecordedOperations( + context.repoRoot, + getManagedOperations(state), + record.targetRoot + ); const statePreview = buildRecordedStatePreview(state, context, operations); return { @@ -1757,7 +1939,17 @@ function repairInstalledStates(options = {}) { }; } + let releaseSettingsLock = null; try { + const settingsPathToLock = !options.dryRun + && getManagedOperations(record.state || {}).some( + operation => operation.kind === 'update-claude-settings' + ) + ? getClaudeSettingsPath(record.targetRoot) + : null; + if (settingsPathToLock) { + releaseSettingsLock = acquireSettingsLock(settingsPathToLock); + } const needsOpencodeBuild = record.adapter.target === 'opencode' && hasOpencodeBuildError(getOpencodeBuildValidationIssues(context)); const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT); @@ -1829,7 +2021,8 @@ function repairInstalledStates(options = {}) { const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, - desiredPlan.operations + desiredPlan.operations, + record.adapter.target ); const unsafeOperationResult = getUnsafeOperationResult( record, @@ -1876,7 +2069,8 @@ function repairInstalledStates(options = {}) { const operationHealth = summarizeManagedOperationHealth( context.repoRoot, record.targetRoot, - desiredPlan.operations + desiredPlan.operations, + record.adapter.target ); const unsafeOperationResult = getUnsafeOperationResult( @@ -1899,7 +2093,20 @@ function repairInstalledStates(options = {}) { }; } - const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))]; + const repairOperations = [ + ...operationHealth.missing.map(entry => ({ ...entry.operation })), + ...operationHealth.drifted.map(entry => ({ ...entry.operation })), + ...desiredPlan.operations + .filter(operation => ( + operation.kind === 'update-claude-settings' + && operation.previousManagedHooks + && !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks) + )) + .map(operation => ({ ...operation })), + ].filter((operation, index, items) => items.findIndex(candidate => ( + candidate.kind === operation.kind + && candidate.destinationPath === operation.destinationPath + )) === index); const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot); const legacyMigrationPaths = migration.legacyOperationsToRemove.map( operation => operation.destinationPath @@ -1934,7 +2141,9 @@ function repairInstalledStates(options = {}) { context.repoRoot, operation, record.targetRoot, - repairLinkIndex + repairLinkIndex, + record.adapter.target, + Boolean(releaseSettingsLock) ); if (repairedPath) { repairedPaths.push(repairedPath); @@ -1986,6 +2195,8 @@ function repairInstalledStates(options = {}) { plannedRepairs: [], error: error.message }; + } finally { + if (releaseSettingsLock) releaseSettingsLock(); } }); @@ -2100,15 +2311,23 @@ function uninstallInstalledStates(options = {}) { }; } + let releaseSettingsLock = null; try { const removedPaths = []; const cleanupTargets = []; const retainedPaths = []; const operations = getManagedOperations(state); + if (operations.some(operation => operation.kind === 'update-claude-settings')) { + releaseSettingsLock = acquireSettingsLock( + getClaudeSettingsPath(record.targetRoot) + ); + } for (const operation of operations) { const outcome = executeUninstallOperation(operation, record.targetRoot, { preserveDriftedCopies: true, + target: record.adapter.target, + settingsLockHeld: Boolean(releaseSettingsLock), }); removedPaths.push(...outcome.removedPaths); cleanupTargets.push(...outcome.cleanupTargets); @@ -2153,6 +2372,8 @@ function uninstallInstalledStates(options = {}) { plannedRemovals, error: error.message }; + } finally { + if (releaseSettingsLock) releaseSettingsLock(); } }); diff --git a/scripts/lib/install-state.js b/scripts/lib/install-state.js index a0aa3bbe6..3b5b7fc23 100644 --- a/scripts/lib/install-state.js +++ b/scripts/lib/install-state.js @@ -1,5 +1,10 @@ const fs = require('fs'); const path = require('path'); +const { + CLAUDE_HOOKS_CONFIG_PATH, + getClaudeSettingsPath, + validateRecordedManagedHooks, +} = require('./install/claude-settings'); // Dependency-free, self-contained validation. The installer closure must not // require any non-builtin package (enterprise supply-chain vetting: the vetted @@ -209,6 +214,38 @@ function createFallbackValidator() { ) { pushError(`${instancePath}/contentSha256`, 'must be a SHA-256 hex digest'); } + if (operation.kind === 'update-claude-settings') { + if (!['claude', 'claude-project'].includes(state.target && state.target.target)) { + pushError(`${instancePath}/kind`, 'is only valid for Claude targets'); + } + if (operation.moduleId !== 'hooks-runtime') { + pushError(`${instancePath}/moduleId`, 'must equal hooks-runtime'); + } + if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== CLAUDE_HOOKS_CONFIG_PATH) { + pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json'); + } + if ( + isNonEmptyString(state.target && state.target.root) + && isNonEmptyString(operation.destinationPath) + ) { + const expectedDestination = path.resolve(getClaudeSettingsPath(state.target.root)); + const actualDestination = path.resolve(operation.destinationPath); + const pathsMatch = process.platform === 'win32' + ? expectedDestination.toLowerCase() === actualDestination.toLowerCase() + : expectedDestination === actualDestination; + if (!pathsMatch) { + pushError( + `${instancePath}/destinationPath`, + 'must equal the canonical Claude settings path' + ); + } + } + try { + validateRecordedManagedHooks(operation.managedHooks); + } catch (error) { + pushError(`${instancePath}/managedHooks`, error.message); + } + } } } diff --git a/scripts/lib/install-targets/claude-home.js b/scripts/lib/install-targets/claude-home.js index 3729b50c8..5cc426ac9 100644 --- a/scripts/lib/install-targets/claude-home.js +++ b/scripts/lib/install-targets/claude-home.js @@ -5,6 +5,7 @@ const { createRemappedOperation, isForeignPlatformPath, normalizeRelativePath, + planClaudeHooksOperations, } = require('./helpers'); const CLAUDE_ECC_NAMESPACE = 'ecc'; @@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({ const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter(p => !isForeignPlatformPath(p, adapter.target)) - .map(sourceRelativePath => { + .flatMap(sourceRelativePath => { + if ( + module.id === 'hooks-runtime' + && normalizeRelativePath(sourceRelativePath) === 'hooks' + ) { + return planClaudeHooksOperations(adapter, module, planningInput); + } + const managedDestinationPath = getClaudeManagedDestinationPath( adapter, sourceRelativePath, @@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({ ); if (managedDestinationPath) { - return createRemappedOperation( + return [createRemappedOperation( adapter, module.id, sourceRelativePath, managedDestinationPath, { strategy: 'preserve-relative-path' } - ); + )]; } - return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput); + return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); }); }, diff --git a/scripts/lib/install-targets/claude-project.js b/scripts/lib/install-targets/claude-project.js index 051b0ae26..a4fda3970 100644 --- a/scripts/lib/install-targets/claude-project.js +++ b/scripts/lib/install-targets/claude-project.js @@ -5,6 +5,7 @@ const { createRemappedOperation, isForeignPlatformPath, normalizeRelativePath, + planClaudeHooksOperations, } = require('./helpers'); const CLAUDE_ECC_NAMESPACE = 'ecc'; @@ -66,7 +67,14 @@ module.exports = createInstallTargetAdapter({ const paths = Array.isArray(module.paths) ? module.paths : []; return paths .filter(p => !isForeignPlatformPath(p, 'claude')) - .map(sourceRelativePath => { + .flatMap(sourceRelativePath => { + if ( + module.id === 'hooks-runtime' + && normalizeRelativePath(sourceRelativePath) === 'hooks' + ) { + return planClaudeHooksOperations(adapter, module, planningInput); + } + const managedDestinationPath = getClaudeManagedDestinationPath( adapter, sourceRelativePath, @@ -74,16 +82,16 @@ module.exports = createInstallTargetAdapter({ ); if (managedDestinationPath) { - return createRemappedOperation( + return [createRemappedOperation( adapter, module.id, sourceRelativePath, managedDestinationPath, { strategy: 'preserve-relative-path' } - ); + )]; } - return adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput); + return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)]; }); }); }, diff --git a/scripts/lib/install-targets/helpers.js b/scripts/lib/install-targets/helpers.js index 9dedcf50a..dbb5b44e5 100644 --- a/scripts/lib/install-targets/helpers.js +++ b/scripts/lib/install-targets/helpers.js @@ -1,6 +1,10 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { + CLAUDE_HOOKS_CONFIG_PATH, + getClaudeSettingsPath, +} = require('../install/claude-settings'); const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({ '.claude-plugin': 'claude', @@ -146,6 +150,42 @@ function createRemappedOperation(adapter, moduleId, sourceRelativePath, destinat }); } +function planClaudeHooksOperations(adapter, module, input) { + const operations = [ + createRemappedOperation( + adapter, + module.id, + CLAUDE_HOOKS_CONFIG_PATH, + getClaudeSettingsPath(adapter.resolveRoot(input)), + { + kind: 'update-claude-settings', + strategy: 'merge-hook-ids', + } + ), + ]; + + if (!input.repoRoot) { + return operations; + } + + const sourceHooksRoot = path.join(input.repoRoot, 'hooks'); + if (!fs.existsSync(sourceHooksRoot)) { + return operations; + } + + return [ + ...operations, + ...fs.readdirSync(sourceHooksRoot, { withFileTypes: true }) + .filter(entry => entry.name !== 'hooks.json') + .sort((left, right) => left.name.localeCompare(right.name)) + .map(entry => adapter.createScaffoldOperation( + module.id, + path.join('hooks', entry.name), + input + )), + ]; +} + function createNamespacedFlatRuleOperations(adapter, moduleId, sourceRelativePath, input = {}) { const normalizedSourcePath = normalizeRelativePath(sourceRelativePath); const sourceRoot = path.join(input.repoRoot || '', normalizedSourcePath); @@ -373,4 +413,5 @@ module.exports = { createRemappedOperation, isForeignPlatformPath, normalizeRelativePath, + planClaudeHooksOperations, }; diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index e755586a8..fbab1293b 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -8,8 +8,18 @@ const { hasExplicitCommitAttributionPreference, withCommitAttributionDisabled, } = require('../claude-commit-attribution'); -const { writeInstallState } = require('../install-state'); +const { readInstallState, writeInstallState } = require('../install-state'); const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); +const { + getClaudeSettingsPath, + mergeManagedHooks, + readSettings, + runWithSettingsLock, + uninstallManagedHooks, + updateSettingsAtomic, + validateManagedHooks, + validateRecordedManagedHooks, +} = require('./claude-settings'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); const { @@ -18,6 +28,11 @@ const { removeLegacyClaudeSkillFiles, } = require('./claude-skill-migration'); const { cleanupLegacyAntigravityInstall } = require('./antigravity-legacy-migration'); +const { + assertNoNewUserOwnedFile, + prepareUserOwnedFileGuard, + preserveUnwrittenFiles, +} = require('./ownership-guard'); const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration'); const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite'); const { adaptAntigravityAgent } = require('./antigravity-agent'); @@ -200,22 +215,12 @@ function shouldSetClaudeCommitAttributionPreference(plan) { }); } -function writeClaudeCommitAttributionPreference(settingsPath) { - // Read once rather than probing with existsSync first. Checking for the file and - // then writing it is a file system race (CodeQL js/file-system-race), and a - // missing file is simply the fresh-install case. +function writeClaudeCommitAttributionPreference(settingsPath, options = {}) { let settings; try { - settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - } catch (error) { - if (error.code !== 'ENOENT') { - // Unreadable or malformed settings belong to the user; leave them untouched. - return false; - } - settings = {}; - } - - if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + settings = readSettings(settingsPath); + } catch (_error) { + // Unreadable or malformed settings belong to the user; leave them untouched. return false; } @@ -223,46 +228,15 @@ function writeClaudeCommitAttributionPreference(settingsPath) { return false; } - fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); - fs.writeFileSync( - settingsPath, - formatJson(withCommitAttributionDisabled(settings)), - 'utf8' - ); - return true; -} - -function replacePluginRootPlaceholders(value, pluginRoot) { - if (!pluginRoot) { - return value; - } - - if (typeof value === 'string') { - return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot); - } - - if (Array.isArray(value)) { - return value.map(item => replacePluginRootPlaceholders(item, pluginRoot)); - } - - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value).map(([key, nestedValue]) => [ - key, - replacePluginRootPlaceholders(nestedValue, pluginRoot), - ]) - ); - } - - return value; -} - -function findHooksOperation(plan, hooksDestinationPath) { - return plan.operations.find(item => ( - item.destinationPath === hooksDestinationPath - && item.moduleId === 'hooks-runtime' - && typeof item.sourcePath === 'string' - )); + let changed = false; + updateSettingsAtomic(settingsPath, latestSettings => { + if (hasExplicitCommitAttributionPreference(latestSettings)) { + return { settings: latestSettings }; + } + changed = true; + return { settings: withCommitAttributionDisabled(latestSettings) }; + }, options); + return changed; } function isMcpConfigPath(filePath) { @@ -302,40 +276,135 @@ function assertSafeInstallOperation(plan, operation) { } } -function buildResolvedClaudeHooks(plan) { - if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) { +function readPreviousInstallState(plan) { + if (!fs.existsSync(plan.installStatePath)) { + return null; + } + return readInstallState(plan.installStatePath); +} + +function comparablePath(filePath) { + const resolved = path.resolve(filePath); + return process.platform === 'win32' ? resolved.toLowerCase() : resolved; +} + +function findPreviousManagedHooks(previousState, plan, operation) { + if ( + !previousState + || previousState.target.id !== plan.adapter.id + || comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot) + || comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath) + ) { return null; } - const pluginRoot = plan.targetRoot; - const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json'); - const hooksOperation = findHooksOperation(plan, hooksDestinationPath); - if (!hooksOperation) { - return null; - } - const hooksSourcePath = hooksOperation.sourcePath; - if (!fs.existsSync(hooksSourcePath)) { + const previousOperation = (previousState.operations || []).find(candidate => ( + candidate.kind === operation.kind + && comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath) + )); + if (!previousOperation || !previousOperation.managedHooks) { return null; } - const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config'); - const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot); - if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) { - throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`); + return validateRecordedManagedHooks( + previousOperation.managedHooks, + 'previous managed hooks' + ); +} + +function preflightClaudeSettingsOperations(plan) { + const settingsOperations = plan.operations.filter(operation => ( + operation.kind === 'update-claude-settings' + || operation.kind === 'remove-claude-settings-hooks' + )); + if (settingsOperations.length === 0) { + return new Map(); } + const previousState = readPreviousInstallState(plan); + return new Map(settingsOperations.map(operation => { + assertSafeInstallOperation(plan, operation); + const managedHooks = validateManagedHooks(operation.managedHooks); + const settings = readSettings(operation.destinationPath); + const previousManagedHooks = findPreviousManagedHooks(previousState, plan, operation); + if (operation.kind === 'remove-claude-settings-hooks') { + const removal = uninstallManagedHooks(settings, managedHooks); + if (removal.retained.length > 0) { + throw new Error( + `Refusing to disable modified Claude hooks in ${operation.destinationPath}; ` + + 'run the ECC uninstaller to review retained entries.' + ); + } + } else { + mergeManagedHooks(settings, managedHooks, { previousManagedHooks }); + } + return [operation, { managedHooks, previousManagedHooks }]; + })); +} + +function prepareHookConsentMigration(plan, migration) { + if (plan.hookConsent !== 'declined') { + return migration; + } + const previousState = readPreviousInstallState(plan); + if (!previousState) { + return migration; + } + + const removals = (previousState.operations || []) + .filter(operation => operation.kind === 'update-claude-settings') + .map(operation => ({ + ...operation, + kind: 'remove-claude-settings-hooks', + strategy: 'remove-hook-ids', + scaffoldOnly: false, + })); + if (removals.length === 0) { + return migration; + } + const removalDestinations = new Set(removals.map(operation => comparablePath( + operation.destinationPath + ))); return { - hooksOperation, - hooksDestinationPath, - resolvedHooksConfig: { - ...hooksConfig, - hooks: resolvedHooks, + ...migration, + // Disable hooks only after every ordinary install operation succeeds so a + // partial reinstall cannot silently revoke working hooks before failing. + appliedOperations: [...migration.appliedOperations, ...removals], + finalState: { + ...migration.finalState, + operations: migration.finalState.operations.filter(operation => !( + operation.kind === 'update-claude-settings' + && removalDestinations.has(comparablePath(operation.destinationPath)) + )), }, + bridgeState: { + ...migration.bridgeState, + request: { + ...migration.bridgeState.request, + hookConsent: 'enabled', + }, + resolution: { + ...migration.bridgeState.resolution, + selectedModules: [...new Set([ + ...migration.bridgeState.resolution.selectedModules, + 'hooks-runtime', + ])], + }, + }, + requiresBridgeState: true, }; } function previewInstallPlan(plan) { - const migration = prepareClaudeSkillMigration(plan); + const migration = prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ); + const appliedPlan = { + ...plan, + operations: migration.appliedOperations, + }; + preflightClaudeSettingsOperations(appliedPlan); const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled' ? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.'] : []; @@ -356,6 +425,23 @@ function previewInstallPlan(plan) { function applyInstallPlan(plan, dependencies = {}) { assertHookConsentReady(plan); + const isClaudeManualTarget = plan.adapter + && (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project'); + const settingsPathToLock = isClaudeManualTarget + ? getClaudeSettingsPath(plan.targetRoot) + : null; + if (settingsPathToLock) { + assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock }); + } + return settingsPathToLock + ? runWithSettingsLock( + settingsPathToLock, + () => applyInstallPlanLocked(plan, dependencies, true) + ) + : applyInstallPlanLocked(plan, dependencies, false); +} + +function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) { const persistInstallState = dependencies.writeInstallState || writeInstallState; const beforeInstallStateRead = dependencies.beforeInstallStateRead; const beforeOperationWrite = dependencies.beforeOperationWrite; @@ -363,30 +449,37 @@ function applyInstallPlan(plan, dependencies = {}) { if (typeof beforeInstallStateRead === 'function') { beforeInstallStateRead({ plan }); } - const migration = prepareClaudeSkillMigration(plan); + const migration = prepareHookConsentMigration( + plan, + prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan)) + ); const appliedPlan = { ...plan, operations: migration.appliedOperations, }; - const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(appliedPlan); + const preparedClaudeSettings = preflightClaudeSettingsOperations(appliedPlan); const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS); const linkIndex = buildLinkIndexForPlan(appliedPlan); const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0; - - if (migration.requiresBridgeState) { - // Own every operation that may be written during a flat-skill migration - // before the first copy. A later failure is retryable and uninstall can - // clean the entire partial install, including non-skill files. During - // legacy migration the bridge also retains the prior managed operations. - if (typeof beforeInstallStateWrite === 'function') { - beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState }); + const hookRemovalCount = appliedPlan.operations.filter(operation => ( + operation.kind === 'remove-claude-settings-hooks' + )).length; + let completedHookRemovalCount = 0; + const writtenDestinations = new Set(); + if (migration.requiresBridgeState) { + // Own every operation that may be written during a flat-skill migration + // before the first copy. A later failure is retryable and uninstall can + // clean the entire partial install, including non-skill files. During + // legacy migration the bridge also retains the prior managed operations. + if (typeof beforeInstallStateWrite === 'function') { + beforeInstallStateWrite({ plan: appliedPlan, state: migration.bridgeState }); + } + persistInstallState(plan.installStatePath, migration.bridgeState); } - persistInstallState(plan.installStatePath, migration.bridgeState); - } - let finalState; - try { - for (const operation of appliedPlan.operations) { + let finalState; + try { + for (const operation of appliedPlan.operations) { assertSafeInstallOperation(appliedPlan, operation); assertSafeClaudeSkillOperation(appliedPlan, operation); fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); @@ -398,6 +491,44 @@ function applyInstallPlan(plan, dependencies = {}) { if (typeof beforeOperationWrite === 'function') { beforeOperationWrite({ plan: appliedPlan, operation }); } + assertNoNewUserOwnedFile(migration, operation); + + if ( + operation.kind === 'update-claude-settings' + || operation.kind === 'remove-claude-settings-hooks' + ) { + // Re-read at the write boundary so unrelated settings added after + // planning are preserved. A same-ID change still fails closed. + const prepared = preparedClaudeSettings.get(operation); + assertSafeInstallOperation(appliedPlan, operation); + updateSettingsAtomic(operation.destinationPath, latestSettings => { + const merged = operation.kind === 'remove-claude-settings-hooks' + ? uninstallManagedHooks(latestSettings, prepared.managedHooks) + : mergeManagedHooks(latestSettings, prepared.managedHooks, { + previousManagedHooks: prepared.previousManagedHooks, + }); + if ( + operation.kind === 'remove-claude-settings-hooks' + && merged.retained.length > 0 + ) { + throw new Error( + `Refusing to disable modified Claude hooks in ${operation.destinationPath}; ` + + 'run the ECC uninstaller to review retained entries.' + ); + } + return merged; + }, { + lockHeld: settingsLockHeld, + beforeCommit() { + assertSafeInstallOperation(appliedPlan, operation); + }, + }); + writtenDestinations.add(operation.destinationPath); + if (operation.kind === 'remove-claude-settings-hooks') { + completedHookRemovalCount += 1; + } + continue; + } if (operation.kind === 'merge-json') { const payload = cloneJsonValue(operation.mergePayload); @@ -417,6 +548,7 @@ function applyInstallPlan(plan, dependencies = {}) { ); const mergedValue = deepMergeJson(currentValue, filteredPayload); fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); + writtenDestinations.add(operation.destinationPath); continue; } @@ -424,6 +556,7 @@ function applyInstallPlan(plan, dependencies = {}) { const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); + writtenDestinations.add(operation.destinationPath); continue; } @@ -446,59 +579,64 @@ function applyInstallPlan(plan, dependencies = {}) { }) : transformed; fs.writeFileSync(operation.destinationPath, installedContent, 'utf8'); + writtenDestinations.add(operation.destinationPath); continue; } fs.copyFileSync(operation.sourcePath, operation.destinationPath); - } - - if (resolvedClaudeHooksPlan) { - assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); - fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true }); - assertSafeInstallOperation(appliedPlan, resolvedClaudeHooksPlan.hooksOperation); - if (typeof beforeOperationWrite === 'function') { - beforeOperationWrite({ plan: appliedPlan, operation: resolvedClaudeHooksPlan.hooksOperation }); + writtenDestinations.add(operation.destinationPath); } - fs.writeFileSync( - resolvedClaudeHooksPlan.hooksDestinationPath, - JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n', - 'utf8' - ); - } - if (hasLegacyMigration) { - removeLegacyClaudeSkillFiles(migration, plan.targetRoot); - } + if (hasLegacyMigration) { + removeLegacyClaudeSkillFiles(migration, plan.targetRoot); + } - if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { - writeClaudeCommitAttributionPreference(path.join(plan.targetRoot, 'settings.json')); - } - - finalState = stateWithContentDigests(migration.finalState, appliedPlan); - if (typeof beforeInstallStateWrite === 'function') { - beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); - } - persistInstallState(plan.installStatePath, finalState); - } catch (error) { - if (migration.requiresBridgeState) { - try { - // The bridge was committed before any writes. Refresh it with hashes of - // files that now exist so uninstall can remove only bytes this attempt - // actually installed while preserving user changes. - persistInstallState( - plan.installStatePath, - stateWithContentDigests(migration.bridgeState, appliedPlan) - ); - } catch (checkpointError) { - throw new Error( - `${error.message} Install-state checkpoint also failed: ${checkpointError.message}`, - { cause: error } + if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) { + writeClaudeCommitAttributionPreference( + getClaudeSettingsPath(plan.targetRoot), + { lockHeld: settingsLockHeld } ); } + + finalState = stateWithContentDigests(migration.finalState, appliedPlan); + if (typeof beforeInstallStateWrite === 'function') { + beforeInstallStateWrite({ plan: appliedPlan, state: finalState }); + } + persistInstallState(plan.installStatePath, finalState); + } catch (error) { + if (migration.requiresBridgeState) { + try { + // The bridge was committed before any writes. Refresh it with hashes of + // files that now exist so uninstall can remove only bytes this attempt + // actually installed while preserving user changes. + persistInstallState( + plan.installStatePath, + stateWithContentDigests( + preserveUnwrittenFiles( + hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount + ? migration.finalState + : migration.bridgeState, + migration, + writtenDestinations + ), + { + ...appliedPlan, + operations: appliedPlan.operations.filter(operation => ( + writtenDestinations.has(operation.destinationPath) + )), + } + ) + ); + } catch (checkpointError) { + throw new Error( + `${error.message} Install-state checkpoint also failed: ${checkpointError.message}`, + { cause: error } + ); + } + } + throw error; } - throw error; - } - let antigravityMigrationWarnings = []; + let antigravityMigrationWarnings = []; try { const antigravityMigration = cleanupLegacyAntigravityInstall(appliedPlan); if (antigravityMigration.detected && !antigravityMigration.complete) { @@ -528,20 +666,20 @@ function applyInstallPlan(plan, dependencies = {}) { ]; } - return { - ...plan, - statePreview: finalState, - plannedOperations: [...plan.operations], - operations: migration.appliedOperations, - skippedOperations: migration.skippedOperations, - warnings: [ - ...(Array.isArray(plan.warnings) ? plan.warnings : []), - ...migration.warnings, - ...antigravityMigrationWarnings, - ...opencodeMigrationWarnings, - ], - applied: true, - }; + return { + ...plan, + statePreview: finalState, + plannedOperations: [...plan.operations], + operations: migration.appliedOperations, + skippedOperations: migration.skippedOperations, + warnings: [ + ...(Array.isArray(plan.warnings) ? plan.warnings : []), + ...migration.warnings, + ...antigravityMigrationWarnings, + ...opencodeMigrationWarnings, + ], + applied: true, + }; } module.exports = { diff --git a/scripts/lib/install/claude-settings-lock.js b/scripts/lib/install/claude-settings-lock.js new file mode 100644 index 000000000..ae413fa01 --- /dev/null +++ b/scripts/lib/install/claude-settings-lock.js @@ -0,0 +1,170 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const INVALID_LOCK_STALE_MS = 5 * 60 * 1000; + +function sameFileIdentity(left, right) { + return left.dev === right.dev && left.ino === right.ino; +} + +function createSettingsLock(lockPath) { + const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`; + let descriptor; + let ownedStats; + try { + descriptor = fs.openSync(tempPath, 'wx', 0o600); + fs.writeFileSync(descriptor, `${JSON.stringify({ + pid: process.pid, + startedAt: new Date().toISOString(), + token: crypto.randomBytes(16).toString('hex'), + })}\n`); + fs.fsyncSync(descriptor); + ownedStats = fs.fstatSync(descriptor, { bigint: true }); + fs.closeSync(descriptor); + descriptor = undefined; + fs.linkSync(tempPath, lockPath); + } catch (error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + fs.rmSync(tempPath, { force: true }); + throw error; + } + fs.rmSync(tempPath, { force: true }); + + let released = false; + return () => { + if (released) return; + const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`; + fs.renameSync(lockPath, quarantinePath); + const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true }); + if (!sameFileIdentity(quarantinedStats, ownedStats)) { + if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath); + throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`); + } + released = true; + fs.rmSync(quarantinePath, { force: true }); + }; +} + +function inspectSettingsLock(lockPath) { + const descriptor = fs.openSync(lockPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const stats = fs.fstatSync(descriptor, { bigint: true }); + const pathStats = fs.lstatSync(lockPath, { bigint: true }); + if ( + !stats.isFile() + || pathStats.isSymbolicLink() + || !pathStats.isFile() + || !sameFileIdentity(stats, pathStats) + ) { + return { metadata: null, stats }; + } + let metadata = null; + try { + metadata = JSON.parse(fs.readFileSync(descriptor, 'utf8')); + } catch (_error) { + // Invalid locks may be recovered only after the bounded lease below. + } + return { metadata, stats }; + } finally { + fs.closeSync(descriptor); + } +} + +function processIsAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== 'ESRCH'; + } +} + +function recoverSettingsLock(lockPath) { + const recoveryPath = `${lockPath}.recover`; + try { + fs.mkdirSync(recoveryPath, { mode: 0o700 }); + } catch (error) { + if (error && error.code === 'EEXIST') return null; + throw error; + } + + const quarantinePath = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(8).toString('hex')}`; + try { + let inspected; + try { + inspected = inspectSettingsLock(lockPath); + } catch (error) { + if (error && error.code === 'ENOENT') return createSettingsLock(lockPath); + throw error; + } + const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid) + && inspected.metadata.pid > 0; + const stale = validOwner + ? !processIsAlive(inspected.metadata.pid) + : Date.now() - Number(inspected.stats.mtimeMs) >= INVALID_LOCK_STALE_MS; + if (!stale) return null; + + fs.renameSync(lockPath, quarantinePath); + const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true }); + if (!sameFileIdentity(quarantinedStats, inspected.stats)) { + if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath); + return null; + } + fs.rmSync(quarantinePath, { force: true }); + return createSettingsLock(lockPath); + } finally { + fs.rmSync(recoveryPath, { recursive: true, force: true }); + fs.rmSync(quarantinePath, { force: true }); + } +} + +function acquireSettingsLock(settingsPath) { + const lockPath = `${settingsPath}.ecc.lock`; + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + try { + return createSettingsLock(lockPath); + } catch (error) { + if (!error || error.code !== 'EEXIST') { + throw error; + } + } + const recovered = recoverSettingsLock(lockPath); + if (recovered) return recovered; + throw new Error( + `Another ECC process is updating Claude settings: ${settingsPath}. ` + + `If no ECC process is active, inspect and remove ${lockPath}.` + ); +} + +function runWithSettingsLock(settingsPath, callback) { + const releaseLock = acquireSettingsLock(settingsPath); + let primaryError = null; + let result; + try { + result = callback(); + } catch (error) { + primaryError = error; + } + + let releaseError = null; + try { + releaseLock(); + } catch (error) { + releaseError = error; + } + + if (primaryError) { + if (releaseError) primaryError.releaseError = releaseError; + throw primaryError; + } + if (releaseError) throw releaseError; + return result; +} + +module.exports = { + acquireSettingsLock, + runWithSettingsLock, +}; diff --git a/scripts/lib/install/claude-settings.js b/scripts/lib/install/claude-settings.js new file mode 100644 index 000000000..7075c5bd9 --- /dev/null +++ b/scripts/lib/install/claude-settings.js @@ -0,0 +1,699 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { isDeepStrictEqual } = require('util'); +const { writeFileAtomic } = require('../atomic-write'); +const { acquireSettingsLock, runWithSettingsLock } = require('./claude-settings-lock'); + +const CLAUDE_SETTINGS_FILENAME = 'settings.json'; +const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json'; +const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}'; +const PLUGIN_ROOT_ENV_PROLOGUE = 'var e=process.env.CLAUDE_PLUGIN_ROOT;'; +const PLUGIN_ROOT_ENV_READ = /\bprocess\.env\.CLAUDE_PLUGIN_ROOT\b(?!\s*=)/; +const VALID_EVENTS = new Set([ + 'SessionStart', 'UserPromptSubmit', 'PreToolUse', 'PermissionRequest', + 'PostToolUse', 'PostToolUseFailure', 'Notification', 'SubagentStart', + 'Stop', 'SubagentStop', 'PreCompact', 'InstructionsLoaded', + 'TeammateIdle', 'TaskCompleted', 'ConfigChange', 'WorktreeCreate', + 'WorktreeRemove', 'SessionEnd', +]); +const EVENTS_WITHOUT_MATCHER = new Set([ + 'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop', +]); +const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']); + +function isJsonObject(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function cloneValue(value) { + if (Array.isArray(value)) { + return value.map(cloneValue); + } + if (isJsonObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [key, cloneValue(nestedValue)]) + ); + } + return value; +} + +function isNonEmptyString(value) { + return typeof value === 'string' && value.trim() !== ''; +} + +function getClaudeSettingsPath(targetRoot) { + return path.join(targetRoot, CLAUDE_SETTINGS_FILENAME); +} + +function assertClaudeSettingsPath(destinationPath, trustedRoot) { + const resolvedDestination = path.resolve(destinationPath); + const resolvedExpected = path.resolve(getClaudeSettingsPath(trustedRoot)); + const pathsMatch = process.platform === 'win32' + ? resolvedDestination.toLowerCase() === resolvedExpected.toLowerCase() + : resolvedDestination === resolvedExpected; + if (!pathsMatch) { + throw new Error( + `Refusing to manage Claude hooks outside the canonical settings file: ${destinationPath}` + ); + } +} + +function validateHookHandler(hook, label) { + if (!isJsonObject(hook)) { + throw new Error(`Invalid managed hook handler at ${label}: expected a JSON object`); + } + if (!VALID_HOOK_TYPES.has(hook.type)) { + throw new Error(`Invalid managed hook handler at ${label}: unsupported type`); + } + if (hook.timeout !== undefined && (typeof hook.timeout !== 'number' || hook.timeout < 0)) { + throw new Error(`Invalid managed hook handler at ${label}: invalid timeout`); + } + + if (hook.type === 'command') { + const validCommand = isNonEmptyString(hook.command) + || (Array.isArray(hook.command) + && hook.command.length > 0 + && hook.command.every(isNonEmptyString)); + if (!validCommand) { + throw new Error(`Invalid managed hook handler at ${label}: invalid command`); + } + if (hook.async !== undefined && typeof hook.async !== 'boolean') { + throw new Error(`Invalid managed hook handler at ${label}: invalid async flag`); + } + return; + } + + if (hook.async !== undefined) { + throw new Error(`Invalid managed hook handler at ${label}: async requires command type`); + } + if (hook.type === 'http') { + if (!isNonEmptyString(hook.url)) { + throw new Error(`Invalid managed hook handler at ${label}: invalid url`); + } + if ( + hook.headers !== undefined + && (!isJsonObject(hook.headers) + || !Object.values(hook.headers).every(value => typeof value === 'string')) + ) { + throw new Error(`Invalid managed hook handler at ${label}: invalid headers`); + } + if ( + hook.allowedEnvVars !== undefined + && (!Array.isArray(hook.allowedEnvVars) + || !hook.allowedEnvVars.every(isNonEmptyString)) + ) { + throw new Error(`Invalid managed hook handler at ${label}: invalid allowedEnvVars`); + } + return; + } + if (!isNonEmptyString(hook.prompt)) { + throw new Error(`Invalid managed hook handler at ${label}: invalid prompt`); + } + if (hook.model !== undefined && !isNonEmptyString(hook.model)) { + throw new Error(`Invalid managed hook handler at ${label}: invalid model`); + } +} + +function validateManagedHooks(managedHooks, label = 'managed hooks') { + if (!isJsonObject(managedHooks)) { + throw new Error(`Invalid ${label}: expected a JSON object`); + } + if (Object.keys(managedHooks).length === 0) { + throw new Error(`Invalid ${label}: expected at least one hook event`); + } + + const seenIds = new Set(); + for (const [event, entries] of Object.entries(managedHooks)) { + if (!VALID_EVENTS.has(event)) { + throw new Error(`Invalid ${label}: unsupported hook event "${event}"`); + } + if (!Array.isArray(entries)) { + throw new Error(`Invalid ${label}.${event}: expected an array`); + } + if (entries.length === 0) { + throw new Error(`Invalid ${label}.${event}: expected at least one hook entry`); + } + + entries.forEach((entry, index) => { + if (!isJsonObject(entry)) { + throw new Error( + `Invalid managed hook entry at ${label}.${event}[${index}]: expected a JSON object` + ); + } + if (typeof entry.id !== 'string' || entry.id.trim() === '') { + throw new Error( + `Invalid managed hook entry at ${label}.${event}[${index}]: ` + + 'expected a non-empty unique id' + ); + } + if (seenIds.has(entry.id)) { + throw new Error(`Invalid ${label}: expected globally unique id "${entry.id}"`); + } + seenIds.add(entry.id); + if ( + !Object.prototype.hasOwnProperty.call(entry, 'matcher') + && !EVENTS_WITHOUT_MATCHER.has(event) + ) { + throw new Error( + `Invalid managed hook entry at ${label}.${event}[${index}]: missing matcher` + ); + } + if ( + Object.prototype.hasOwnProperty.call(entry, 'matcher') + && typeof entry.matcher !== 'string' + && !isJsonObject(entry.matcher) + ) { + throw new Error( + `Invalid managed hook entry at ${label}.${event}[${index}]: invalid matcher` + ); + } + if (!Array.isArray(entry.hooks) || entry.hooks.length === 0) { + throw new Error( + `Invalid managed hook entry at ${label}.${event}[${index}]: expected hooks` + ); + } + entry.hooks.forEach((hook, hookIndex) => { + validateHookHandler(hook, `${label}.${event}[${index}].hooks[${hookIndex}]`); + }); + }); + } + + return cloneValue(managedHooks); +} + +function validateRecordedManagedHooks(managedHooks, label = 'recorded managed hooks') { + if (!isJsonObject(managedHooks) || Object.keys(managedHooks).length === 0) { + throw new Error(`Invalid ${label}: expected a non-empty JSON object`); + } + for (const [event, entries] of Object.entries(managedHooks)) { + if (!isNonEmptyString(event) || !Array.isArray(entries) || entries.length === 0) { + throw new Error(`Invalid ${label}.${event}: expected a non-empty hook array`); + } + const seenIds = new Set(); + entries.forEach((entry, index) => { + if (!isJsonObject(entry) || !isNonEmptyString(entry.id) || !Array.isArray(entry.hooks)) { + throw new Error(`Invalid hook entry at ${label}.${event}[${index}]`); + } + if (seenIds.has(entry.id)) { + throw new Error( + `Invalid ${label}: expected unique id "${entry.id}" within event "${event}"` + ); + } + seenIds.add(entry.id); + }); + } + return cloneValue(managedHooks); +} + +function validateSettings(settings, label = 'Claude settings') { + if (!isJsonObject(settings)) { + throw new Error(`Invalid ${label}: expected a JSON object`); + } + + if (Object.prototype.hasOwnProperty.call(settings, 'hooks')) { + if (!isJsonObject(settings.hooks)) { + throw new Error(`Invalid ${label}: expected "hooks" to be a JSON object`); + } + for (const [event, entries] of Object.entries(settings.hooks)) { + if (!Array.isArray(entries)) { + throw new Error(`Invalid ${label}: expected hooks.${event} to be an array`); + } + } + } + + return cloneValue(settings); +} + +function replacePluginRootPlaceholders(value, pluginRoot) { + if (typeof pluginRoot !== 'string') { + throw new Error('Invalid Claude plugin root: expected a string'); + } + if (typeof value === 'string') { + return value.split(PLUGIN_ROOT_PLACEHOLDER).join(pluginRoot); + } + if (Array.isArray(value)) { + return value.map(item => replacePluginRootPlaceholders(item, pluginRoot)); + } + if (isJsonObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, nestedValue]) => [ + key, + replacePluginRootPlaceholders(nestedValue, pluginRoot), + ]) + ); + } + return value; +} + +function resolveManagedHookCommands(managedHooks, targetRoot) { + const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64'); + const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`; + const resolveCommand = command => { + // Leave invalid entries intact so managed-hook validation reports them. + if (typeof command !== 'string') { + return command; + } + const resolved = command + .split(PLUGIN_ROOT_ENV_PROLOGUE) + .join(`var e=${rootExpression};`); + if (PLUGIN_ROOT_ENV_READ.test(resolved)) { + throw new Error( + 'Unable to resolve CLAUDE_PLUGIN_ROOT in a managed hook command; ' + + 'the hooks.json command prologue no longer matches the expected form' + ); + } + return resolved; + }; + return Object.fromEntries( + Object.entries(managedHooks).map(([event, entries]) => [ + event, + entries.map(entry => ({ + ...entry, + hooks: entry.hooks.map(hook => ({ + ...hook, + ...(typeof hook.command === 'string' || Array.isArray(hook.command) + ? { + command: Array.isArray(hook.command) + ? hook.command.map(resolveCommand) + : resolveCommand(hook.command), + } + : {}), + })), + })), + ]) + ); +} + +function materializeManagedHooks(hooksConfig, targetRoot) { + if (!isJsonObject(hooksConfig) || !isJsonObject(hooksConfig.hooks)) { + throw new Error('Invalid hooks config: expected a JSON object with a hooks object'); + } + if (!isNonEmptyString(targetRoot)) { + throw new Error('Invalid Claude target root: expected a non-empty string'); + } + return validateManagedHooks(resolveManagedHookCommands( + replacePluginRootPlaceholders(hooksConfig.hooks, targetRoot), + targetRoot + )); +} + +function parseSettings(rawSettings, label = 'Claude settings') { + let settings; + try { + settings = JSON.parse(rawSettings); + } catch (error) { + throw new Error(`Failed to parse ${label}: ${error.message}`, { cause: error }); + } + return validateSettings(settings, label); +} + +function readSettings(settingsPath, fileSystem = fs) { + const reader = fileSystem && fileSystem.fs ? fileSystem.fs : fileSystem; + let rawSettings; + try { + rawSettings = reader.readFileSync(settingsPath, 'utf8'); + } catch (error) { + if (error && error.code === 'ENOENT') { + return {}; + } + throw error; + } + return parseSettings(rawSettings, `Claude settings at ${settingsPath}`); +} + +function readSettingsSnapshot(settingsPath) { + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + let descriptor; + try { + descriptor = fs.openSync(settingsPath, flags); + } catch (error) { + if (error && error.code === 'ENOENT') { + return { exists: false, raw: null, settings: {}, mode: 0o600 }; + } + throw error; + } + + try { + const descriptorStat = fs.fstatSync(descriptor); + let pathStat; + try { + pathStat = fs.lstatSync(settingsPath); + } catch (error) { + if (error && error.code === 'ENOENT') { + error.code = 'ECC_SETTINGS_CHANGED'; + } + throw error; + } + if ( + !descriptorStat.isFile() + || !pathStat.isFile() + || pathStat.isSymbolicLink() + || descriptorStat.dev !== pathStat.dev + || descriptorStat.ino !== pathStat.ino + ) { + const error = new Error(`Refusing to read changed Claude settings at ${settingsPath}`); + error.code = 'ECC_SETTINGS_CHANGED'; + throw error; + } + const raw = fs.readFileSync(descriptor, 'utf8'); + return { + exists: true, + raw, + settings: parseSettings(raw, `Claude settings at ${settingsPath}`), + mode: descriptorStat.mode & 0o777, + dev: descriptorStat.dev, + ino: descriptorStat.ino, + }; + } finally { + fs.closeSync(descriptor); + } +} + +function assertSettingsSnapshotUnchanged(settingsPath, snapshot) { + let current; + try { + current = readSettingsSnapshot(settingsPath); + } catch (error) { + error.code = error.code || 'ECC_SETTINGS_CHANGED'; + throw error; + } + const unchanged = current.exists === snapshot.exists + && current.raw === snapshot.raw + && (!current.exists || (current.dev === snapshot.dev && current.ino === snapshot.ino)); + if (!unchanged) { + const error = new Error(`Claude settings changed during update: ${settingsPath}`); + error.code = 'ECC_SETTINGS_CHANGED'; + throw error; + } +} + +function updateSettingsAtomic(settingsPath, transform, options = {}) { + const update = () => { + const parentPath = path.dirname(path.resolve(settingsPath)); + const parentStats = fs.lstatSync(parentPath, { bigint: true }); + const validateParent = () => { + const current = fs.lstatSync(parentPath, { bigint: true }); + if ( + !current.isDirectory() || current.isSymbolicLink() + || current.dev !== parentStats.dev || current.ino !== parentStats.ino + ) { + const error = new Error(`Claude settings parent directory changed: ${parentPath}`); + error.code = 'ECC_SETTINGS_PARENT_CHANGED'; + throw error; + } + }; + const maxAttempts = options.maxAttempts || 3; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + validateParent(); + const snapshot = readSettingsSnapshot(settingsPath); + const result = transform(snapshot.settings); + if (typeof options.beforeCommit === 'function') options.beforeCommit(); + assertSettingsSnapshotUnchanged(settingsPath, snapshot); + writeFileAtomic( + settingsPath, + `${JSON.stringify(result.settings, null, 2)}\n`, + { + encoding: 'utf8', + mode: snapshot.mode, + validateParent, + beforeRename() { + assertSettingsSnapshotUnchanged(settingsPath, snapshot); + }, + } + ); + return result; + } catch (error) { + if (error.code !== 'ECC_SETTINGS_CHANGED' || attempt === maxAttempts) { + throw error; + } + } + } + throw new Error(`Unable to update Claude settings at ${settingsPath}`); + }; + if (options.lockHeld) { + return update(); + } + return runWithSettingsLock(settingsPath, update); +} + +function reference(event, id) { + return { event, id }; +} + +function entriesMatchingId(entries, id) { + return entries + .map((entry, index) => ({ entry, index })) + .filter(candidate => isJsonObject(candidate.entry) && candidate.entry.id === id); +} + +function assertUnambiguousMatch(entries, event, id) { + const matches = entriesMatchingId(entries, id); + if (matches.length > 1) { + throw new Error( + `Claude settings contains multiple hooks for event "${event}" and id "${id}"` + ); + } + return matches[0] || null; +} + +function managedEntryFor(managedHooks, event, id) { + const entries = managedHooks && managedHooks[event]; + if (!Array.isArray(entries)) { + return null; + } + return entries.find(entry => entry.id === id) || null; +} + +function mergeManagedHooks(settings, managedHooks, options = {}) { + const validatedSettings = validateSettings(settings); + const desiredHooks = validateManagedHooks(managedHooks); + const previousHooks = options.previousManagedHooks === undefined + || options.previousManagedHooks === null + ? null + : validateRecordedManagedHooks(options.previousManagedHooks, 'previous managed hooks'); + const repair = options.mode === 'repair' || options.repair === true; + if (options.mode !== undefined && options.mode !== 'merge' && options.mode !== 'repair') { + throw new Error(`Unknown Claude settings merge mode: ${options.mode}`); + } + + let nextHooks = validatedSettings.hooks + ? cloneValue(validatedSettings.hooks) + : {}; + const added = []; + const updated = []; + const unchanged = []; + const removed = []; + + if (previousHooks) { + for (const [event, previousEntries] of Object.entries(previousHooks)) { + let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : []; + for (const previousEntry of previousEntries) { + if (managedEntryFor(desiredHooks, event, previousEntry.id)) continue; + const match = assertUnambiguousMatch(eventEntries, event, previousEntry.id); + if (!match) continue; + if (!isDeepStrictEqual(match.entry, previousEntry)) { + throw new Error( + `Refusing to remove Claude hook for event "${event}" and id ` + + `"${previousEntry.id}" because the previous managed entry has drifted` + ); + } + eventEntries = eventEntries.filter((_entry, index) => index !== match.index); + removed.push(reference(event, previousEntry.id)); + } + nextHooks = eventEntries.length > 0 + ? { ...nextHooks, [event]: eventEntries } + : withoutProperty(nextHooks, event); + } + } + + for (const [event, desiredEntries] of Object.entries(desiredHooks)) { + let eventEntries = nextHooks[event] ? cloneValue(nextHooks[event]) : []; + for (const desiredEntry of desiredEntries) { + const match = assertUnambiguousMatch(eventEntries, event, desiredEntry.id); + if (!match) { + eventEntries = [...eventEntries, cloneValue(desiredEntry)]; + added.push(reference(event, desiredEntry.id)); + continue; + } + if (isDeepStrictEqual(match.entry, desiredEntry)) { + unchanged.push(reference(event, desiredEntry.id)); + continue; + } + + const previousEntry = managedEntryFor(previousHooks, event, desiredEntry.id); + if (!repair && (!previousEntry || !isDeepStrictEqual(match.entry, previousEntry))) { + const driftReason = previousEntry ? ' because the previous managed entry has drifted' : ''; + throw new Error( + `Refusing to overwrite Claude hook for event "${event}" and id ` + + `"${desiredEntry.id}"${driftReason}` + ); + } + + eventEntries = eventEntries.map((entry, index) => ( + index === match.index ? cloneValue(desiredEntry) : entry + )); + updated.push(reference(event, desiredEntry.id)); + } + if (desiredEntries.length > 0) { + nextHooks = { ...nextHooks, [event]: eventEntries }; + } + } + + const nextSettings = Object.keys(nextHooks).length > 0 + ? { ...validatedSettings, hooks: nextHooks } + : validatedSettings; + return { + settings: nextSettings, + managedHooks: cloneValue(desiredHooks), + added, + updated, + unchanged, + removed, + }; +} + +function repairManagedHooks(settings, managedHooks, options = {}) { + return mergeManagedHooks(settings, managedHooks, { + ...options, + mode: 'repair', + }); +} + +function inspectManagedHooks(settings, managedHooks) { + const validatedSettings = validateSettings(settings); + const expectedHooks = validateManagedHooks(managedHooks); + const settingsHooks = validatedSettings.hooks || {}; + const managedSubset = {}; + const matched = []; + const missing = []; + const drifted = []; + + for (const [event, expectedEntries] of Object.entries(expectedHooks)) { + const actualEntries = settingsHooks[event] || []; + const foundEntries = []; + for (const expectedEntry of expectedEntries) { + const match = assertUnambiguousMatch(actualEntries, event, expectedEntry.id); + if (!match) { + missing.push(reference(event, expectedEntry.id)); + continue; + } + + foundEntries.push(cloneValue(match.entry)); + if (isDeepStrictEqual(match.entry, expectedEntry)) { + matched.push(reference(event, expectedEntry.id)); + } else { + drifted.push({ + ...reference(event, expectedEntry.id), + expected: cloneValue(expectedEntry), + actual: cloneValue(match.entry), + }); + } + } + if (foundEntries.length > 0) { + managedSubset[event] = foundEntries; + } + } + + const ok = missing.length === 0 && drifted.length === 0; + return { + status: ok ? 'ok' : (missing.length > 0 ? 'missing' : 'drifted'), + ok, + managedHooks: managedSubset, + matched, + missing, + drifted, + }; +} + +function withoutProperty(object, omittedKey) { + return Object.fromEntries( + Object.entries(object).filter(([key]) => key !== omittedKey) + ); +} + +function uninstallManagedHooks(settings, recordedManagedHooks) { + const validatedSettings = validateSettings(settings); + const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks); + const currentHooks = validatedSettings.hooks || {}; + + for (const [event, recordedEntries] of Object.entries(recordedHooks)) { + const eventEntries = currentHooks[event] || []; + for (const recordedEntry of recordedEntries) { + assertUnambiguousMatch(eventEntries, event, recordedEntry.id); + } + } + + const removed = []; + const retained = []; + const missing = []; + let nextHooks = cloneValue(currentHooks); + + for (const [event, recordedEntries] of Object.entries(recordedHooks)) { + let eventEntries = nextHooks[event] || []; + for (const recordedEntry of recordedEntries) { + const match = assertUnambiguousMatch(eventEntries, event, recordedEntry.id); + if (!match) { + missing.push(reference(event, recordedEntry.id)); + continue; + } + if (!isDeepStrictEqual(match.entry, recordedEntry)) { + retained.push({ + ...reference(event, recordedEntry.id), + expected: cloneValue(recordedEntry), + actual: cloneValue(match.entry), + reason: 'modified', + }); + continue; + } + + eventEntries = eventEntries.filter((_entry, index) => index !== match.index); + removed.push(reference(event, recordedEntry.id)); + } + nextHooks = eventEntries.length > 0 + ? { ...nextHooks, [event]: eventEntries } + : withoutProperty(nextHooks, event); + } + + nextHooks = Object.fromEntries( + Object.entries(nextHooks).filter(([, entries]) => entries.length > 0) + ); + const settingsWithoutHooks = withoutProperty(validatedSettings, 'hooks'); + const nextSettings = Object.keys(nextHooks).length > 0 + ? { ...settingsWithoutHooks, hooks: nextHooks } + : settingsWithoutHooks; + + return { + settings: nextSettings, + removed, + retained, + missing, + }; +} + +module.exports = { + CLAUDE_HOOKS_CONFIG_PATH, + CLAUDE_SETTINGS_FILENAME, + acquireSettingsLock, + assertClaudeSettingsPath, + getClaudeSettingsPath, + inspectManagedHooks, + materializeManagedHooks, + mergeManagedHooks, + parseSettings, + readSettings, + repairManagedHooks, + replacePluginRootPlaceholders, + runWithSettingsLock, + updateSettingsAtomic, + uninstallManagedHooks, + validateManagedHooks, + validateRecordedManagedHooks, + validateSettings, +}; diff --git a/scripts/lib/install/config.js b/scripts/lib/install/config.js index 2ba012267..32c1b47a9 100644 --- a/scripts/lib/install/config.js +++ b/scripts/lib/install/config.js @@ -2,7 +2,6 @@ const fs = require('fs'); const path = require('path'); -const Ajv = require('ajv'); const DEFAULT_INSTALL_CONFIG = 'ecc-install.json'; const CONFIG_SCHEMA_PATH = path.join(__dirname, '..', '..', '..', 'schemas', 'ecc-install-config.schema.json'); @@ -22,6 +21,9 @@ function getValidator() { return cachedValidator; } + // ajv is required lazily so scripts that never load an install config (the + // common case, e.g. `--list-profiles`) don't need it on the require path. + const Ajv = require('ajv'); const schema = readJson(CONFIG_SCHEMA_PATH, 'ecc-install-config.schema.json'); const ajv = new Ajv({ allErrors: true }); cachedValidator = ajv.compile(schema); diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index f12bd833c..883c121d7 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -44,7 +44,10 @@ function normalizeOperationPath(value) { } function isHookRuntimeOperation(operation = {}) { - if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) { + if ( + operation.kind === 'update-claude-settings' + || operation.moduleId === HOOK_RUNTIME_MODULE_ID + ) { return true; } diff --git a/scripts/lib/install/ownership-guard.js b/scripts/lib/install/ownership-guard.js new file mode 100644 index 000000000..962c62e88 --- /dev/null +++ b/scripts/lib/install/ownership-guard.js @@ -0,0 +1,159 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +const { readInstallState } = require('../install-state'); + +function pathExists(filePath) { + try { + fs.lstatSync(filePath); + return true; + } catch (error) { + if (error && error.code === 'ENOENT') { + return false; + } + throw error; + } +} + +function comparablePath(filePath) { + const resolvedPath = path.resolve(filePath); + return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath; +} + +/** + * #2964: the shared copy path used to write every copy-file operation + * unconditionally and record the destination as `ownership: 'managed'` even + * when the file already existed and was authored by the user. The visible + * symptom is a lost edit; the dangerous one is the install-state record, + * which makes a later uninstall delete the user's file. + * + * This guard generalises the Claude flat-skill migration conflict pattern to + * every adapter copy operation: when a destination exists and is NOT recorded + * as an ECC-managed operation in the previous install-state, the operation is + * skipped with a warning instead of overwriting and claiming ownership. + * + * All managed targets share this ownership boundary (#2964). + */ +function prepareUserOwnedFileGuard(plan, migration) { + const previousState = pathExists(plan.installStatePath) + ? readInstallState(plan.installStatePath) + : null; + if (previousState && ( + previousState.target.id !== plan.adapter.id + || comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot) + || comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath) + )) { + throw new Error(`Refusing install: install-state target does not match the current plan at ${plan.installStatePath}.`); + } + // Recorded files remain updateable by reinstall/repair. Preserve their prior + // digests if an attempt fails before writing them so uninstall detects drift. + const previousManagedOperations = new Map( + ((previousState && previousState.operations) || []) + .filter(operation => ( + operation + && operation.ownership === 'managed' + && operation.destinationPath + )) + .map(operation => [comparablePath(operation.destinationPath), operation]) + ); + const managedDestinations = new Set(previousManagedOperations.keys()); + + const appliedOperations = []; + const skippedOperations = []; + const warnings = []; + for (const operation of (migration && migration.appliedOperations) || []) { + if ( + operation + && operation.kind === 'copy-file' + && operation.destinationPath + && pathExists(operation.destinationPath) + && !managedDestinations.has(comparablePath(operation.destinationPath)) + ) { + skippedOperations.push(operation); + warnings.push( + `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.` + ); + continue; + } + appliedOperations.push(operation); + } + + if (skippedOperations.length === 0) { + return { ...migration, managedDestinations, previousManagedOperations }; + } + + const skippedDestinations = new Set( + skippedOperations.map(operation => comparablePath(operation.destinationPath)) + ); + const filterStateOperations = operations => (operations || []) + .filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath))); + + // Never leave a skipped destination inside the install-state: recording it + // would claim ownership of a file ECC did not create and make uninstall + // delete it (#2964). + const bridgeState = migration.bridgeState + ? { + ...migration.bridgeState, + operations: filterStateOperations(migration.bridgeState.operations), + } + : migration.bridgeState; + const finalState = migration.finalState + ? { + ...migration.finalState, + operations: filterStateOperations(migration.finalState.operations), + } + : migration.finalState; + + return { + ...migration, + managedDestinations, + previousManagedOperations, + appliedOperations, + skippedOperations: [ + ...((migration && migration.skippedOperations) || []), + ...skippedOperations, + ], + warnings: [...((migration && migration.warnings) || []), ...warnings], + bridgeState, + finalState, + // Only keep bridge persistence when operations actually remain; a fully + // skipped plan installs nothing and must not claim anything. + requiresBridgeState: Boolean(migration.requiresBridgeState) + && appliedOperations.length > 0, + }; +} + +function assertNoNewUserOwnedFile(migration, operation) { + if (operation.kind !== 'copy-file' + || migration.managedDestinations.has(comparablePath(operation.destinationPath)) + || !pathExists(operation.destinationPath)) { + return; + } + throw new Error(`Refusing install: a user-owned file appeared at ${operation.destinationPath} after planning. Rerun the installer to preserve it.`); +} + +function preserveUnwrittenFiles(state, migration, writtenDestinations) { + const writtenPaths = new Set([...writtenDestinations].map(comparablePath)); + return { + ...state, + operations: state.operations.filter(operation => ( + operation.kind !== 'copy-file' + || migration.managedDestinations.has(comparablePath(operation.destinationPath)) + || writtenPaths.has(comparablePath(operation.destinationPath)) + || !pathExists(operation.destinationPath) + )).map(operation => { + const destination = comparablePath(operation.destinationPath); + return operation.kind === 'copy-file' && !writtenPaths.has(destination) + ? migration.previousManagedOperations.get(destination) || operation + : operation; + }), + }; +} + +module.exports = { + assertNoNewUserOwnedFile, + prepareUserOwnedFileGuard, + preserveUnwrittenFiles, +}; diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index d98ef8f0b..08173a672 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -7,6 +7,9 @@ const { execFileSync } = require('child_process'); const { resolveInstallPlan } = require('../install-manifests'); const { getInstallTargetAdapter } = require('../install-targets/registry'); const { resolveInvocationEnvironment } = require('../invocation-environment'); +const { + materializeManagedHooks, +} = require('./claude-settings'); const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json']; const IGNORED_DIRECTORY_NAMES = new Set([ @@ -127,7 +130,31 @@ function readJsonObject(filePath, label) { return parsed; } +function materializeClaudeSettingsOperation(sourceRoot, operation) { + const sourcePath = path.join(sourceRoot, operation.sourceRelativePath); + if (!fs.existsSync(sourcePath)) { + return []; + } + + const hooksConfig = readJsonObject(sourcePath, operation.sourceRelativePath); + const managedHooks = materializeManagedHooks( + hooksConfig, + path.dirname(operation.destinationPath) + ); + + return [{ + ...operation, + sourcePath, + scaffoldOnly: false, + managedHooks, + }]; +} + function materializeScaffoldOperation(sourceRoot, operation) { + if (operation.kind === 'update-claude-settings') { + return materializeClaudeSettingsOperation(sourceRoot, operation); + } + if (operation.kind === 'merge-json') { return [ { diff --git a/scripts/lib/missing-dependency.js b/scripts/lib/missing-dependency.js new file mode 100644 index 000000000..d75334714 --- /dev/null +++ b/scripts/lib/missing-dependency.js @@ -0,0 +1,65 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +// Runtime dependencies that can be missing when ECC is installed via the +// Claude Code plugin marketplace (a plain git clone, so `npm install` never +// runs), even though the code that needs them is fine. Versions are read +// straight from package.json's "dependencies" field instead of a second +// hardcoded copy, so this can't silently drift out of sync with what's +// actually declared there. +const TRACKED_DEPENDENCIES = ['ajv', 'sql.js', 'js-yaml', '@iarna/toml']; + +function loadRuntimeDependencyVersions() { + try { + const packageJsonPath = path.join(__dirname, '..', '..', 'package.json'); + const declared = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).dependencies || {}; + + const versions = {}; + for (const name of TRACKED_DEPENDENCIES) { + const declaredVersion = declared[name]; + if (declaredVersion) { + versions[name] = declaredVersion.replace(/^[\^~]/, ''); + } + } + return versions; + } catch { + // package.json isn't reachable from here for some reason. Fall back to + // an empty map rather than crash — describeMissingDependencyError() + // just won't be able to suggest a pinned version in that case. + return {}; + } +} + +const RUNTIME_DEPENDENCY_VERSIONS = loadRuntimeDependencyVersions(); + +function describeMissingDependencyError(error) { + if (!error || error.code !== 'MODULE_NOT_FOUND') { + return null; + } + + const match = /Cannot find module '([^']+)'/.exec(error.message || ''); + const moduleName = match && match[1]; + + if (!moduleName || !TRACKED_DEPENDENCIES.includes(moduleName)) { + return null; + } + + const pinnedVersion = RUNTIME_DEPENDENCY_VERSIONS[moduleName]; + const installCommand = pinnedVersion + ? `npm install --no-save ${moduleName}@${pinnedVersion}` + : `npm install --no-save ${moduleName}`; + + return ( + `Missing dependency '${moduleName}'. ECC's production dependencies aren't installed ` + + '(this happens when ECC was installed via the Claude Code plugin marketplace, which ' + + 'clones the repo but never runs npm install). Run "npm install" from the ECC repo ' + + `root, or install just this package with "${installCommand}".` + ); +} + +module.exports = { + RUNTIME_DEPENDENCY_VERSIONS, + describeMissingDependencyError, +}; diff --git a/scripts/lib/multi-harness-setup.js b/scripts/lib/multi-harness-setup.js index 30b9d469d..141eb4206 100644 --- a/scripts/lib/multi-harness-setup.js +++ b/scripts/lib/multi-harness-setup.js @@ -373,7 +373,12 @@ async function applyPreflightedManagedPlan(entry) { : preflightManagedPlan(entry.preview.plan); const ownedDestinations = new Set(preview.ownershipSnapshot.destinations); let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint; - let operationIndex = 0; + // Several ordered JSON merges may share one destination. Preserve each + // operation's preview instead of collapsing that sequence to one path entry. + const expectedOperations = new Map(preview.plan.operations.map((operation, index) => [ + operation, preview.operations[index], + ])); + const writtenDestinations = new Set(); const assertStateUnchanged = () => ( assertInstallStateUnchanged(preview.plan, expectedStateFingerprint) ); @@ -381,26 +386,41 @@ async function applyPreflightedManagedPlan(entry) { assertStateUnchanged(); expectedStateFingerprint = fingerprintInstallStateValue(state); }; + const assertOperationUnchanged = operation => { + const destination = canonicalPath(operation.destinationPath); + const expected = expectedOperations.get(operation); + const currentClassification = classifyManagedOperation(operation, ownedDestinations); + const expectedClassification = operation.kind === 'merge-json' + && writtenDestinations.has(destination) + ? 'managed-json-update' + : expected && expected.classification; + if ( + !expected + || expected.kind !== operation.kind + || canonicalPath(expected.destinationPath) !== destination + || expectedClassification !== currentClassification + ) { + throw new Error( + `Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.` + ); + } + return destination; + }; const result = require('./install-executor').applyInstallPlan(preview.plan, { - beforeInstallStateRead: assertStateUnchanged, + beforeInstallStateRead() { + assertStateUnchanged(); + // Check the original preview before ownership filtering can skip a late + // collision. Guided setup must report the changed plan as a failure. + for (const operation of preview.plan.operations) { + assertOperationUnchanged(operation); + } + }, beforeOperationWrite({ operation }) { assertStateUnchanged(); - const expected = preview.operations[operationIndex]; - const currentClassification = classifyManagedOperation(operation, ownedDestinations); - const destination = canonicalPath(operation.destinationPath); - if ( - !expected - || expected.kind !== operation.kind - || canonicalPath(expected.destinationPath) !== destination - || expected.classification !== currentClassification - ) { - throw new Error( - `Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.` - ); - } + const destination = assertOperationUnchanged(operation); ownedDestinations.add(destination); - operationIndex += 1; + writtenDestinations.add(destination); }, beforeInstallStateWrite: prepareInstallStateWrite, }); diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js new file mode 100644 index 000000000..6ec294453 --- /dev/null +++ b/scripts/lib/powershell-destructive-command.js @@ -0,0 +1,2089 @@ +'use strict'; + +/** + * Pure PowerShell destructive-command classifier. + * + * This is deliberately a small policy parser rather than a PowerShell + * interpreter. It understands the quoting, escaping, subexpression, and + * nested-shell forms needed to make GateGuard and governance reach the same + * decision without retaining raw command text. + */ + +const RULE_IDS = 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', +}); + +const DELETE_COMMANDS = new Set([ + 'remove-item', + 'remove-itemproperty', + 'rp', + 'ri', + 'rm', + 'rmdir', + 'rd', + 'del', + 'erase', +]); + +const POWERSHELL_COMMANDS = new Set(['powershell', 'pwsh']); +const CMD_DELETE_COMMANDS = new Set(['rd', 'rmdir', 'del', 'erase']); +const START_PROCESS_VALUE_PARAMETERS = new Set([ + 'argumentlist', + 'credential', + 'environment', + 'filepath', + 'redirectstandarderror', + 'redirectstandardinput', + 'redirectstandardoutput', + 'verb', + 'windowstyle', + 'workingdirectory', +]); +const START_PROCESS_SWITCH_PARAMETERS = new Set([ + 'loaduserprofile', + 'nonewwindow', + 'passthru', + 'usenewenvironment', + 'wait', +]); +const ALIAS_VALUE_PARAMETERS = new Set([ + 'name', 'value', 'description', 'option', 'scope', + 'erroraction', 'warningaction', 'informationaction', 'progressaction', + 'errorvariable', 'warningvariable', 'informationvariable', + 'outvariable', 'outbuffer', 'pipelinevariable', +]); +const ALIAS_SWITCH_PARAMETERS = new Set([ + 'force', 'passthru', 'whatif', 'confirm', 'verbose', 'debug', +]); +const ALIAS_PARAMETER_ABBREVIATIONS = Object.freeze({ + ea: 'erroraction', wa: 'warningaction', infa: 'informationaction', proga: 'progressaction', + ev: 'errorvariable', wv: 'warningvariable', iv: 'informationvariable', + ov: 'outvariable', ob: 'outbuffer', pv: 'pipelinevariable', + wi: 'whatif', cf: 'confirm', vb: 'verbose', db: 'debug', +}); +const MAX_SCAN_DEPTH = 4; +const MAX_CONTEXT_LENGTH = 4096; +const DYNAMIC_EXECUTION_MARKER = '__ecc_dynamic_execution__'; + +function normalizeSmartQuotes(value) { + return String(value || '') + .replace(/[\u2018\u2019\u201a\u201b]/g, "'") + .replace(/[\u201c\u201d\u201e]/g, '"') + .replace(/[\u2013\u2014\u2015]/g, '-'); +} + +function commandBasename(value) { + const parts = String(value || '').split(/[\\/]/); + return (parts[parts.length - 1] || '').replace(/\.exe$/i, '').toLowerCase(); +} + +function isParameterPrefix(token, parameter) { + const raw = String(token || ''); + if (!raw.startsWith('-')) return false; + const name = raw.replace(/^-+/, '').split(':')[0].toLowerCase(); + return name.length > 0 && parameter.startsWith(name); +} + +function isEnabledSwitch(token, parameter) { + if (!isParameterPrefix(token, parameter)) return false; + const separator = String(token).indexOf(':'); + if (separator === -1) return true; + return !/^\$?(?:false|null|0)$/i.test(String(token).slice(separator + 1)); +} + +function isEncodedCommandFlag(token) { + return isParameterPrefix(token, 'encodedcommand'); +} + +function isCommandFlag(token) { + const name = String(token || '').replace(/^-+/, '').split(':')[0].toLowerCase(); + return isParameterPrefix(token, 'command') || + isParameterPrefix(token, 'commandwithargs') || name === 'cwa'; +} + +function normalizeHereStrings(input, executablePayloads = []) { + const output = [...input]; + const replacements = []; + let ordinaryQuote = null; + let lineComment = false; + let blockComment = false; + let bracedVariable = false; + + for (let index = 0; index < input.length - 1; index += 1) { + const char = input[index]; + const next = input[index + 1]; + if (lineComment) { + if (char === '\n' || char === '\r') lineComment = false; + continue; + } + if (blockComment) { + if (char === '#' && next === '>') { + blockComment = false; + index += 1; + } + continue; + } + if (bracedVariable) { + if (char === '`') index += 1; + else if (char === '}') bracedVariable = false; + continue; + } + if (ordinaryQuote === "'") { + if (char === "'" && input[index + 1] === "'") index += 1; + else if (char === "'") ordinaryQuote = null; + continue; + } + if (char === '`') { + index += 1; + continue; + } + if (ordinaryQuote === '"') { + if (char === '"') ordinaryQuote = null; + continue; + } + + if (char === '$' && next === '{') { + bracedVariable = true; + index += 1; + continue; + } + if (char === '<' && next === '#') { + blockComment = true; + index += 1; + continue; + } + if (char === '#') { + lineComment = true; + continue; + } + + if (input[index] !== '@' || (input[index + 1] !== "'" && input[index + 1] !== '"')) { + if (char === "'" || char === '"') ordinaryQuote = char; + continue; + } + + const quote = input[index + 1]; + let openerLineEnd = index + 2; + while (input[openerLineEnd] === ' ' || input[openerLineEnd] === '\t') openerLineEnd += 1; + if (input[openerLineEnd] === '\r' && input[openerLineEnd + 1] === '\n') openerLineEnd += 1; + if (input[openerLineEnd] !== '\n') { + if (openerLineEnd >= input.length) break; + continue; + } + + let closingEnd = -1; + for (let lineStart = openerLineEnd + 1; lineStart < input.length;) { + let contentStart = lineStart; + while (input[contentStart] === ' ' || input[contentStart] === '\t') contentStart += 1; + if (input[contentStart] === quote && input[contentStart + 1] === '@') { + closingEnd = contentStart + 2; + break; + } + while (lineStart < input.length && input[lineStart] !== '\n') lineStart += 1; + if (lineStart < input.length) lineStart += 1; + } + + const contentEnd = closingEnd === -1 ? input.length : closingEnd - 2; + const content = input.slice(openerLineEnd + 1, contentEnd); + + // Represent a here-string as one ordinary literal token. Standalone + // literals remain inert, while static consumers such as Invoke-Expression + // and `pwsh -Command -` can recover the value from normal token flow. + replacements.push({ + end: closingEnd === -1 ? input.length : closingEnd, + start: index, + value: `'${content.replace(/'/g, "''")}'`, + }); + + // Expandable here-strings execute their unescaped subexpressions while the + // string value is being formed, independently of any later consumer. + if (quote === '"') { + for (let offset = openerLineEnd + 1; offset < contentEnd; offset += 1) { + if (input[offset] === '`') { + offset += 1; + continue; + } + if (input[offset] !== '$' || input[offset + 1] !== '(') continue; + const group = readBalancedGroup(input, offset + 1, '(', ')'); + if (!group || group.end > contentEnd) break; + executablePayloads.push(group.body); + offset = group.end - 1; + } + } + + if (closingEnd === -1) break; + index = closingEnd - 1; + } + + if (replacements.length === 0) return output.join(''); + let normalized = ''; + let cursor = 0; + for (const replacement of replacements) { + normalized += output.slice(cursor, replacement.start).join(''); + normalized += replacement.value; + cursor = replacement.end; + } + normalized += output.slice(cursor).join(''); + return normalized; +} + +function stripPowerShellComments(input) { + const output = [...input]; + let quote = null; + let lineComment = false; + let blockComment = false; + let bracedVariable = false; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + const next = input[index + 1]; + + if (lineComment) { + if (char === '\n' || char === '\r') { + lineComment = false; + } else { + output[index] = ' '; + } + continue; + } + + if (blockComment) { + if (char === '#' && next === '>') { + output[index] = ' '; + output[index + 1] = ' '; + blockComment = false; + index += 1; + } else if (char !== '\n' && char !== '\r') { + output[index] = ' '; + } + continue; + } + + if (bracedVariable) { + if (char === '`') index += 1; + else if (char === '}') bracedVariable = false; + continue; + } + + if (quote === "'") { + if (char === "'" && next === "'") { + index += 1; + } else if (char === "'") { + quote = null; + } + continue; + } + if (char === '`') { + index += 1; + continue; + } + if (quote === '"') { + if (char === '"') quote = null; + continue; + } + if (char === "'" || char === '"') { + quote = char; + continue; + } + + if (char === '$' && next === '{') { + bracedVariable = true; + index += 1; + continue; + } + + if (char === '<' && next === '#') { + output[index] = ' '; + output[index + 1] = ' '; + blockComment = true; + index += 1; + continue; + } + + if (char === '#') { + output[index] = ' '; + lineComment = true; + } + } + + return output.join(''); +} + +/** + * Read one balanced PowerShell container. Quotes do not affect delimiter + * balance, and a backtick protects exactly the following character. Callers + * stop after the first unmatched opener, which keeps malformed input linear. + */ +function readBalancedGroup(input, openingIndex, open, close) { + let depth = 1; + let quote = null; + let lineComment = false; + let blockComment = false; + let bracedVariable = false; + + for (let index = openingIndex + 1; index < input.length; index += 1) { + const char = input[index]; + const next = input[index + 1]; + + if (lineComment) { + if (char === '\n' || char === '\r') lineComment = false; + continue; + } + if (blockComment) { + if (char === '#' && next === '>') { + blockComment = false; + index += 1; + } + continue; + } + if (bracedVariable) { + if (char === '`') index += 1; + else if (char === '}') bracedVariable = false; + continue; + } + + if (quote === "'") { + if (char === "'" && input[index + 1] === "'") { + index += 1; + } else if (char === "'") { + quote = null; + } + continue; + } + if (char === '`') { + index += 1; + continue; + } + + if (quote === '"') { + if (char === '"') quote = null; + continue; + } + + if (char === "'" || char === '"') { + quote = char; + continue; + } + + if (char === '$' && next === '{') { + bracedVariable = true; + index += 1; + continue; + } + + if (char === '<' && next === '#') { + blockComment = true; + index += 1; + continue; + } + if (char === '#') { + lineComment = true; + continue; + } + + if (char === open) { + depth += 1; + } else if (char === close) { + depth -= 1; + if (depth === 0) { + return { + body: input.slice(openingIndex + 1, index), + end: index + 1, + }; + } + } + } + + return null; +} + +/** + * Decide whether a script block is executed at its declaration site. Function + * and variable declarations remain inert, while call operators, control-flow + * clauses, and common script-block-consuming commands execute their bodies. + */ +function currentClause(prefix) { + const clauseStart = Math.max( + prefix.lastIndexOf(';'), + prefix.lastIndexOf('\n'), + prefix.lastIndexOf('\r') + ); + return prefix.slice(clauseStart + 1).trim(); +} + +function invokesContainerResult(prefix) { + const clause = currentClause(prefix); + const pipelineStart = clause.lastIndexOf('|'); + const pipelineCommand = clause.slice(pipelineStart + 1).trim(); + return /(?:^|\s)(?:&|\.)\s*$/.test(clause) || + /\.\s*(?:foreach|where)\s*$/i.test(clause) || + /-(?:action|begin|command|end|expression|filter|initializationscript|parallel|process|scriptblock)(?:\s*:\s*)?$/i.test(clause) || + /^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand); +} + +function invokesDynamicResult(prefix) { + return invokesContainerResult(prefix) || + /(?:^|\s)(?:iex|invoke-expression)\s*$/i.test(currentClause(prefix)); +} + +function deferredScriptBlockName(prefix) { + const clause = currentClause(prefix); + const functionMatch = clause.match(/^(?:function|filter|workflow)\s+(?:(?:global|local|script|private):)?([A-Za-z_][\w-]*)\b/i); + if (functionMatch) return functionMatch[1].toLowerCase(); + const classMatch = clause.match(/^class\s+([A-Za-z_][\w-]*)\b/i); + if (classMatch) return `__class__:${classMatch[1].toLowerCase()}`; + const variableMatch = clause.match( + /^((?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*))\s*=\s*$/ + ); + return variableMatch ? variableMatch[1].toLowerCase() : null; +} + +function isExecutableScriptBlock(prefix, options = {}) { + if (options.executeBareScriptBlocks) return true; + const clause = currentClause(prefix); + const pipelineStart = clause.lastIndexOf('|'); + const pipelineCommand = clause.slice(pipelineStart + 1).trim(); + + if (invokesContainerResult(prefix)) return true; + if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { + return true; + } + return /^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand); +} + +function isInvokedAfterContainer(input, end) { + let index = end; + const skipSpacing = () => { + while (index < input.length) { + if (/\s/.test(input[index])) { + index += 1; + } else if (input[index] === '`' && /[\r\n]/.test(input[index + 1] || '')) { + index += input[index + 1] === '\r' && input[index + 2] === '\n' ? 3 : 2; + } else { + break; + } + } + }; + + while (index < input.length) { + skipSpacing(); + if (input[index] !== '.') return false; + index += 1; + skipSpacing(); + + let method = ''; + const quote = input[index] === "'" || input[index] === '"' ? input[index++] : null; + while (index < input.length) { + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + method += input[index + 1]; + index += 2; + } else if (quote ? char === quote : !/[A-Za-z]/.test(char)) { + if (quote) index += 1; + break; + } else { + method += char; + index += 1; + } + } + skipSpacing(); + if (input[index] !== '(') return false; + + const normalizedMethod = method.toLowerCase(); + if (['invoke', 'invokereturnasis', 'invokewithcontext'].includes(normalizedMethod)) { + return true; + } + if (normalizedMethod !== 'getnewclosure') return false; + index += 1; + skipSpacing(); + if (input[index] !== ')') return false; + index += 1; + } + return false; +} + +function staticStringResult(body) { + const value = String(body || '').trim(); + if (value.length < 2) return null; + const quote = value[0]; + if ((quote !== "'" && quote !== '"') || value[value.length - 1] !== quote) return null; + const content = value.slice(1, -1); + return quote === "'" ? content.replace(/''/g, "'") : decodeDoubleQuotedString(content); +} + +function decodeDoubleQuotedString(content) { + const input = String(content || ''); + let value = ''; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (char !== '`' || index + 1 >= input.length) { + value += char; + continue; + } + const escaped = input[index + 1]; + index += 1; + if (escaped === '\r' && input[index + 1] === '\n') index += 1; + if (escaped !== '\r' && escaped !== '\n') value += escaped; + } + return value; +} + +function expandStaticDoubleQuotedString(content, state, findings) { + const input = String(content || ''); + let value = ''; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (char === '`' && index + 1 < input.length) { + const escaped = input[index + 1]; + index += 1; + if (escaped === '\r' && input[index + 1] === '\n') index += 1; + if (escaped !== '\r' && escaped !== '\n') value += escaped; + continue; + } + if (char !== '$') { + value += char; + continue; + } + + if (input[index + 1] === '(') { + const group = readBalancedGroup(input, index + 1, '(', ')'); + const reference = group ? variableReference(group.body) : null; + const staticValue = reference ? state?.staticScalars.get(reference) : undefined; + if (!group || staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return null; + } + value += staticValue; + index = group.end - 1; + continue; + } + + const referenceMatch = input.slice(index).match( + /^(?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)/ + ); + if (!referenceMatch) { + value += char; + continue; + } + + const reference = variableReference(referenceMatch[0]); + const staticValue = reference ? state?.staticScalars.get(reference) : undefined; + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return null; + } + value += staticValue; + index += referenceMatch[0].length - 1; + } + + return value; +} + +function leadingStaticStringResult(source) { + const input = String(source || ''); + let index = 0; + while (/\s/.test(input[index] || '')) index += 1; + const quote = input[index]; + if (quote !== "'" && quote !== '"') return null; + index += 1; + let value = ''; + while (index < input.length) { + const char = input[index]; + if (quote === "'" && char === "'" && input[index + 1] === "'") { + value += "'"; + index += 2; + continue; + } + if (quote === '"' && char === '`' && index + 1 < input.length) { + const escaped = input[index + 1]; + index += 2; + if (escaped === '\r' && input[index] === '\n') index += 1; + if (escaped !== '\r' && escaped !== '\n') value += escaped; + continue; + } + if (char === quote) return value; + value += char; + index += 1; + } + return null; +} + +function staticScalarResult(body, depth = 0) { + if (depth > MAX_SCAN_DEPTH) return null; + const value = String(body || '').trim(); + const literal = staticStringResult(value); + if (literal !== null) return literal; + + const isSubexpression = value.startsWith('$('); + const openingIndex = isSubexpression ? 1 : 0; + if (value[openingIndex] !== '(') return null; + const group = readBalancedGroup(value, openingIndex, '(', ')'); + if (!group || group.end !== value.length) return null; + return staticScalarResult(group.body, depth + 1); +} + +function staticCommandResult(body) { + const value = staticScalarResult(body); + const command = value === null ? '' : value.trim(); + return command && /^[A-Za-z_][\w./\\-]*$/.test(command) ? command : null; +} + +function staticStringArrayResult(body) { + const input = String(body || ''); + const items = []; + let item = ''; + let quote = null; + let depth = 0; + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (char === '`' && quote === '"' && index + 1 < input.length) { + item += char + input[index + 1]; + index += 1; + continue; + } + if (quote === "'" && char === "'" && input[index + 1] === "'") { + item += "''"; + index += 1; + continue; + } + if (char === "'" || char === '"') { + quote = quote === char ? null : (quote || char); + item += char; + continue; + } + if (!quote && char === '(') depth += 1; + if (!quote && char === ')') depth -= 1; + if (!quote && depth === 0 && char === ',') { + items.push(item); + item = ''; + continue; + } + item += char; + } + if (quote || depth !== 0) return null; + items.push(item); + const values = items.map(value => staticScalarResult(value)); + return values.length > 0 && values.every(value => value !== null) + ? values.join(' ') + : null; +} + +function staticTypeNameResult(body) { + const value = String(body || '').trim(); + const match = value.match(/^\[([A-Za-z_][\w-]*)\]$/); + if (match) return match[1]; + const scalar = staticScalarResult(value); + if (scalar !== null && /^[A-Za-z_][\w-]*$/.test(scalar)) return scalar; + const openingIndex = value.startsWith('(') ? 0 : -1; + if (openingIndex === -1) return null; + const group = readBalancedGroup(value, openingIndex, '(', ')'); + return group && group.end === value.length ? staticTypeNameResult(group.body) : null; +} + +function variableReference(value) { + const variable = String(value || '').trim(); + return /^(?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)$/.test(variable) + ? variable.toLowerCase() + : null; +} + +function staticOutputResult(body, depth = 0) { + if (depth > MAX_SCAN_DEPTH) return null; + const scalar = staticScalarResult(body); + if (scalar !== null) return scalar.trim(); + const value = String(body || '').trim(); + const openingIndex = value.startsWith('$(') ? 1 : 0; + if (value[openingIndex] === '(') { + const group = readBalancedGroup(value, openingIndex, '(', ')'); + if (group && group.end === value.length) { + return staticOutputResult(group.body, depth + 1); + } + } + const statements = parseStatements(value); + if (statements.length !== 1 || statements[0].length !== 1) return null; + const tokens = statements[0][0]; + const command = commandBasename(tokens[0]); + if ((command !== 'write-output' && command !== 'echo') || tokens.length < 2) return null; + return tokens.slice(1).join(' '); +} + +function isPipedToPowerShellStdin(input, end) { + return /^\s*\|\s*(?:pwsh|powershell)(?:\.exe)?\s+-(?:command|c)\s+-\s*(?:[;\r\n]|$)/i.test( + input.slice(end) + ); +} + +/** + * Extract executable `$()`, `@()`, grouping parentheses, and selected script + * blocks while masking every container from the outer statement pass. `$()` + * also executes inside double quotes. Other containers are literal there. + */ +function extractExecutableContainers(input, options = {}) { + const bodies = []; + const deferredFunctions = []; + const masked = [...input]; + let quote = null; + let bracedVariable = false; + let context = ''; + let contextTruncated = false; + + const resetContext = () => { + context = ''; + contextTruncated = false; + }; + + const appendContext = value => { + for (const contextChar of value) { + if (contextChar === ';' || contextChar === '}') { + resetContext(); + } else if (contextChar === '\n' || contextChar === '\r') { + const clause = currentClause(context); + if (/^(?:if|elseif|else|for|foreach|while|do|switch|default|try|catch|finally|trap|function|filter|workflow|begin|process|end|dynamicparam|clean)\b/i.test(clause)) { + if (context && !context.endsWith(' ')) context += ' '; + } else { + resetContext(); + } + } else if (/\s/.test(contextChar)) { + if (context && !context.endsWith(' ')) context += ' '; + } else { + context += contextChar; + } + if (context.length > MAX_CONTEXT_LENGTH) { + context = context.slice(-Math.floor(MAX_CONTEXT_LENGTH / 2)); + contextTruncated = true; + } + } + }; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (bracedVariable) { + if (char === '`' && index + 1 < input.length) { + appendContext(input[index + 1]); + index += 1; + } else if (char === '}') { + context += char; + bracedVariable = false; + } else { + appendContext(char); + } + continue; + } + + if (quote === "'") { + if (char === "'" && input[index + 1] === "'") { + index += 1; + } else if (char === "'") { + quote = null; + } + continue; + } + if (char === '`') { + if (!quote && index + 1 < input.length) { + const escaped = input[index + 1]; + appendContext(escaped === '\n' || escaped === '\r' ? ' ' : escaped); + if (escaped === '\r' && input[index + 2] === '\n') index += 1; + } + index += 1; + continue; + } + + if (!quote && char === "'") { + quote = "'"; + appendContext(' '); + continue; + } + + if (!quote && char === '$' && input[index + 1] === '{') { + appendContext('${'); + bracedVariable = true; + index += 1; + continue; + } + + if (char === '"') { + quote = quote === '"' ? null : '"'; + if (quote === '"') appendContext(' '); + continue; + } + + const isSubexpression = char === '$' && input[index + 1] === '('; + if (quote === '"' && !isSubexpression) continue; + + const isArrayExpression = !quote && char === '@' && input[index + 1] === '('; + const isGroupingExpression = !quote && char === '('; + const isScriptBlock = !quote && char === '{'; + const isHashtable = isScriptBlock && input[index - 1] === '@'; + const isCmdPayloadGroup = isGroupingExpression && + /(?:^|\s)cmd(?:\.exe)?\s+\/[ck](?:\s|$)/i.test(currentClause(context)); + if (!isSubexpression && !isArrayExpression && !isGroupingExpression && !isScriptBlock) { + if (!quote) appendContext(char); + continue; + } + if (isCmdPayloadGroup) { + appendContext(char); + continue; + } + + const openingIndex = isSubexpression || isArrayExpression ? index + 1 : index; + const open = isScriptBlock ? '{' : '('; + const close = isScriptBlock ? '}' : ')'; + const group = readBalancedGroup(input, openingIndex, open, close); + if (!group) { + for (let offset = index; offset < input.length; offset += 1) masked[offset] = ' '; + break; + } + + const withinDoubleQuote = quote === '"'; + const prefix = context; + const invokedAfter = isInvokedAfterContainer(input, group.end); + const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test( + currentClause(prefix) + ); + const shouldScan = contextTruncated || !isScriptBlock || isHashtable || invokedAfter || + isExecutableScriptBlock(prefix, options); + if (shouldScan) { + const executesNestedScriptBlocks = isScriptBlock && /^switch\b/i.test(currentClause(prefix)); + bodies.push({ + body: group.body, + options: { + executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) || + invokedAfter || executesNestedScriptBlocks || + (!isScriptBlock && invokesContainerResult(prefix)), + }, + }); + } else { + const functionName = deferredScriptBlockName(prefix); + if (functionName) deferredFunctions.push({ body: group.body, functionName }); + } + if (createsScriptBlock && (invokedAfter || options.executeBareScriptBlocks)) { + const scalarReference = variableReference(group.body); + const scriptText = staticStringResult(group.body) || + (scalarReference ? options.staticScalars?.get(scalarReference) : null); + if (scriptText) { + bodies.push({ body: scriptText, options: { executeBareScriptBlocks: true } }); + } + } + for (let offset = index; offset < group.end; offset += 1) { + masked[offset] = ' '; + } + let resolvedCommand = null; + if (!isScriptBlock) { + if (isSubexpression || invokesContainerResult(prefix)) { + resolvedCommand = staticOutputResult(group.body); + if (resolvedCommand === null && isSubexpression) { + const scalarReference = variableReference(group.body); + if (scalarReference) { + resolvedCommand = options.staticScalars?.get(scalarReference) ?? null; + } + } + } else if (/^(?:start-process|saps|start)\b/i.test(currentClause(prefix))) { + resolvedCommand = staticStringArrayResult(group.body); + } else if (/^new-object\b/i.test(currentClause(prefix))) { + resolvedCommand = staticTypeNameResult(group.body); + } else if (isPipedToPowerShellStdin(input, group.end)) { + resolvedCommand = staticScalarResult(group.body); + } else { + resolvedCommand = staticCommandResult(group.body); + } + } + const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test( + maskQuotedStrings(group.body) + ); + if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix) && !executableBlockExpression) { + resolvedCommand = DYNAMIC_EXECUTION_MARKER; + } + if (resolvedCommand) { + for (let offset = 0; offset < resolvedCommand.length; offset += 1) { + masked[index + offset] = resolvedCommand[offset]; + } + if (!withinDoubleQuote) appendContext(resolvedCommand); + } else if (isScriptBlock) { + if (invokesContainerResult(prefix)) { + context = prefix; + } else { + resetContext(); + } + } else if (!withinDoubleQuote) { + appendContext(' '); + } + index = group.end - 1; + } + + return { bodies, deferredFunctions, outer: masked.join('') }; +} + +/** + * Split PowerShell into statements, pipelines, and dequoted words. Backticks + * are interpreted before token comparison so `Rem`ove-Item` normalizes to the + * command PowerShell executes. Backslashes remain ordinary characters. + */ +function parseStatements(input) { + const statements = []; + let statement = []; + let segment = []; + let segmentQuotedTokens = []; + let segmentQuoteKinds = []; + let segmentTokenSources = []; + let segmentInlineValueQuoteKinds = []; + let word = ''; + let wordSource = ''; + let wordHasQuotedContent = false; + let wordHasUnquotedContent = false; + let wordQuoteKind = null; + let wordInlineValueQuoteKind = null; + let wordInlineValueQuoteClosed = false; + let quote = null; + let parenDepth = 0; + let callOperatorPending = false; + + const flushWord = () => { + if (word) { + segment.push(word); + segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent); + segmentQuoteKinds.push( + wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null + ); + segmentTokenSources.push(wordSource); + segmentInlineValueQuoteKinds.push( + wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed' + ? wordInlineValueQuoteKind + : null + ); + } + word = ''; + wordSource = ''; + wordHasQuotedContent = false; + wordHasUnquotedContent = false; + wordQuoteKind = null; + wordInlineValueQuoteKind = null; + wordInlineValueQuoteClosed = false; + }; + const flushSegment = () => { + flushWord(); + if (segment.length) { + Object.defineProperties(segment, { + invokedByCallOperator: { value: callOperatorPending }, + quotedTokens: { value: segmentQuotedTokens }, + quoteKinds: { value: segmentQuoteKinds }, + tokenSources: { value: segmentTokenSources }, + inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds }, + }); + statement.push(segment); + callOperatorPending = false; + } + segment = []; + segmentQuotedTokens = []; + segmentQuoteKinds = []; + segmentTokenSources = []; + segmentInlineValueQuoteKinds = []; + }; + const flushStatement = () => { + flushSegment(); + if (statement.length) statements.push(statement); + statement = []; + }; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + + if (quote === "'") { + if (char === "'" && input[index + 1] === "'") { + word += "'"; + wordSource += "''"; + index += 1; + } else if (char === "'") { + quote = null; + if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true; + } else { + word += char; + wordSource += char; + wordHasQuotedContent = true; + } + continue; + } + + if (char === '`') { + if (index + 1 >= input.length) { + word += '`'; + wordSource += '`'; + continue; + } + const escaped = input[index + 1]; + wordSource += `\`${escaped}`; + index += 1; + if (escaped === '\n' || escaped === '\r') { + if (escaped === '\r' && input[index + 1] === '\n') { + wordSource += '\n'; + index += 1; + } + } else { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; + word += escaped; + if (quote) wordHasQuotedContent = true; + else wordHasUnquotedContent = true; + } + continue; + } + + if (quote === '"') { + if (char === '"') { + quote = null; + if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true; + } else { + word += char; + wordSource += char; + wordHasQuotedContent = true; + } + continue; + } + + if (char === "'" || char === '"') { + if (wordInlineValueQuoteClosed) { + wordInlineValueQuoteKind = 'mixed'; + } else if (wordInlineValueQuoteKind === null && /^-+[^:\s]+:$/.test(word)) { + wordInlineValueQuoteKind = char; + } + quote = char; + wordHasQuotedContent = true; + wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed'; + continue; + } + + if (char === '(') { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; + parenDepth += 1; + word += char; + wordSource += char; + wordHasUnquotedContent = true; + continue; + } + if (char === ')' && parenDepth > 0) { + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; + parenDepth -= 1; + word += char; + wordSource += char; + wordHasUnquotedContent = true; + continue; + } + + if (parenDepth === 0 && (char === ';' || char === '\n' || char === '\r')) { + flushStatement(); + continue; + } + if (parenDepth === 0 && char === '|') { + flushSegment(); + continue; + } + if (parenDepth === 0 && char === '&') { + if (word || segment.length) flushStatement(); + callOperatorPending = true; + continue; + } + if (/\s/.test(char)) { + flushWord(); + continue; + } + + if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed'; + word += char; + wordSource += char; + wordHasUnquotedContent = true; + } + + flushStatement(); + return statements; +} + +function maskQuotedStrings(input) { + let output = ''; + let quote = null; + + for (let index = 0; index < input.length; index += 1) { + const char = input[index]; + if (quote === "'") { + output += ' '; + if (char === "'" && input[index + 1] === "'") { + output += ' '; + index += 1; + } else if (char === "'") { + quote = null; + } + continue; + } + if (char === '`') { + if (index + 1 < input.length) { + output += quote ? ' ' : input[index + 1]; + index += 1; + } else { + output += quote ? ' ' : '`'; + } + continue; + } + if (quote === '"') { + output += ' '; + if (char === '"') quote = null; + continue; + } + if (char === "'" || char === '"') { + quote = char; + output += ' '; + continue; + } + output += char; + } + + return output; +} + +function decodeUtf16LeBase64(value) { + const encoded = String(value || '').trim(); + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) { + return null; + } + + const bytes = Buffer.from(encoded, 'base64'); + if (bytes.length === 0 || bytes.length % 2 !== 0) return null; + if (bytes.toString('base64').replace(/=+$/, '') !== encoded.replace(/=+$/, '')) return null; + + const decoded = bytes.toString('utf16le'); + if (!decoded || decoded.includes('\uFFFD') || decoded.includes('\u0000')) return null; + return decoded; +} + +function createScanState() { + return { + deferredFunctions: new Map(), + invokedCommands: new Set(), + pendingInvocations: [], + resolvingFunctions: false, + scannedFunctions: new Set(), + staticScalars: new Map(), + aliases: new Map(), + }; +} + +function collectStaticScalarAssignments(input, state) { + const variable = String.raw`(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`; + const firstReferences = new Map(); + const referencePattern = new RegExp(variable, 'g'); + let reference; + while ((reference = referencePattern.exec(input)) !== null) { + const name = reference[1].toLowerCase(); + if (!firstReferences.has(name)) firstReferences.set(name, reference.index); + } + const assignmentCounts = new Map(); + const assignmentPattern = new RegExp(`${variable}\\s*(?:\\+=|-=|\\*=|\\/=|%=|=)`, 'g'); + let assignmentMatch; + while ((assignmentMatch = assignmentPattern.exec(input)) !== null) { + const name = assignmentMatch[1].toLowerCase(); + assignmentCounts.set(name, (assignmentCounts.get(name) || 0) + 1); + } + const pattern = new RegExp( + String.raw`(?:^|[;\r\n])\s*${variable}\s*=\s*(?:'((?:''|[^'])*)'|"((?:\x60[\s\S]|[^\x60"])*)")\s*(?=;|\r?\n|$)`, + 'g' + ); + let match; + while ((match = pattern.exec(input)) !== null) { + const name = match[1].toLowerCase(); + // The scan pre-collects immutable scalars for nested executable bodies. + // A value assigned after an earlier reference cannot explain that use. + // Keep it unresolved so dynamic execution remains gated. Counting even + // quoted references is deliberately conservative, with a linear scan. + const assignmentIndex = match.index + match[0].indexOf(match[1]); + if (firstReferences.get(name) !== assignmentIndex) continue; + if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue; + const value = match[2] !== undefined + ? match[2].replace(/''/g, "'") + : decodeDoubleQuotedString(match[3]); + state.staticScalars.set(name, value); + } + for (const [name, count] of assignmentCounts) { + if (count !== 1) state.staticScalars.delete(name); + } +} + +function recordInvocation(state, commandName) { + if (!commandName || state.invokedCommands.has(commandName)) return; + state.invokedCommands.add(commandName); + if (state.resolvingFunctions) state.pendingInvocations.push(commandName); +} + +function registerDeferredFunction(state, definition) { + const definitions = state.deferredFunctions.get(definition.functionName) || []; + definitions.push(definition); + state.deferredFunctions.set(definition.functionName, definitions); + if (state.resolvingFunctions && state.invokedCommands.has(definition.functionName)) { + state.pendingInvocations.push(definition.functionName); + } +} + +function addNestedScan(payload, depth, findings, analysis, options = {}, scanState = null) { + if (depth >= MAX_SCAN_DEPTH) { + findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + scanPowerShell(payload, depth + 1, findings, analysis, options, scanState); +} + +function staticTokenValue(tokens, index, state, findings, inline = false) { + const value = inline ? parameterValue(tokens[index]) : tokens[index]; + const quoteKind = inline ? tokens.inlineValueQuoteKinds?.[index] : tokens.quoteKinds?.[index]; + if (quoteKind === "'") return value; + const source = inline + ? parameterValue(tokens.tokenSources?.[index] || tokens[index]) + : tokens.tokenSources?.[index] ?? value; + return expandStaticDoubleQuotedString(source, state, findings); +} + +function staticPipelineInput(tokens, state, findings) { + if (!tokens || tokens.length === 0) return null; + if (tokens.length === 1) return staticTokenValue(tokens, 0, state, findings); + const command = commandBasename(tokens[0]); + if ((command === 'write-output' || command === 'echo') && tokens.length === 2) { + return staticTokenValue(tokens, 1, state, findings); + } + return null; +} + +function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upstreamTokens = null) { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + + if (isEncodedCommandFlag(token)) { + const inlinePayload = parameterValue(token); + let encodedPayload = inlinePayload || tokens[index + 1]; + const payloadIndex = index + 1; + const quoteKind = tokens.quoteKinds?.[payloadIndex]; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if ((inlinePayload && inlineQuoteKind !== "'") || + (!inlinePayload && encodedPayload && quoteKind !== "'")) { + const source = inlinePayload + ? parameterValue(tokens.tokenSources?.[index] || token) + : tokens.tokenSources?.[payloadIndex] ?? encodedPayload; + const expanded = expandStaticDoubleQuotedString( + source || encodedPayload, + scanState, + findings + ); + if (expanded === null) return; + encodedPayload = expanded; + } + const decoded = decodeUtf16LeBase64(encodedPayload); + if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState); + return; + } + + if (isCommandFlag(token)) { + const inlinePayload = parameterValue(token); + let payload = inlinePayload + ? [inlinePayload, ...tokens.slice(index + 1)].join(' ') + : tokens.slice(index + 1).join(' '); + const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null; + const payloadIndex = index + 1; + const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index]; + if (inlinePayload && inlineQuoteKind !== "'") { + const inlineSource = parameterValue(tokens.tokenSources?.[index] || token); + const expanded = expandStaticDoubleQuotedString( + inlineSource || inlinePayload, + scanState, + findings + ); + if (expanded === null) return; + payload = [expanded, ...tokens.slice(index + 1)].join(' '); + } else if (inlinePayload) { + payload = [inlinePayload, ...tokens.slice(index + 1)].join(' '); + } else if (tokens[payloadIndex] && tokens.quoteKinds?.[payloadIndex] !== "'") { + const expanded = expandStaticDoubleQuotedString( + tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex], + scanState, + findings + ); + if (expanded === null) return; + payload = [expanded, ...tokens.slice(payloadIndex + 1)].join(' '); + } else { + const payloadReference = tokens.quoteKinds?.[payloadIndex] === "'" + ? null + : variableReference(payload); + if (payloadReference) { + const staticValue = scanState?.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + payload = staticValue; + } + } + if (pipelinePayload || (payload && payload !== '-')) { + addNestedScan( + pipelinePayload || payload, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } + return; + } + } +} + +function splitCmdSegments(payload) { + const segments = []; + let segment = ''; + let quote = false; + + for (let index = 0; index < payload.length; index += 1) { + const char = payload[index]; + if (char === '^' && index + 1 < payload.length) { + segment += payload[index + 1]; + index += 1; + continue; + } + if (char === '"') { + quote = !quote; + continue; + } + if (!quote && (char === '&' || char === '|')) { + if (segment.trim()) segments.push(segment.trim()); + segment = ''; + continue; + } + segment += char; + } + if (segment.trim()) segments.push(segment.trim()); + return segments; +} + +function scanCmdWords(inputWords, depth, findings, analysis, scanState, wrapperDepth = 0) { + if (wrapperDepth > 64) { + findings.add(RULE_IDS.SCAN_DEPTH_EXCEEDED); + return; + } + + let words = inputWords.filter(Boolean).map(word => String(word)); + if (words.length === 0) return; + words[0] = words[0].replace(/^@+/, '').replace(/^\(+/, ''); + words[words.length - 1] = words[words.length - 1].replace(/\)+$/, ''); + + while (words.length > 0 && /^\d*(?:>>?|<>?|< index > 0 && /^else$/i.test(word)); + const trueBranch = elseIndex === -1 ? words : words.slice(0, elseIndex); + let commandIndex = 1; + if (/^\/i$/i.test(trueBranch[commandIndex])) commandIndex += 1; + if (/^not$/i.test(trueBranch[commandIndex])) commandIndex += 1; + if (/^(?:exist|defined|errorlevel|cmdextversion)$/i.test(trueBranch[commandIndex])) { + commandIndex += 2; + } else if (/^(?:equ|neq|lss|leq|gtr|geq)$/i.test(trueBranch[commandIndex + 1])) { + commandIndex += 3; + } else { + commandIndex += 1; + } + scanCmdWords( + trueBranch.slice(commandIndex), + depth, + findings, + analysis, + scanState, + wrapperDepth + 1 + ); + if (elseIndex !== -1) { + scanCmdWords( + words.slice(elseIndex + 1), + depth, + findings, + analysis, + scanState, + wrapperDepth + 1 + ); + } + return; + } + if (firstCommand === 'for') { + const doIndex = words.findIndex(word => /^do$/i.test(word)); + if (doIndex !== -1) { + scanCmdWords( + words.slice(doIndex + 1), + depth, + findings, + analysis, + scanState, + wrapperDepth + 1 + ); + } + return; + } + if (firstCommand === 'call') { + scanCmdWords(words.slice(1), depth, findings, analysis, scanState, wrapperDepth + 1); + return; + } + if (firstCommand === 'start') { + words = words.slice(1); + while (words.length > 0 && /^\//.test(words[0])) { + const option = words.shift().toLowerCase(); + if (/^\/(?:d|node|affinity)$/.test(option)) words.shift(); + } + const knownCommands = new Set([ + ...CMD_DELETE_COMMANDS, + ...POWERSHELL_COMMANDS, + 'call', + 'cmd', + 'for', + 'if', + 'start', + ]); + if (words.length > 1 && !knownCommands.has(commandBasename(words[0]))) { + const commandIndex = words.findIndex(word => knownCommands.has(commandBasename(word))); + if (commandIndex > 0) words = words.slice(commandIndex); + } + scanCmdWords(words, depth, findings, analysis, scanState, wrapperDepth + 1); + return; + } + + if (POWERSHELL_COMMANDS.has(firstCommand) || firstCommand === 'cmd') { + addNestedScan( + [firstCommand, ...words.slice(1)].join(' '), + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + return; + } + if (CMD_DELETE_COMMANDS.has(firstCommand) && words.slice(1).some(word => /^[-/]s$/i.test(word))) { + findings.add(RULE_IDS.CMD_RECURSIVE_DELETE); + } +} + +function scanCmd(tokens, depth, findings, analysis, scanState) { + const flagIndex = tokens.findIndex((token, index) => index > 0 && /^\/[ck]$/i.test(token)); + if (flagIndex === -1) return; + + const payload = tokens + .slice(flagIndex + 1) + .filter(token => token !== '--%') + .join(' '); + for (const segment of splitCmdSegments(payload)) { + scanCmdWords( + segment.trim().split(/\s+/), + depth, + findings, + analysis, + scanState + ); + } +} + +function scanDeleteSegment(tokens, findings, quotedTokens = []) { + if (tokens.length === 0) return false; + + const command = commandBasename(tokens[0]); + if (!DELETE_COMMANDS.has(command)) return false; + + const usesLiteralPath = tokens.slice(1).some( + (token, index) => !quotedTokens[index + 1] && isParameterPrefix(token, 'literalpath') + ); + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!quotedTokens[index] && token.startsWith('@')) { + findings.add(RULE_IDS.REMOVE_SPLAT); + continue; + } + if (!quotedTokens[index] && isEnabledSwitch(token, 'recurse')) { + findings.add(RULE_IDS.REMOVE_RECURSE); + continue; + } + if (!quotedTokens[index] && isEnabledSwitch(token, 'force')) { + findings.add(RULE_IDS.REMOVE_FORCE); + continue; + } + if (!usesLiteralPath && !token.startsWith('-') && /[*?]/.test(token)) { + findings.add(RULE_IDS.REMOVE_WILDCARD); + } + } + + return true; +} + +function parameterValue(token) { + const separator = String(token || '').indexOf(':'); + return separator === -1 ? '' : String(token).slice(separator + 1); +} + +function startProcessParameterName(token) { + const raw = String(token || ''); + if (!raw.startsWith('-')) return null; + const name = raw.replace(/^-+/, '').split(':')[0].toLowerCase(); + if (name === 'args') return 'argumentlist'; + const candidates = [...START_PROCESS_VALUE_PARAMETERS, ...START_PROCESS_SWITCH_PARAMETERS] + .filter(parameter => parameter.startsWith(name)); + return candidates.length === 1 ? candidates[0] : null; +} + +function normalizeArgumentList(parts) { + let payload = parts.join(' ').trim(); + if (/^@?\(/.test(payload) && /\)$/.test(payload)) { + payload = payload.replace(/^@?\(\s*/, '').replace(/\s*\)$/, ''); + } + return payload.replace(/\s*,\s*/g, ' ').trim(); +} + +function scanStartProcess(tokens, depth, findings, analysis, scanState) { + const command = commandBasename(tokens[0]); + if (!['start-process', 'saps', 'start'].includes(command)) return; + if (tokens.slice(1).some((token, index) => + !(tokens.quotedTokens || [])[index + 1] && + /^@(?:(?:global|script|local|private):)?[A-Za-z_][\w-]*$/i.test(token) + )) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + + let executable = null; + let argumentParts = null; + const quotedTokens = tokens.quotedTokens || []; + + for (let index = 1; index < tokens.length; index += 1) { + if (quotedTokens[index]) continue; + const parameter = startProcessParameterName(tokens[index]); + if (parameter !== 'filepath') continue; + executable = parameterValue(tokens[index]) || tokens[index + 1] || null; + break; + } + + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const quoted = quotedTokens[index] === true; + const parameter = quoted ? null : startProcessParameterName(token); + if (parameter === 'argumentlist') { + const inlineValue = parameterValue(token); + const end = tokens.findIndex( + (candidate, candidateIndex) => candidateIndex > index && + !quotedTokens[candidateIndex] && startProcessParameterName(candidate) + ); + const remaining = tokens.slice(index + 1, end === -1 ? tokens.length : end); + argumentParts = inlineValue ? [inlineValue, ...remaining] : remaining; + break; + } + if (parameter) { + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + continue; + } + if (!quoted && token.startsWith('-')) continue; + if (executable !== null) continue; + if (executable === null) { + executable = token; + } + } + + if (argumentParts === null && executable !== null) { + let executableSeen = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + const parameter = quotedTokens[index] ? null : startProcessParameterName(token); + if (parameter) { + if (parameter === 'filepath') executableSeen = true; + if (START_PROCESS_VALUE_PARAMETERS.has(parameter) && !parameterValue(token)) index += 1; + continue; + } + if (!executableSeen && token === executable) { + executableSeen = true; + continue; + } + if (executableSeen) { + argumentParts = tokens.slice(index); + break; + } + if (!quotedTokens[index] && token.startsWith('-')) continue; + } + } + + const nestedCommand = commandBasename(executable); + let argumentList = argumentParts ? normalizeArgumentList(argumentParts) : ''; + if (POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') { + const argumentReference = variableReference(argumentList); + if (argumentReference) { + const staticValue = scanState.staticScalars.get(argumentReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + return; + } + argumentList = staticValue; + } + } + if ((POWERSHELL_COMMANDS.has(nestedCommand) || nestedCommand === 'cmd') && argumentList) { + addNestedScan( + `${executable} ${argumentList}`, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + scanState + ); + } +} + +function isAssignmentTarget(value) { + const variable = String(value || ''); + const oneTarget = String.raw`(?:\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.[A-Za-z_][\w-]*)*)`; + return new RegExp(`^(?:\\[[^\\]]+\\])?${oneTarget}(?:,${oneTarget})*$`).test(variable); +} + +function executableSegment(tokens) { + if (!tokens || tokens.length === 0) { + return { firstTokenQuoted: false, quotedTokens: [], tokens: [] }; + } + const first = String(tokens[0] || ''); + const inlineAssignment = first.match(/^(.+?)(\+=|-=|\*=|\/=|%=|=)(.+)$/); + if (inlineAssignment && isAssignmentTarget(inlineAssignment[1])) { + return { + firstTokenQuoted: false, + quotedTokens: [false, ...(tokens.quotedTokens || []).slice(1)], + tokens: [inlineAssignment[3], ...tokens.slice(1)], + }; + } + if (tokens.length >= 2 && isAssignmentTarget(first) && /^(?:=|\+=|-=|\*=|\/=|%=)$/.test(tokens[1])) { + return { + firstTokenQuoted: tokens.quotedTokens?.[2] === true, + quotedTokens: (tokens.quotedTokens || []).slice(2), + tokens: tokens.slice(2), + }; + } + if (/^(?:return)$/i.test(first) && tokens.length > 1) { + return { + firstTokenQuoted: tokens.quotedTokens?.[1] === true, + quotedTokens: (tokens.quotedTokens || []).slice(1), + tokens: tokens.slice(1), + }; + } + return { + firstTokenQuoted: tokens.quotedTokens?.[0] === true, + quotedTokens: tokens.quotedTokens || [], + tokens, + }; +} + +function newObjectClassName(tokens, quotedTokens = []) { + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!quotedTokens[index] && isParameterPrefix(token, 'typename')) { + return parameterValue(token) || tokens[index + 1] || null; + } + if (!String(token).startsWith('-')) return token; + } + return null; +} + +function markPowerShellElevation(tokens, analysis) { + if (!analysis || analysis.elevated || tokens.length === 0) return; + const commandName = commandBasename(tokens[0]); + if (['set-acl', 'icacls', 'takeown', 'runas', 'sudo', 'chmod', 'chown'].includes(commandName)) { + analysis.elevated = true; + return; + } + if (!['start-process', 'saps', 'start'].includes(commandName)) return; + + for (let index = 1; index < tokens.length; index += 1) { + const token = tokens[index]; + if (!isParameterPrefix(token, 'verb')) continue; + const inlineValue = String(token).split(':').slice(1).join(':'); + const value = inlineValue || tokens[index + 1] || ''; + if (/^runas$/i.test(value)) analysis.elevated = true; + return; + } +} + +function scanScriptBlockConsumer(tokens, quotedTokens, findings, state) { + const command = commandBasename(tokens[0]); + const consumers = new Set([ + 'foreach', + 'foreach-object', + 'icm', + 'invoke-command', + 'measure-command', + 'register-engineevent', + 'register-objectevent', + 'register-wmievent', + 'start-job', + 'sajb', + 'trace-command', + 'where', + 'where-object', + '%', + '?', + ]); + if (!consumers.has(command)) return; + + for (const token of tokens.slice(1)) { + const reference = variableReference(token); + if (reference && state.deferredFunctions.has(reference)) recordInvocation(state, reference); + } + + if (tokens.length === 2) { + const positionalReference = variableReference(tokens[1]); + if (positionalReference) { + recordInvocation(state, positionalReference); + if (!state.deferredFunctions.has(positionalReference)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + return; + } + } + + const parameters = [ + 'action', + 'begin', + 'end', + 'expression', + 'filter', + 'initializationscript', + 'parallel', + 'process', + 'scriptblock', + ]; + for (let index = 1; index < tokens.length; index += 1) { + if (quotedTokens[index]) continue; + const parameter = parameters.find(name => isParameterPrefix(tokens[index], name)); + if (!parameter) continue; + const reference = variableReference(parameterValue(tokens[index]) || tokens[index + 1]); + if (!reference) continue; + recordInvocation(state, reference); + if (!state.deferredFunctions.has(reference)) findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } +} + +function aliasParameterName(token) { + const name = String(token).replace(/^-+/, '').split(':')[0].toLowerCase(); + if (Object.hasOwn(ALIAS_PARAMETER_ABBREVIATIONS, name)) return ALIAS_PARAMETER_ABBREVIATIONS[name]; + const parameters = [...ALIAS_VALUE_PARAMETERS, ...ALIAS_SWITCH_PARAMETERS]; + if (parameters.includes(name)) return name; + const matches = parameters.filter(parameter => name && parameter.startsWith(name)); + return matches.length === 1 ? matches[0] : null; +} + +function aliasArguments(tokens, quotedTokens) { + const named = new Map(); + const positional = []; + let ambiguous = false; + for (let index = 1; index < tokens.length; index += 1) { + const token = String(tokens[index]); + if (quotedTokens[index] || !token.startsWith('-')) { + positional.push({ index, inline: false }); + if (!quotedTokens[index] && token.startsWith('@')) ambiguous = true; + continue; + } + const parameter = aliasParameterName(token); + if (!parameter) { + ambiguous = true; + continue; + } + if (named.has(parameter)) ambiguous = true; + const inline = token.includes(':'); + const argument = { index: ALIAS_VALUE_PARAMETERS.has(parameter) && !inline ? ++index : index, inline }; + if (ALIAS_VALUE_PARAMETERS.has(parameter) && tokens[argument.index] === undefined) ambiguous = true; + named.set(parameter, argument); + // Option accepts a comma-separated array; its continuation belongs to the + // named parameter rather than the remaining positional name/value slots. + if (parameter === 'option') { + while (index + 1 < tokens.length && !quotedTokens[index] && + (String(tokens[index]).endsWith(',') || String(tokens[index + 1]).startsWith(','))) index += 1; + } + } + return { named, positional, ambiguous }; +} + +function staticAliasDefinitions(tokens, quotedTokens, state) { + const args = aliasArguments(tokens, quotedTokens); + // Definitions stay inert. Uncertain binding is gated only when a candidate + // alias is invoked, without allowing auxiliary values to hide its target. + const unresolved = new Set(); + const resolve = argument => argument + ? staticTokenValue(tokens, argument.index, state, unresolved, argument.inline) + : null; + let positionalIndex = 0; + const nameArgument = args.named.get('name') || args.positional[positionalIndex++]; + const valueArgument = args.named.get('value') || args.positional[positionalIndex++]; + const name = resolve(nameArgument); + const value = resolve(valueArgument); + const ambiguous = args.ambiguous || positionalIndex < args.positional.length; + const possibleNames = args.named.has('value') ? args.positional : args.positional.slice(0, -1); + const names = ambiguous && !args.named.has('name') + ? [name, ...possibleNames.map(resolve)] + : [name]; + const target = ambiguous || value === null || /^@/.test(value) + ? DYNAMIC_EXECUTION_MARKER + : value; + if (!/^[A-Za-z_][\w./\\-]*$/.test(target || '')) return []; + return [...new Set(names.filter(candidate => /^[A-Za-z_][\w-]*$/.test(candidate || '')))] + .map(candidate => ({ name: candidate.toLowerCase(), value: target })); +} + +function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) { + const pattern = /(?:\$\{executioncontext\}|\$executioncontext)\.invokecommand\.invokescript\s*\(/gi; + while (pattern.exec(unquoted) !== null) { + const argumentSource = source.slice(pattern.lastIndex); + const payload = leadingStaticStringResult(argumentSource); + if (payload === null) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else { + addNestedScan( + payload, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + state + ); + } + } +} + +function scanPowerShell(command, depth, findings, analysis = null, options = {}, scanState = null) { + const raw = normalizeSmartQuotes(command); + if (!raw.trim()) return; + + const state = scanState || createScanState(); + + const hereStringExpressions = []; + const normalizedHereStrings = normalizeHereStrings(raw, hereStringExpressions); + const withoutComments = stripPowerShellComments(normalizedHereStrings); + collectStaticScalarAssignments(withoutComments, state); + const unquoted = maskQuotedStrings(withoutComments); + scanInvokeScriptCalls(withoutComments, unquoted, depth, findings, analysis, state); + if (/\[\s*(?:system\.)?io\.directory\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { + findings.add(RULE_IDS.DOTNET_DIRECTORY_DELETE); + } + if (/\[\s*(?:system\.)?io\.file\s*\]\s*::\s*delete\s*\(/i.test(unquoted)) { + findings.add(RULE_IDS.DOTNET_FILE_DELETE); + } + const activatorPattern = /\[\s*(?:system\.)?activator\s*\]\s*::\s*createinstance\s*\(\s*\[([A-Za-z_][\w-]*)\]/gi; + let activatorMatch; + while ((activatorMatch = activatorPattern.exec(unquoted)) !== null) { + recordInvocation(state, `__class__:${activatorMatch[1].toLowerCase()}`); + } + for (const payload of hereStringExpressions) { + addNestedScan(payload, depth, findings, analysis, { executeBareScriptBlocks: true }, state); + } + + const { bodies, deferredFunctions, outer } = extractExecutableContainers(withoutComments, { + ...options, + staticScalars: state.staticScalars, + }); + for (const definition of deferredFunctions) { + registerDeferredFunction(state, { ...definition, depth }); + } + const invokedBlockVariable = /(\$\{[^}]+\}|\$(?:[A-Za-z_][\w-]*:)?[A-Za-z_][\w-]*(?:\[[^\]]+\]|\.(?!getnewclosure\b)[A-Za-z_][\w-]*)*)(?:\.getnewclosure\s*\(\s*\))+\.\s*(?:invoke|invokereturnasis|invokewithcontext)\s*\(/gi; + let invokedBlockMatch; + while ((invokedBlockMatch = invokedBlockVariable.exec(unquoted)) !== null) { + recordInvocation(state, invokedBlockMatch[1].toLowerCase()); + } + for (const entry of bodies) { + addNestedScan(entry.body, depth, findings, analysis, entry.options, state); + } + + for (const statement of parseStatements(outer)) { + const deleteSegments = new Set(); + const recurseSegments = new Set(); + + for (let index = 0; index < statement.length; index += 1) { + const segmentTokens = statement[index]; + const executable = executableSegment(segmentTokens); + const tokens = executable.tokens; + if (tokens.length === 0) continue; + if (executable.firstTokenQuoted && !segmentTokens.invokedByCallOperator) continue; + const commandName = commandBasename(tokens[0]); + recordInvocation(state, commandName); + const aliasTarget = state.aliases.get(commandName); + if (aliasTarget) { + addNestedScan( + [aliasTarget, ...tokens.slice(1)].join(' '), + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + state + ); + } + if (['set-alias', 'new-alias', 'sal', 'nal'].includes(commandName)) { + for (const definition of staticAliasDefinitions(tokens, executable.quotedTokens, state)) { + state.aliases.set(definition.name, definition.value); + } + } + const classInvocation = commandName.match(/^\[([a-z_][\w-]*)\]::/i); + if (classInvocation) recordInvocation(state, `__class__:${classInvocation[1].toLowerCase()}`); + if (commandName === 'new-object') { + let className = newObjectClassName(tokens, executable.quotedTokens); + const classReference = variableReference(className); + if (classReference) { + const staticClassName = state.staticScalars.get(classReference); + if (staticClassName === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + className = null; + } else { + className = staticClassName; + } + } + if (className && /^[A-Za-z_][\w-]*$/.test(className)) { + recordInvocation(state, `__class__:${className.toLowerCase()}`); + } + } + const invokedVariable = commandName.match( + /^((?:\$\{[^}]+\}|\$(?:[a-z_][\w-]*:)?[a-z_][\w-]*(?:\[[^\]]+\]|\.[a-z_][\w-]*)*))(?:\.getnewclosure\(\))*\.(?:invoke|invokereturnasis|invokewithcontext)(?:\(|$)/i + ); + if (invokedVariable) recordInvocation(state, invokedVariable[1].toLowerCase()); + if (commandName === '.' && tokens[1]) { + recordInvocation(state, commandBasename(tokens[1])); + } + markPowerShellElevation(tokens, analysis); + scanStartProcess(tokens, depth, findings, analysis, state); + scanScriptBlockConsumer(tokens, executable.quotedTokens, findings, state); + if (tokens.some(token => commandBasename(token) === DYNAMIC_EXECUTION_MARKER)) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } + + const invokedReference = segmentTokens.invokedByCallOperator + ? variableReference(tokens[0]) + : null; + if (invokedReference && !state.deferredFunctions.has(invokedReference)) { + const commandValue = state.staticScalars.get(invokedReference); + if (commandValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + } else if (POWERSHELL_COMMANDS.has(commandBasename(commandValue))) { + scanNestedPowerShell( + [commandValue, ...tokens.slice(1)], + depth, + findings, + analysis, + state, + statement[index - 1] + ); + } else { + addNestedScan( + [commandValue, ...tokens.slice(1)].join(' '), + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + state + ); + } + } + + if (POWERSHELL_COMMANDS.has(commandName)) { + scanNestedPowerShell(tokens, depth, findings, analysis, state, statement[index - 1]); + } else if (commandName === 'cmd') { + scanCmd(tokens, depth, findings, analysis, state); + } else if (commandName === 'invoke-expression' || commandName === 'iex') { + let payload = tokens.slice(1).join(' '); + const payloadReference = variableReference(payload); + if (payloadReference) { + const staticValue = state.staticScalars.get(payloadReference); + if (staticValue === undefined) { + findings.add(RULE_IDS.DYNAMIC_EXECUTION); + payload = ''; + } else { + payload = staticValue; + } + } + if (payload) { + addNestedScan( + payload, + depth, + findings, + analysis, + { executeBareScriptBlocks: true }, + state + ); + } + } else if (commandName === 'clear-content' || commandName === 'clc') { + findings.add(RULE_IDS.CLEAR_CONTENT); + } else if (commandName === 'clear-disk') { + findings.add(RULE_IDS.CLEAR_DISK); + } else if (commandName === 'format-volume') { + findings.add(RULE_IDS.FORMAT_VOLUME); + } + + if (scanDeleteSegment(tokens, findings, executable.quotedTokens)) deleteSegments.add(index); + if (tokens.some( + (token, tokenIndex) => !executable.quotedTokens[tokenIndex] && + isEnabledSwitch(token, 'recurse') + )) { + recurseSegments.add(index); + } + } + + const hasUpstreamRecurse = [...recurseSegments].some(index => !deleteSegments.has(index)); + if (statement.length > 1 && deleteSegments.size > 0 && hasUpstreamRecurse) { + findings.add(RULE_IDS.PIPELINE_RECURSE); + } + } + +} + +function resolveDeferredFunctions(findings, analysis, state) { + state.pendingInvocations.push(...state.invokedCommands); + state.resolvingFunctions = true; + for (let cursor = 0; cursor < state.pendingInvocations.length; cursor += 1) { + const commandName = state.pendingInvocations[cursor]; + const definitions = state.deferredFunctions.get(commandName) || []; + for (const definition of definitions) { + if (state.scannedFunctions.has(definition)) continue; + state.scannedFunctions.add(definition); + const options = definition.functionName.startsWith('__class__:') + ? { executeBareScriptBlocks: true } + : {}; + addNestedScan(definition.body, definition.depth, findings, analysis, options, state); + } + } + state.resolvingFunctions = false; +} + +function classifyPowerShellDestructiveCommand(command) { + if (typeof command !== 'string' || !command.trim()) return []; + + const findings = new Set(); + const state = createScanState(); + scanPowerShell(command, 0, findings, null, {}, state); + resolveDeferredFunctions(findings, null, state); + return [...findings]; +} + +function isElevatedPowerShellCommand(command) { + if (typeof command !== 'string' || !command.trim()) return false; + + const analysis = { elevated: false }; + const state = createScanState(); + const findings = new Set(); + scanPowerShell(command, 0, findings, analysis, {}, state); + resolveDeferredFunctions(findings, analysis, state); + return analysis.elevated; +} + +module.exports = { + RULE_IDS, + classifyPowerShellDestructiveCommand, + isElevatedPowerShellCommand, +}; diff --git a/scripts/memory-mcp.mjs b/scripts/memory-mcp.mjs index 7821efbfc..741f864fb 100755 --- a/scripts/memory-mcp.mjs +++ b/scripts/memory-mcp.mjs @@ -3,7 +3,16 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const Ajv = require('ajv'); +const { describeMissingDependencyError } = require('./lib/missing-dependency.js'); + +let Ajv; +try { + Ajv = require('ajv'); +} catch (error) { + process.stderr.write(`ECC memory MCP startup failed: ${describeMissingDependencyError(error) || error.message}\n`); + process.exit(1); +} + const fs = require('fs'); const path = require('path'); const { fileURLToPath } = require('url'); diff --git a/scripts/uninstall.js b/scripts/uninstall.js index abeb2efa8..0a7f41231 100644 --- a/scripts/uninstall.js +++ b/scripts/uninstall.js @@ -61,10 +61,15 @@ function printHuman(result) { return; } - console.log('Uninstall summary:\n'); + // Dry-run output must be phrased as a preview so it can never be mistaken + // for a completed uninstall (#2952). + console.log(`Uninstall summary${result.dryRun ? ' (dry run; nothing was removed)' : ''}:\n`); for (const entry of result.results) { console.log(`- ${entry.adapter.id}`); - console.log(` Status: ${entry.status.toUpperCase()}`); + const statusLabel = result.dryRun && entry.status === 'planned' + ? 'WOULD UNINSTALL (dry run)' + : entry.status.toUpperCase(); + console.log(` Status: ${statusLabel}`); console.log(` Install-state: ${entry.installStatePath}`); if (entry.error) { @@ -84,10 +89,10 @@ function printHuman(result) { const candidatePaths = result.dryRun ? entry.plannedRemovals : entry.removedPaths; const paths = Array.isArray(candidatePaths) ? candidatePaths : []; - console.log(` ${result.dryRun ? 'Planned removals' : 'Removed paths'}: ${paths.length}`); + console.log(` ${result.dryRun ? 'Would remove' : 'Removed paths'}: ${paths.length}`); } - console.log(`\nSummary: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'uninstalled'}=${result.dryRun ? result.summary.plannedRemovalCount : result.summary.uninstalledCount}, partial=${result.summary.partialCount}, errors=${result.summary.errorCount}`); + console.log(`\nSummary${result.dryRun ? ' (dry run)' : ''}: checked=${result.summary.checkedCount}, ${result.dryRun ? 'planned' : 'uninstalled'}=${result.dryRun ? result.summary.plannedRemovalCount : result.summary.uninstalledCount}, partial=${result.summary.partialCount}, errors=${result.summary.errorCount}`); if (!result.dryRun) { console.log(`\n${exitFeedbackLines().join('\n')}`); @@ -114,6 +119,20 @@ function codexHomePath() { return process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex'); } +/** + * Dry-run is enabled either by the subcommand-level `--dry-run` flag or by the + * global `ecc --dry-run ` prefix, which sets ECC_DRY_RUN=1 (#2952). + * Destructive subcommands must honor both forms rather than silently ignoring + * the global flag. + */ +function isDryRun(options) { + const dryRunEnv = process.env.ECC_DRY_RUN; + if (dryRunEnv !== undefined && dryRunEnv !== '0' && dryRunEnv !== '1') { + throw new Error('ECC_DRY_RUN must be "1" or "0" when set'); + } + return options.dryRun || dryRunEnv === '1'; +} + function includesCodexTarget(targets) { return targets.length === 0 || targets.includes('codex'); } @@ -125,6 +144,8 @@ async function main() { showHelp(0); } + const dryRun = isDryRun(options); + if (options.legacyCodexSync && options.targets.length > 0) { throw new Error('--legacy-codex-sync cannot be combined with --target'); } @@ -135,7 +156,7 @@ async function main() { if (options.legacyCodexSync) { result = uninstallLegacyCodexSync({ codexHome: codexHomePath(), - dryRun: options.dryRun, + dryRun, }); mode = 'legacy-codex-sync'; } else { @@ -144,7 +165,7 @@ async function main() { env: process.env, projectRoot: process.cwd(), targets: options.targets, - dryRun: options.dryRun, + dryRun, }); if ( @@ -154,12 +175,12 @@ async function main() { ) { result = uninstallLegacyCodexSync({ codexHome: codexHomePath(), - dryRun: options.dryRun, + dryRun, }); mode = 'legacy-codex-sync'; } - if (mode === 'install-state' && !options.dryRun) { + if (mode === 'install-state' && !dryRun) { const { reconcileCanonicalInstallStates } = require('./lib/install-state-store-sync'); result.installStateProjection = await reconcileCanonicalInstallStates({ homeDir: process.env.HOME || os.homedir(), @@ -177,7 +198,7 @@ async function main() { if (options.json) { console.log(JSON.stringify(result, null, 2)); } else if (mode === 'legacy-codex-sync') { - printLegacy(result, options.dryRun); + printLegacy(result, dryRun); } else { printHuman(result); } diff --git a/skills/autonomous-agent-harness/SKILL.md b/skills/autonomous-agent-harness/SKILL.md index f2e3929ab..2f92a5a17 100644 --- a/skills/autonomous-agent-harness/SKILL.md +++ b/skills/autonomous-agent-harness/SKILL.md @@ -7,7 +7,7 @@ metadata: # Autonomous Agent Harness -Turn Claude Code into a persistent, self-directing agent system using only native features and MCP servers. +Combine Claude Code's session tools with separately configured scheduling, memory, and computer-use integrations. This is a setup pattern, not a bundled always-on runtime. ## Consent and Safety Boundaries @@ -85,23 +85,23 @@ Use mcp__memory__add_observations for new facts about known entities ### 2. Scheduled Operations (Crons) -Use Claude Code's scheduled tasks to create recurring agent operations. +Use Claude Code's native [scheduled tasks](https://code.claude.com/docs/en/scheduled-tasks) for recurring prompts within an interactive session. These tasks are session-scoped; an external scheduler is required for work that must run independently of an open session. No scheduling MCP server is required for `/loop`. **Setting up a cron:** ``` -# Via MCP tool -mcp__scheduled-tasks__create_scheduled_task({ - name: "daily-pr-review", - schedule: "0 9 * * 1-5", # 9 AM weekdays - prompt: "Review all open PRs in affaan-m/everything-claude-code. For each: check CI status, review changes, flag issues. Post summary to memory.", - project_dir: "/path/to/repo" -}) - -# Via claude -p (programmatic mode) -echo "Review open PRs and summarize" | claude -p --project /path/to/repo +# In an interactive Claude Code session +/loop 30m Review open PRs in this repository and summarize CI failures. ``` +For a one-shot run from a shell, set the working directory before invoking the CLI: + +```bash +cd "/path/to/repo" && claude -p "Review open PRs and summarize" +``` + +Use an OS scheduler or CI schedule to invoke that command repeatedly when no interactive session is running. Configure the runner's authentication and tool permissions separately. + **Useful cron patterns:** | Pattern | Schedule | Use Case | @@ -114,18 +114,16 @@ echo "Review open PRs and summarize" | claude -p --project /path/to/repo ### 3. Dispatch / Remote Agents -Trigger Claude Code agents remotely for event-driven workflows. +Have an authenticated CI job or webhook receiver invoke Claude Code in a workspace it owns. The supported entrypoint is [programmatic CLI mode](https://code.claude.com/docs/en/headless), not a public Anthropic dispatch endpoint. **Dispatch patterns:** ```bash -# Trigger from CI/CD -curl -X POST "https://api.anthropic.com/dispatch" \ - -H "Authorization: Bearer $ANTHROPIC_API_KEY" \ - -d '{"prompt": "Build failed on main. Diagnose and fix.", "project": "/repo"}' +# Run inside the CI workspace +cd "/path/to/repo" && claude -p "Build failed on main. Diagnose the failure." # Trigger from webhook -# GitHub webhook → dispatch → Claude agent → fix → PR +# GitHub webhook -> authenticated CI runner -> claude -p -> reviewable result # Trigger from another agent claude -p "Analyze the output of the security scan and create issues for findings" @@ -133,7 +131,7 @@ claude -p "Analyze the output of the security scan and create issues for finding ### 4. Computer Use -Leverage Claude's computer-use MCP for physical world interaction. +Computer control needs a separately configured integration. Anthropic's [computer-use tool and reference environment](https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool) require an application to execute tool calls in an isolated desktop environment. Adding an MCP package name does not supply that environment. **Capabilities:** - Browser automation (navigate, click, fill forms, screenshot) @@ -176,11 +174,11 @@ description: Persistent task queue for autonomous operation | Hermes Component | ECC Equivalent | How | |------------------|---------------|-----| -| Gateway/Router | Claude Code dispatch + crons | Scheduled tasks trigger agent sessions | +| Gateway/Router | CLI + external scheduler | An authenticated runner starts agent sessions | | Memory System | Claude memory + MCP memory server | Built-in persistence + knowledge graph | | Tool Registry | MCP servers | Dynamically loaded tool providers | | Orchestration | ECC skills + agents | Skill definitions direct agent behavior | -| Computer Use | computer-use MCP | Native browser and desktop control | +| Computer Use | Separately configured integration | Browser or desktop control in an isolated environment | | Context Manager | Session management + memory | ECC 2.0 session lifecycle | | Task Queue | Memory-persisted task list | TodoWrite + memory files | @@ -188,37 +186,36 @@ description: Persistent task queue for autonomous operation ### Step 1: Configure MCP Servers -Ensure these are in `~/.claude.json`: +Memory MCP is optional. The [MCP reference memory server](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) is published as `@modelcontextprotocol/server-memory`; version `2026.8.31` was verified on the public npm registry on 2026-09-07. It is a reference implementation, not an ECC-bundled service. + +After reviewing that package and approving its use, merge this entry into the user-scoped MCP configuration in `~/.claude.json`, preserving existing settings. Replace `MEMORY_FILE_PATH` with an absolute path in a private directory you own. See [Claude Code MCP configuration](https://code.claude.com/docs/en/mcp) for CLI registration and Windows `cmd /c npx` configuration. ```json { "mcpServers": { "memory": { "command": "npx", - "args": ["-y", "@anthropic/memory-mcp-server"] - }, - "scheduled-tasks": { - "command": "npx", - "args": ["-y", "@anthropic/scheduled-tasks-mcp-server"] - }, - "computer-use": { - "command": "npx", - "args": ["-y", "@anthropic/computer-use-mcp-server"] + "args": ["-y", "@modelcontextprotocol/server-memory@2026.8.31"], + "env": { + "MEMORY_FILE_PATH": "/absolute/path/to/private/memory.jsonl" + } } } } ``` +Do not register guessed or unpublished npm packages: `npx -y` would execute whatever is later published under that name. Verify the exact package, publisher, and version before adding another server. Scheduling and computer use do not require the three unpublished package names previously listed here. + ### Step 2: Create Base Crons -```bash -# Daily morning briefing -claude -p "Create a scheduled task: every weekday at 9am, review my GitHub notifications, open PRs, and calendar. Write a morning briefing to memory." +For polling during an interactive session, enter: -# Continuous learning -claude -p "Create a scheduled task: every Sunday at 8pm, extract patterns from this week's sessions and update the learned skills." +```text +/loop 30m Review open PRs in this repository and summarize CI failures. ``` +For daily or weekly work that must survive a closed session, configure an external scheduler, such as an OS cron job or GitHub Actions, to run the one-shot command from Step 2 of Core Components. Calling `claude -p` to request a schedule does not provision an always-on scheduler. Choose the schedule, workspace, and allowed actions explicitly before enabling it. + ### Step 3: Initialize Memory Graph ```bash @@ -228,7 +225,7 @@ claude -p "Create memory entities for: me (user profile), my projects, my key co ### Step 4: Enable Computer Use (Optional) -Grant computer-use MCP the necessary permissions for browser and desktop control. +Follow the computer-use reference environment linked above, or the documentation for a specific browser integration you have reviewed. Grant only the required permissions and verify a harmless action in the isolated environment before adding it to scheduled workflows. ## Example Workflows @@ -267,8 +264,8 @@ Trigger: 30 min before each calendar event ## Constraints -- Cron tasks run in isolated sessions — they don't share context with interactive sessions unless through memory. +- Native scheduled prompts share their interactive session. External scheduler invocations start separate sessions unless explicitly resumed. - Computer use requires explicit permission grants. Don't assume access. -- Remote dispatch may have rate limits. Design crons with appropriate intervals. +- CLI automation still consumes model usage and is subject to the configured provider's limits. Choose appropriate scheduler intervals. - Memory files should be kept concise. Archive old data rather than letting files grow unbounded. - Always verify that scheduled tasks completed successfully. Add error handling to cron prompts. diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index 74b8f5110..bce0d2d7c 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -9,6 +9,13 @@ set +e unset CLAUDECODE SLEEP_PID="" +CLAUDE_PID="" +CLAUDE_PROCESS_GROUP=0 +WATCHDOG_PID="" +ACTIVE_ANALYSIS_FILE="" +ACTIVE_PROMPT_FILE="" +ACTIVE_RESULT_FILE="" +RESULT_FDS_OPEN=0 USR1_FIRED=0 PENDING_ANALYSIS=0 ANALYZING=0 @@ -25,7 +32,83 @@ ACTIVITY_FILE="${PROJECT_DIR}/.observer-last-activity" # ${BASH_SOURCE[0]}, which always points at this file (#2370). SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +claude_process_alive() { + local process_pid="$1" + + if [ -z "$process_pid" ]; then + return 1 + fi + + if [ "$CLAUDE_PROCESS_GROUP" -eq 1 ]; then + kill -0 -- "-$process_pid" 2>/dev/null + else + kill -0 "$process_pid" 2>/dev/null + fi +} + +signal_claude_process() { + local process_pid="$1" + local signal_name="$2" + + if [ "$CLAUDE_PROCESS_GROUP" -eq 1 ]; then + kill -"$signal_name" -- "-$process_pid" 2>/dev/null || true + else + kill -"$signal_name" "$process_pid" 2>/dev/null || true + fi +} + +stop_claude_process() { + local process_pid="$1" + local attempts=0 + + if [ -z "$process_pid" ]; then + return + fi + + if claude_process_alive "$process_pid"; then + signal_claude_process "$process_pid" TERM + while claude_process_alive "$process_pid" && [ "$attempts" -lt 20 ]; do + sleep 0.1 + attempts=$((attempts + 1)) + done + if claude_process_alive "$process_pid"; then + signal_claude_process "$process_pid" KILL + fi + fi + wait "$process_pid" 2>/dev/null || true + CLAUDE_PROCESS_GROUP=0 +} + +cleanup_analysis_resources() { + if [ -n "$WATCHDOG_PID" ]; then + kill "$WATCHDOG_PID" 2>/dev/null || true + wait "$WATCHDOG_PID" 2>/dev/null || true + WATCHDOG_PID="" + fi + if [ -n "$CLAUDE_PID" ]; then + stop_claude_process "$CLAUDE_PID" + CLAUDE_PID="" + fi + + if [ "$RESULT_FDS_OPEN" -eq 1 ]; then + { exec 8>&-; } 2>/dev/null || true + if [ -n "${LOG_FILE:-}" ]; then + cat <&9 >> "$LOG_FILE" 2>/dev/null || true + fi + { exec 7<&-; } 2>/dev/null || true + { exec 9<&-; } 2>/dev/null || true + RESULT_FDS_OPEN=0 + fi + [ -n "$ACTIVE_ANALYSIS_FILE" ] && rm -f "$ACTIVE_ANALYSIS_FILE" + [ -n "$ACTIVE_PROMPT_FILE" ] && rm -f "$ACTIVE_PROMPT_FILE" + [ -n "$ACTIVE_RESULT_FILE" ] && rm -f "$ACTIVE_RESULT_FILE" + ACTIVE_ANALYSIS_FILE="" + ACTIVE_PROMPT_FILE="" + ACTIVE_RESULT_FILE="" +} + cleanup() { + cleanup_analysis_resources [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null if [ -f "$PID_FILE" ] && [ "$(cat "$PID_FILE" 2>/dev/null)" = "$$" ]; then rm -f "$PID_FILE" @@ -149,7 +232,17 @@ analyze_observations() { # substitutes a trailing X run, so a suffix after it (e.g. `.jsonl`) produces a # literal, non-random name that wedges every later cycle with "File exists" (#2417). analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.jsonl.XXXXXX")" - tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file" + if [ -z "$analysis_file" ] || [ ! -f "$analysis_file" ]; then + echo "[$(date)] Failed to create observer analysis file; retaining observations for retry" >> "$LOG_FILE" + return + fi + ACTIVE_ANALYSIS_FILE="$analysis_file" + + if ! tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file"; then + echo "[$(date)] Failed to snapshot observations; retaining them for retry" >> "$LOG_FILE" + cleanup_analysis_resources + return + fi analysis_count=$(wc -l < "$analysis_file" 2>/dev/null || echo 0) echo "[$(date)] Using last $analysis_count of $obs_count observations for analysis" >> "$LOG_FILE" @@ -166,6 +259,12 @@ analyze_observations() { fi prompt_file="$(mktemp "${observer_tmp_dir}/ecc-observer-prompt.XXXXXX")" + if [ -z "$prompt_file" ] || [ ! -f "$prompt_file" ]; then + echo "[$(date)] Failed to create observer prompt file; retaining observations for retry" >> "$LOG_FILE" + cleanup_analysis_resources + return + fi + ACTIVE_PROMPT_FILE="$prompt_file" cat > "$prompt_file" </dev/null || true)" rm -f "$prompt_file" + ACTIVE_PROMPT_FILE="" if [ -z "$prompt_content" ]; then echo "[$(date)] Failed to load observer prompt content, skipping analysis" >> "$LOG_FILE" - rm -f "$analysis_file" + cleanup_analysis_resources return fi @@ -249,7 +356,30 @@ PROMPT # Ensure CWD is PROJECT_DIR so the relative analysis_relpath resolves correctly # on all platforms, not just when the observer happens to be launched from the project root. - cd "$PROJECT_DIR" || { echo "[$(date)] Failed to cd to PROJECT_DIR ($PROJECT_DIR), skipping analysis" >> "$LOG_FILE"; rm -f "$analysis_file"; return; } + cd "$PROJECT_DIR" || { echo "[$(date)] Failed to cd to PROJECT_DIR ($PROJECT_DIR), skipping analysis" >> "$LOG_FILE"; cleanup_analysis_resources; return; } + + analysis_result_file="$(mktemp "${observer_tmp_dir}/ecc-observer-result.XXXXXX")" + if [ -z "$analysis_result_file" ] || [ ! -f "$analysis_result_file" ]; then + echo "[$(date)] Failed to create observer result file, skipping analysis" >> "$LOG_FILE" + cleanup_analysis_resources + return + fi + ACTIVE_RESULT_FILE="$analysis_result_file" + + # Keep validation bound to the inode created by mktemp. Removing the path + # after opening both descriptors prevents a workspace process from replacing + # it with a forged completion record while Claude is running. + RESULT_FDS_OPEN=1 + if ! { exec 7<"$analysis_result_file" && exec 9<"$analysis_result_file" && exec 8>"$analysis_result_file"; }; then + echo "[$(date)] Failed to open observer result descriptors, skipping analysis" >> "$LOG_FILE" + cleanup_analysis_resources + return + fi + if ! rm -f "$analysis_result_file" || [ -e "$analysis_result_file" ] || [ -L "$analysis_result_file" ]; then + echo "[$(date)] Failed to unlink observer result file, skipping analysis" >> "$LOG_FILE" + cleanup_analysis_resources + return + fi # Prevent observe.sh from recording this automated observer session as observations. # Pass prompt via -p flag instead of stdin redirect for Windows compatibility (#842). @@ -262,34 +392,77 @@ PROMPT # e.g. ECC_OBSERVER_MODEL=opus for higher-quality instinct extraction. Heavier models are # slower — consider raising ECC_OBSERVER_TIMEOUT_SECONDS (default 120s) so the watchdog # doesn't kill the analysis mid-run. + # Job control gives the background Claude command its own process group on + # Bash, including macOS's Bash 3.2 and Git Bash. That lets timeout/signal + # cleanup terminate tool subprocesses as well as the direct CLI process. + set -m ECC_SKIP_OBSERVE=1 ECC_HOOK_PROFILE=minimal claude --model "${ECC_OBSERVER_MODEL:-haiku}" --max-turns "$max_turns" --print \ --allowedTools "Read,Write" \ - -p "$prompt_content" < /dev/null >> "$LOG_FILE" 2>&1 & - claude_pid=$! + -p "$prompt_content" < /dev/null >&8 2>> "$LOG_FILE" & + CLAUDE_PID=$! + CLAUDE_PROCESS_GROUP=1 + set +m ( sleep "$timeout_seconds" - if kill -0 "$claude_pid" 2>/dev/null; then + if claude_process_alive "$CLAUDE_PID"; then echo "[$(date)] Claude analysis timed out after ${timeout_seconds}s; terminating process" >> "$LOG_FILE" - kill "$claude_pid" 2>/dev/null || true + signal_claude_process "$CLAUDE_PID" TERM + grace_attempts=0 + while claude_process_alive "$CLAUDE_PID" && [ "$grace_attempts" -lt 20 ]; do + sleep 0.1 + grace_attempts=$((grace_attempts + 1)) + done + if claude_process_alive "$CLAUDE_PID"; then + echo "[$(date)] Claude analysis ignored TERM; killing process" >> "$LOG_FILE" + signal_claude_process "$CLAUDE_PID" KILL + fi fi - ) & - watchdog_pid=$! + ) /dev/null 2>&1 7<&- 8>&- 9<&- & + WATCHDOG_PID=$! - wait_for_claude_analysis "$claude_pid" + wait_for_claude_analysis "$CLAUDE_PID" exit_code=$? - kill "$watchdog_pid" 2>/dev/null || true + completed_claude_pid="$CLAUDE_PID" + CLAUDE_PID="" + kill "$WATCHDOG_PID" 2>/dev/null || true + wait "$WATCHDOG_PID" 2>/dev/null || true + WATCHDOG_PID="" + # A successful CLI can still leave tool subprocesses behind. Terminate any + # remaining members before closing the inherited result descriptors. + if claude_process_alive "$completed_claude_pid"; then + stop_claude_process "$completed_claude_pid" + else + CLAUDE_PROCESS_GROUP=0 + fi + { exec 8>&-; } 2>/dev/null || true + + analysis_complete=0 + if awk '{ sub(/\r$/, "", $0); if ($0 == "{\"status\":\"analysis_complete\"}") count++; if (NF) last = $0 } END { exit !(count == 1 && last == "{\"status\":\"analysis_complete\"}") }' <&7; then + analysis_complete=1 + fi + cat <&9 >> "$LOG_FILE" 2>/dev/null || true + { exec 7<&-; } 2>/dev/null || true + { exec 9<&-; } 2>/dev/null || true + RESULT_FDS_OPEN=0 + rm -f "$analysis_result_file" rm -f "$analysis_file" + ACTIVE_RESULT_FILE="" + ACTIVE_ANALYSIS_FILE="" if [ "$exit_code" -ne 0 ]; then echo "[$(date)] Claude analysis failed (exit $exit_code); retaining observations for retry" >> "$LOG_FILE" return fi - # Archive observations only after a successful analysis. A transient - # failure (timeout, non-zero exit, rate limit) must not discard the batch - # before it has been turned into instincts, since the analyzer only ever - # reads the live observations file (#2370). + if [ "$analysis_complete" -ne 1 ]; then + echo "[$(date)] Claude analysis incomplete (completion record missing); retaining observations for retry" >> "$LOG_FILE" + return + fi + + # Archive observations only after process success and the current analysis + # result's exact completion record. A semantic failure can still exit zero, + # so exit status alone must not discard the only live copy (#2370, #2673). if [ -f "$OBSERVATIONS_FILE" ]; then archive_dir="${PROJECT_DIR}/observations.archive" mkdir -p "$archive_dir" diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 2c37994a7..e7ebc5cec 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -135,16 +135,20 @@ For hook-level control, keep using `ECC_DISABLED_HOOKS` with the GateGuard hook #### Glob semantics for `GATEGUARD_EXEMPT_GLOBS` -Patterns are matched, unanchored, against the target path with backslashes -normalized to `/` and the whole string lowercased — the path exactly as the -hook receives it, which for Claude Code tool payloads is absolute. `*` matches -within a path segment, `**` across segments, `?` a single character. Matching -is fail-open: a malformed pattern is dropped rather than raising. +Patterns match the entire project-relative target path. The project root is +`CLAUDE_PROJECT_DIR`, falling back to the hook payload's `cwd`, then the hook +process working directory. Relative globs never exempt targets outside that +root. Explicit absolute globs match the entire absolute target path and may +deliberately exempt paths outside the project. -Note that a leading `**/` compiles to `.*/`, so it requires at least one -preceding separator: `**/tests/**` exempts `/repo/tests/foo.js` but would not -match a bare relative `tests/foo.js`. Add the separator-free form too if you -pass relative paths: +Both patterns and paths use `/` separators and lowercase matching. `*` matches +within a segment, `**` across segments, and `?` one non-separator character. +`**/` includes zero directories, so `**/tests/**` also matches `tests/foo.js`. +Malformed patterns are dropped without granting an exemption. + +Since 2.2.1, `services/**` only covers the project's root services tree, and +`*.md` only covers its root Markdown files. Use `**/*.md` for all Markdown +files within the project. Existing unanchored exemptions may need adjustment: ```json { diff --git a/tests/ci/run-all.test.js b/tests/ci/run-all.test.js new file mode 100644 index 000000000..94a7274ef --- /dev/null +++ b/tests/ci/run-all.test.js @@ -0,0 +1,119 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync(path.join(__dirname, '..', 'run-all.js'), 'utf8'); + +function run(result, filename = 'sample.test.js', actions = true) { + const logs = []; + const exit = {}; + let status; + let spawns = 0; + const fakeProcess = { + env: actions ? { GITHUB_ACTIONS: 'true' } : {}, + exit(code) { status = code; throw exit; }, + }; + const fakeFs = { + readdirSync: () => [{ + name: filename, + isDirectory: () => false, + isFile: () => true, + }], + existsSync: () => true, + }; + try { + vm.runInNewContext(source, { + __dirname: path.resolve('/virtual/tests'), + process: fakeProcess, + console: { log: (...args) => logs.push(args.join(' ')) }, + require(name) { + if (name === 'fs') return fakeFs; + if (name === 'path') return path; + if (name === 'child_process') return { + spawnSync() { spawns += 1; return result; }, + }; + throw new Error(`Unexpected dependency: ${name}`); + }, + }); + } catch (error) { + if (error !== exit) throw error; + } + assert.strictEqual(spawns, 1); + return { status, logs, annotations: logs.filter(line => line.startsWith('::error ')) }; +} + +const tests = [ + ['nonzero exit overrides a zero-failure summary', () => { + const result = run({ status: 1, stdout: 'Passed: 2, Failed: 0', stderr: 'Error: late crash' }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /file=tests\/sample.test.js/); + assert.match(result.annotations[0], /status 1.*Error: late crash/); + assert.ok(result.logs.includes('Error: late crash')); + }], + ['startup errors always count as failures and annotate their cause', () => { + const result = run({ status: null, stdout: 'Failed: 0', error: new Error('spawn node ENOENT') }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /failed to start.*spawn node ENOENT/); + }], + ['annotation properties and messages escape workflow command characters', () => { + const result = run({ status: null, error: new Error('100% broken\r\nnext line') }, 'sample%,:.test.js'); + assert.strictEqual(result.annotations.length, 1); + assert.ok(result.annotations[0].includes('file=tests/sample%25%2C%3A.test.js')); + assert.ok(result.annotations[0].includes('100%25 broken%0D%0Anext line')); + assert.ok(!result.annotations[0].includes('\n')); + assert.ok(!result.annotations[0].includes('\r')); + }], + ['failure summaries annotate concise context even with a successful exit', () => { + const output = `${'routine log\n'.repeat(100)}FAIL regression example\nPassed: 2, Failed: 1`; + const result = run({ status: 0, stdout: output }); + assert.strictEqual(result.status, 1); + assert.strictEqual(result.annotations.length, 1); + assert.match(result.annotations[0], /FAIL regression example/); + assert.ok(result.annotations[0].length < 1500); + assert.ok(!result.annotations[0].includes('routine log')); + assert.ok(result.logs.includes(output)); + assert.ok(result.logs.some(line => /Failed:\s+1\s/.test(line))); + }], + ['signals fail even when no summary was printed', () => { + const result = run({ status: null, signal: 'SIGTERM' }); + assert.strictEqual(result.status, 1); + assert.match(result.annotations[0], /SIGTERM/); + }], + ['passing error-handling cases cannot hide the actual failure', () => { + const output = `${'PASS handles Error conditions\n'.repeat(5)}FAIL actual regression\n AssertionError: mismatch\nFailed: 1`; + const result = run({ status: 1, stdout: output }); + assert.match(result.annotations[0], /FAIL actual regression/); + assert.match(result.annotations[0], /AssertionError: mismatch/); + assert.ok(!result.annotations[0].includes('PASS handles')); + }], + ['healthy suites preserve successful totals and emit no annotation', () => { + const result = run({ status: 0, stdout: 'Passed: 3, Failed: 0' }); + assert.strictEqual(result.status, 0); + assert.deepStrictEqual(result.annotations, []); + assert.ok(result.logs.some(line => /Passed:\s+3\s/.test(line))); + }], + ['local failures retain console diagnostics without workflow annotations', () => { + const result = run({ status: 1, stderr: 'Error: local failure' }, 'sample.test.js', false); + assert.strictEqual(result.status, 1); + assert.deepStrictEqual(result.annotations, []); + assert.ok(result.logs.includes('Error: local failure')); + }], +]; + +let failed = 0; +for (const [name, test] of tests) { + try { + test(); + console.log(`PASS ${name}`); + } catch (error) { + failed += 1; + console.error(`FAIL ${name}\n${error.stack || error.message}`); + } +} +console.log(`Passed: ${tests.length - failed}, Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js index afac4a469..8f0a92eab 100644 --- a/tests/ci/validators.test.js +++ b/tests/ci/validators.test.js @@ -699,7 +699,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - InvalidEventType: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo hi' }] }] + InvalidEventType: [{ id: 'test:invalid-event', matcher: 'test', hooks: [{ type: 'command', command: 'echo hi' }] }] } })); @@ -714,7 +714,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ command: 'echo hi' }] }] + PreToolUse: [{ id: 'test:missing-type', matcher: 'test', hooks: [{ command: 'echo hi' }] }] } })); @@ -729,7 +729,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command' }] }] + PreToolUse: [{ id: 'test:missing-command', matcher: 'test', hooks: [{ type: 'command' }] }] } })); @@ -744,7 +744,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', async: 'yes' }] }] + PreToolUse: [{ id: 'test:invalid-async', matcher: 'test', hooks: [{ type: 'command', command: 'echo', async: 'yes' }] }] } })); @@ -759,7 +759,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: -5 }] }] + PreToolUse: [{ id: 'test:negative-timeout', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: -5 }] }] } })); @@ -774,7 +774,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "function {"' }] }] + PreToolUse: [{ id: 'test:invalid-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "function {"' }] }] } })); @@ -789,7 +789,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(1+2)"' }] }] + PreToolUse: [{ id: 'test:valid-inline-js', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "console.log(1+2)"' }] }] } })); @@ -803,7 +803,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', '-e', 'console.log(1)'] }] }] + PreToolUse: [{ id: 'test:array-command', matcher: 'test', hooks: [{ type: 'command', command: ['node', '-e', 'console.log(1)'] }] }] } })); @@ -829,7 +829,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test' }] + PreToolUse: [{ id: 'test:missing-hooks', matcher: 'test' }] } })); @@ -1396,7 +1396,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ' \t ' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ' \t ' }] }] } })); @@ -1411,7 +1411,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: null }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: null }] }] } })); @@ -1426,7 +1426,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 42 }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 42 }] }] } })); @@ -1605,7 +1605,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: '' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: '' }] }] } })); @@ -1620,7 +1620,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: [] }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: [] }] }] } })); @@ -1635,7 +1635,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', 123, null] }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ['node', 123, null] }] }] } })); @@ -1650,7 +1650,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 42, command: 'echo hi' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 42, command: 'echo hi' }] }] } })); @@ -1665,7 +1665,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 'fast' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 'fast' }] }] } })); @@ -1680,7 +1680,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 0 }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo', timeout: 0 }] }] } })); @@ -1694,7 +1694,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); // data.hooks is undefined, so fallback to data itself fs.writeFileSync(hooksFile, JSON.stringify({ - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo ok' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo ok' }] }] })); const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); @@ -1796,7 +1796,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: ['node', '', 'script.js'] }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: ['node', '', 'script.js'] }] }] } })); @@ -1811,7 +1811,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo hi', timeout: -5 }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo hi', timeout: -5 }] }] } })); @@ -1826,7 +1826,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PostToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo ok', async: 'yes' }] }] + PostToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'echo ok', async: 'yes' }] }] } })); @@ -1847,7 +1847,7 @@ function runTests() { manyHooks.push({ type: 'command', command: '' }); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: manyHooks }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: manyHooks }] } })); @@ -1862,7 +1862,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'node -e "const x = 1 + 2; process.exit(0)"' }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "const x = 1 + 2; process.exit(0)"' }] }] } })); @@ -1876,9 +1876,9 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo pre' }] }], - PostToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo post' }] }], - Stop: [{ matcher: 'test', hooks: [{ type: 'command', command: 'echo stop' }] }] + PreToolUse: [{ id: 'test:multi-event-pre', matcher: 'test', hooks: [{ type: 'command', command: 'echo pre' }] }], + PostToolUse: [{ id: 'test:multi-event-post', matcher: 'test', hooks: [{ type: 'command', command: 'echo post' }] }], + Stop: [{ id: 'test:multi-event-stop', matcher: 'test', hooks: [{ type: 'command', command: 'echo stop' }] }] } })); @@ -2227,7 +2227,7 @@ function runTests() { // After unescape chain: var a = "ok"\nconsole.log(a) (real newline) — valid JS fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "var a = \\"ok\\"\\nconsole.log(a)"' }] }] } })); @@ -2243,7 +2243,7 @@ function runTests() { // After unescape this becomes: var x = { — missing closing brace fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: 'node -e "var x = {"' }] }] } })); @@ -2427,7 +2427,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ matcher: 'test', hooks: [{ type: 'command', command: { run: 'echo hi' } }] }] + PreToolUse: [{ id: 'test:fixture', matcher: 'test', hooks: [{ type: 'command', command: { run: 'echo hi' } }] }] } })); @@ -2446,7 +2446,7 @@ function runTests() { // Object format: matcher entry has hooks array but NO matcher field fs.writeFileSync(hooksFile, JSON.stringify({ hooks: { - PreToolUse: [{ hooks: [{ type: 'command', command: 'echo ok' }] }] + PreToolUse: [{ id: 'test:missing-matcher', hooks: [{ type: 'command', command: 'echo ok' }] }] } })); @@ -2554,6 +2554,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ PreToolUse: [{ + id: 'test:round72-async', matcher: 'Write', hooks: [{ type: 'command', @@ -2574,6 +2575,7 @@ function runTests() { const hooksFile = path.join(testDir, 'hooks.json'); fs.writeFileSync(hooksFile, JSON.stringify({ PostToolUse: [{ + id: 'test:round72-timeout', matcher: 'Edit', hooks: [{ type: 'command', @@ -2661,8 +2663,8 @@ function runTests() { fs.writeFileSync(hooksFile, JSON.stringify({ "$schema": "https://json.schemastore.org/claude-code-settings.json", hooks: { - PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo ok' }] }], - PostToolUse: [{ matcher: 'Read', hooks: [{ type: 'command', command: 'echo done' }] }] + PreToolUse: [{ id: 'test:wrapped-pre', matcher: 'Write', hooks: [{ type: 'command', command: 'echo ok' }] }], + PostToolUse: [{ id: 'test:wrapped-post', matcher: 'Read', hooks: [{ type: 'command', command: 'echo done' }] }] } })); @@ -2674,6 +2676,105 @@ function runTests() { cleanupTestDir(testDir); })) passed++; else failed++; + if (test('rejects wrapped matcher entry missing id', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo missing id' }] + }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should reject wrapped matcher entries without an id'); + assert.ok(result.stderr.includes('id'), `Should report missing id, got: ${result.stderr}`); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('rejects wrapped matcher entry missing a required matcher', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + SessionStart: [{ + id: 'test:missing-matcher', + hooks: [{ type: 'command', command: 'echo start' }] + }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('matcher'), result.stderr); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('rejects wrapped matcher entry with an empty handlers array', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { Stop: [{ id: 'test:empty-handlers', hooks: [] }] } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('hooks'), result.stderr); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('rejects wrapped matcher entry with whitespace-only id', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ + id: ' \t', + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo blank id' }] + }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should reject whitespace-only matcher ids'); + assert.ok(result.stderr.includes('id'), `Should report invalid id, got: ${result.stderr}`); + cleanupTestDir(testDir); + })) passed++; else failed++; + + if (test('rejects duplicate wrapped matcher ids across events', () => { + const testDir = createTestDir(); + const hooksFile = path.join(testDir, 'hooks.json'); + fs.writeFileSync(hooksFile, JSON.stringify({ + hooks: { + PreToolUse: [{ + id: 'shared:matcher', + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo pre' }] + }], + PostToolUse: [{ + id: 'shared:matcher', + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo post' }] + }] + } + })); + + const result = runValidatorWithDir('validate-hooks', 'HOOKS_FILE', hooksFile); + assert.strictEqual(result.code, 1, 'Should reject matcher ids reused by another event'); + assert.ok( + result.stderr.includes("duplicate id 'shared:matcher'"), + `Should report the duplicate id, got: ${result.stderr}` + ); + assert.ok( + result.stderr.includes('PreToolUse[0]') && result.stderr.includes('PostToolUse[0]'), + `Should report both matcher locations, got: ${result.stderr}` + ); + cleanupTestDir(testDir); + })) passed++; else failed++; + // ── Round 79: validate-commands.js warnings count suffix in output ── console.log('\nRound 79: validate-commands.js (warnings count in output):'); @@ -2756,6 +2857,7 @@ function runTests() { hooks: { UserPromptSubmit: [ { + id: 'test:user-prompt-submit', hooks: [ { type: 'prompt', prompt: 'Summarize the request.' }, { type: 'agent', prompt: 'Review for security issues.', model: 'gpt-5.4' }, diff --git a/tests/docs/autonomous-harness-setup.test.js b/tests/docs/autonomous-harness-setup.test.js new file mode 100644 index 000000000..59d5c53e0 --- /dev/null +++ b/tests/docs/autonomous-harness-setup.test.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const source = fs.readFileSync(path.resolve(__dirname, '../../skills/autonomous-agent-harness/SKILL.md'), 'utf8'); +const blocks = [...source.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map(match => match[1]).join('\n'); +const checks = [ + ['executable examples omit unpublished packages and the invented dispatch endpoint', () => { + assert.doesNotMatch(blocks, /@anthropic\/(?:memory|scheduled-tasks|computer-use)-mcp-server/); + assert.ok(!blocks.includes('api.anthropic.com/dispatch')); + }], + ['CLI examples use the working directory and native session scheduling', () => { + assert.doesNotMatch(blocks, /--project\b|mcp__scheduled-tasks__/); + assert.match(blocks, /cd "\/path\/to\/repo" && claude -p/); + assert.match(blocks, /\/loop 30m/); + assert.match(source, /session-scoped/); + assert.match(source, /external scheduler/); + }], + ['optional memory configuration uses a pinned reference package and explicit data location', () => { + const jsonBlock = source.match(/```json\n([\s\S]*?)```/); + assert.ok(jsonBlock); + const memory = JSON.parse(jsonBlock[1]).mcpServers.memory; + assert.strictEqual(memory.command, 'npx'); + assert.match(memory.args[1], /^@modelcontextprotocol\/server-memory@\d{4}\.\d+\.\d+$/); + assert.ok(path.posix.isAbsolute(memory.env.MEMORY_FILE_PATH)); + }], + ['setup links to upstream memory, scheduling, CLI, and computer-use documentation', () => { + for (const link of [ + 'https://github.com/modelcontextprotocol/servers/tree/main/src/memory', + 'https://code.claude.com/docs/en/scheduled-tasks', + 'https://code.claude.com/docs/en/headless', + 'https://platform.claude.com/docs/en/agents-and-tools/tool-use/computer-use-tool' + ]) assert.ok(source.includes(link), `missing primary source ${link}`); + }] +]; + +let failed = 0; +for (const [name, check] of checks) { + try { check(); console.log(` PASS ${name}`); } + catch (error) { failed++; console.error(` FAIL ${name}: ${error.message}`); } +} +console.log(`Passed: ${checks.length - failed}\nFailed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index 54a19c0e0..495928691 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -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); @@ -2788,7 +2820,7 @@ function runTests() { tool_name: 'Edit', tool_input: { file_path: '/proj/tests/test_x.js', old_string: 'a', new_string: 'b' } }; - const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '**/tests/**' }); + const result = runHook(input, { GATEGUARD_EXEMPT_GLOBS: '**/tests/**', CLAUDE_PROJECT_DIR: '/proj' }); assert.strictEqual(result.code, 0, 'exit code should be 0'); const output = parseOutput(result.stdout); assert.ok(output, 'should produce valid JSON output'); @@ -2824,7 +2856,7 @@ function runTests() { clearState(); const exempt = runHook( { tool_name: 'Write', tool_input: { file_path: '/tmp/x/scratchpad/s.js', content: 'x' } }, - { GATEGUARD_EXEMPT_GLOBS: globs } + { GATEGUARD_EXEMPT_GLOBS: globs, CLAUDE_PROJECT_DIR: '/tmp/x' } ); const exemptOut = parseOutput(exempt.stdout); assert.ok(exemptOut, 'should produce JSON output'); @@ -2860,6 +2892,218 @@ function runTests() { passed++; else failed++; + for (const { glob, filePath, cwd = '/proj', exempt } of [ + { glob: 'services/**', filePath: '/proj/services/api.js', exempt: true }, + { glob: 'services/**', filePath: '/other/services/api.js', exempt: false }, + { glob: 'services/**', filePath: '/proj/vendor/services/api.js', exempt: false }, + { glob: 'services/**', filePath: '/proj/my-services/api.js', exempt: false }, + { glob: '*.md', filePath: '/proj/notes.md/outline.txt', exempt: false }, + { glob: '*.md', filePath: '/proj/docs/notes.md', exempt: false }, + { glob: '*.md', filePath: '/other/notes.md', exempt: false }, + { glob: 'README.md', filePath: '/proj/readme.md', exempt: true }, + { glob: '*.md', filePath: './notes.md', exempt: true }, + { glob: '**/*.md', filePath: '/proj/docs/notes.md', exempt: true }, + { glob: '**/*.md', filePath: '/proj/notes.md', exempt: true }, + { glob: '**/*.md', filePath: '../other/notes.md', exempt: false }, + { glob: 'docs/?otes.md', filePath: '/proj/docs/notes.md', exempt: true }, + { glob: 'docs?notes.md', filePath: '/proj/docs/notes.md', exempt: false }, + { glob: 'services/**', filePath: 'C:\\proj\\services\\api.js', cwd: 'C:\\proj', exempt: true }, + { glob: 'services/**', filePath: 'C:\\other\\services\\api.js', cwd: 'C:\\proj', exempt: false }, + { glob: '/approved/docs/**', filePath: '/approved/docs/notes.md', exempt: true }, + ]) { + clearState(); + if (test(`scopes exempt glob ${glob} for ${filePath}`, () => { + const result = runHook( + { cwd, tool_name: 'Edit', tool_input: { file_path: filePath } }, + { GATEGUARD_EXEMPT_GLOBS: glob, CLAUDE_PROJECT_DIR: cwd } + ); + const output = parseOutput(result.stdout); + assert.strictEqual(output?.hookSpecificOutput?.permissionDecision === 'deny', !exempt); + })) passed++; + else failed++; + } + + clearState(); + if (test('MultiEdit gates outside-project targets even when another target is exempt', () => { + const result = runHook({ + cwd: '/proj', tool_name: 'MultiEdit', + tool_input: { edits: [{ file_path: '/proj/docs/a.md' }, { file_path: '/other/docs/b.md' }] } + }, { GATEGUARD_EXEMPT_GLOBS: 'docs/**', CLAUDE_PROJECT_DIR: '/proj' }); + const output = parseOutput(result.stdout); + assert.strictEqual(output?.hookSpecificOutput?.permissionDecision, 'deny'); + assert.ok(output.hookSpecificOutput.permissionDecisionReason.includes('/other/docs/b.md')); + })) 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", + "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/tmp/demo", + 'Set-Alias -Unknown demo -Name zap Write-Output; zap ok', + + 'Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo', + 'Set-Alias -Name zap $cmd; zap -Force C:/tmp/demo', + "$cmd='Remove-Item'; Set-Alias -Value $cmd zap; zap -Force C:/tmp/demo", + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -", + "$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -", + "Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'", + "$payload | pwsh -Command -; $payload='Write-Output ok'", + '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)) { diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index df118594a..7d40ebe78 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -185,6 +185,301 @@ 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: "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Name zap Remove-Item; zap -Force C:/private/alias-command-sentinel', + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Name zap $cmd; zap -Force C:/private/alias-command-sentinel', + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$cmd='Remove-Item'; Set-Alias -Scope Global -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; New-Alias -Description demo -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "$cmd='Remove-Item'; sal -Option AllScope -Name zap $cmd; zap -Force C:/private/alias-command-sentinel", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: 'Set-Alias -Unknown demo -Name zap Write-Output; zap ok', + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$payload='Remove-Item -Force C:/private/stdin-command-sentinel'; $payload | pwsh -Command -", + expectedRules: ['powershell.remove-item.force'], + }, + { + command: "Set-Alias zap $cmd; zap -Force C:/private/alias-command-sentinel; $cmd='Write-Output'", + expectedRules: ['powershell.dynamic-execution'], + }, + { + command: "$payload | pwsh -Command -; $payload='Write-Output ok'", + expectedRules: ['powershell.dynamic-execution'], + }, + { + 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', diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index ce3411b15..93ad6133b 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -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'); @@ -4310,9 +4411,13 @@ async function runTests() { else failed++; if ( - await asyncTest('source calls process.exit(0) after writing output', async () => { + await asyncTest('source exits only after stdout finishes writing', async () => { const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - assert.ok(formatSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); + assert.match( + formatSource, + /process\.stdout\.write\(data,\s*\(\)\s*=>\s*process\.exit\(0\)\)/, + 'Should exit from the stdout write callback' + ); }) ) passed++; @@ -4321,7 +4426,7 @@ async function runTests() { if ( await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { const formatSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-format.js'), 'utf8'); - assert.ok(formatSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write to avoid trailing newline'); + assert.ok(formatSource.includes('process.stdout.write(data,'), 'Should use process.stdout.write to avoid trailing newline'); // Verify no console.log(data) for pass-through (console.error for warnings is OK) const lines = formatSource.split('\n'); const passThrough = lines.filter(l => /console\.log\(data\)/.test(l)); @@ -4334,9 +4439,13 @@ async function runTests() { console.log('\nRound 29: post-edit-typecheck.js (exit and pass-through):'); if ( - await asyncTest('source calls process.exit(0) after writing output', async () => { + await asyncTest('source exits only after stdout finishes writing', async () => { const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); - assert.ok(tcSource.includes('process.exit(0)'), 'Should call process.exit(0) for clean termination'); + assert.match( + tcSource, + /process\.stdout\.write\(data,\s*\(\)\s*=>\s*process\.exit\(0\)\)/, + 'Should exit from the stdout write callback' + ); }) ) passed++; @@ -4345,7 +4454,7 @@ async function runTests() { if ( await asyncTest('uses process.stdout.write instead of console.log for pass-through', async () => { const tcSource = fs.readFileSync(path.join(scriptsDir, 'post-edit-typecheck.js'), 'utf8'); - assert.ok(tcSource.includes('process.stdout.write(data)'), 'Should use process.stdout.write'); + assert.ok(tcSource.includes('process.stdout.write(data,'), 'Should use process.stdout.write'); const lines = tcSource.split('\n'); const passThrough = lines.filter(l => /console\.log\(data\)/.test(l)); assert.strictEqual(passThrough.length, 0, 'Should not use console.log(data) for pass-through'); @@ -5446,18 +5555,17 @@ async function runTests() { passed++; else failed++; - console.log('\nRound 59: check-console-log.js (stdin exceeding 1MB — truncation):'); + console.log('\nRound 59: check-console-log.js (large stdin pass-through):'); if ( - await asyncTest('suppresses pass-through for oversized stdin (fail-open, #2090)', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit. Echoing the - // truncated string would emit a JSON document cut mid-stream, which the - // harness reports as a Stop hook JSON validation failure. + await asyncTest('preserves complete oversized stdin (#2924)', async () => { + // Direct/legacy entrypoints preserve the protocol payload. Production + // wrappers continue to enforce their own bounded-input policy. const payload = 'x'.repeat(1024 * 1024 + 200000); const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), payload); assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - assert.strictEqual(result.stdout, '', 'Truncated stdin must not be echoed (empty stdout = no opinion)'); + assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload'); }) ) passed++; @@ -5548,20 +5656,16 @@ async function runTests() { passed++; else failed++; - console.log('\nRound 60: post-edit-console-warn.js (stdin exceeding 1MB — truncation):'); + console.log('\nRound 60: post-edit-console-warn.js (large stdin pass-through):'); if ( - await asyncTest('truncates stdin at 1MB limit and still passes through data', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit + await asyncTest('preserves complete oversized stdin', async () => { const payload = 'x'.repeat(1024 * 1024 + 200000); const result = await runScript(path.join(scriptsDir, 'post-edit-console-warn.js'), payload); assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Data should be truncated — stdout significantly less than input - assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload'); + assert.ok(result.stdout.length > 0, 'Should pass through complete data'); }) ) passed++; @@ -6074,40 +6178,32 @@ Some random content without the expected ### Context to Load section passed++; else failed++; - // ── Round 87: post-edit-format.js and post-edit-typecheck.js stdin overflow (1MB) ── - console.log('\nRound 87: post-edit-format.js (stdin exceeding 1MB — truncation):'); + // ── Round 87: post-edit-format.js and post-edit-typecheck.js large stdin pass-through ── + console.log('\nRound 87: post-edit-format.js (large stdin pass-through):'); if ( - await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-format)', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 14-22) + await asyncTest('preserves complete oversized stdin (post-edit-format)', async () => { const payload = 'x'.repeat(1024 * 1024 + 200000); const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), payload); assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Output should be truncated — significantly less than input - assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Output should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload'); + assert.ok(result.stdout.length > 0, 'Should pass through complete data'); }) ) passed++; else failed++; - console.log('\nRound 87: post-edit-typecheck.js (stdin exceeding 1MB — truncation):'); + console.log('\nRound 87: post-edit-typecheck.js (large stdin pass-through):'); if ( - await asyncTest('truncates stdin at 1MB limit and still passes through data (post-edit-typecheck)', async () => { - // Send 1.2MB of data — exceeds the 1MB MAX_STDIN limit (lines 16-24) + await asyncTest('preserves complete oversized stdin (post-edit-typecheck)', async () => { const payload = 'x'.repeat(1024 * 1024 + 200000); const result = await runScript(path.join(scriptsDir, 'post-edit-typecheck.js'), payload); assert.strictEqual(result.code, 0, 'Should exit 0 even with oversized stdin'); - // Output should be truncated — significantly less than input - assert.ok(result.stdout.length < payload.length, `stdout (${result.stdout.length}) should be shorter than input (${payload.length})`); - // Output should be approximately 1MB (last accepted chunk may push slightly over) - assert.ok(result.stdout.length <= 1024 * 1024 + 65536, `stdout (${result.stdout.length}) should be near 1MB, not unbounded`); - assert.ok(result.stdout.length > 0, 'Should still pass through truncated data'); + assert.strictEqual(result.stdout, payload, 'stdout should exactly match the complete stdin payload'); + assert.ok(result.stdout.length > 0, 'Should pass through complete data'); }) ) passed++; diff --git a/tests/hooks/observer-loop-archive.test.js b/tests/hooks/observer-loop-archive.test.js index 56676e76b..f35089282 100644 --- a/tests/hooks/observer-loop-archive.test.js +++ b/tests/hooks/observer-loop-archive.test.js @@ -1,19 +1,10 @@ /** - * Tests for observer-loop archive-on-failure fix (#2370) + * Tests for observer-loop archive-on-failure fixes (#2370, #2673). * - * Bug: analyze_observations() in observer-loop.sh moved the live - * observations.jsonl into observations.archive/ unconditionally, even when - * the Claude analysis step failed (timeout, non-zero exit, rate limit). - * Because the analyzer only ever reads the live file, a failed batch could - * never be re-analyzed and its instincts were silently lost. - * - * Fix: archive only after a successful analysis; on failure log and return, - * retaining observations for the next cycle to retry. - * - * Strategy: source observer-loop.sh (a BASH_SOURCE guard stops the main - * loop from running when sourced) and drive analyze_observations directly - * with a stub `claude` (exit code controlled per case) and a stub sibling - * session-guardian.sh. Assert symmetric outcomes for failure vs success. + * A batch may be archived only when the Claude process exits successfully and + * its current stdout contains one exact completion record as the final + * non-empty line. Process failures, semantic failures, stderr/log markers, + * duplicate markers, and tampering with the result path must fail closed. * * Run with: node tests/hooks/observer-loop-archive.test.js */ @@ -26,6 +17,8 @@ const { spawnSync } = require('child_process'); let passed = 0; let failed = 0; +let skipped = 0; +const SKIP = Symbol('skip'); function test(name, fn) { try { @@ -33,12 +26,23 @@ function test(name, fn) { console.log(` ✓ ${name}`); passed++; } catch (err) { + if (err === SKIP) { + console.log(` - ${name} (skipped: requires bash fixture)`); + skipped++; + return; + } console.log(` ✗ ${name}`); console.log(` Error: ${err.message}`); failed++; } } +function skipOnWindows() { + if (process.platform === 'win32') { + throw SKIP; + } +} + function createTempDir() { return fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-observer-archive-')); } @@ -47,7 +51,17 @@ function cleanupDir(dir) { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { - // ignore cleanup errors + // Ignore cleanup errors in an already-isolated test directory. + } +} + +function processExists(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; } } @@ -55,148 +69,316 @@ const repoRoot = path.resolve(__dirname, '..', '..'); const observerLoopPath = path.join( repoRoot, 'skills', 'continuous-learning-v2', 'agents', 'observer-loop.sh' ); +const ANALYSIS_COMPLETE_RECORD = '{"status":"analysis_complete"}'; +const ORIGINAL_OBSERVATIONS = '{"a":1}\n{"a":2}\n{"a":3}\n'; /** - * Run analyze_observations once with the given stub claude exit code. - * Returns { liveExists, archivedCount, log } describing the resulting state. + * Source observer-loop.sh in a sandbox and invoke analyze_observations once. */ -function runAnalyzeOnce(claudeExitCode) { +function runAnalyzeOnce(options = {}) { + const { + claudeExitCode = 0, + claudeOutput = '', + claudeStderr = '', + claudeDelaySeconds = 0, + claudeIgnoreTerm = false, + claudeSpawnChild = false, + existingLog = '', + observerTimeoutSeconds = 10, + probeResultPath = false, + } = options; const sandbox = createTempDir(); + try { const binDir = path.join(sandbox, 'bin'); const projectDir = path.join(sandbox, 'project'); + const observerFixtureDir = path.join(sandbox, 'observer'); fs.mkdirSync(binDir, { recursive: true }); fs.mkdirSync(projectDir, { recursive: true }); + fs.mkdirSync(observerFixtureDir, { recursive: true }); - // Stub claude: exit with the requested code, ignoring all args. const claudeStub = path.join(binDir, 'claude'); - fs.writeFileSync(claudeStub, '#!/usr/bin/env bash\nexit ${CLAUDE_STUB_EXIT:-0}\n'); + fs.writeFileSync(claudeStub, [ + '#!/usr/bin/env bash', + "printf '%s\n' \"$$\" > \"${CLAUDE_STUB_PID_FILE}\"", + "if [ \"${CLAUDE_STUB_IGNORE_TERM:-false}\" = \"true\" ]; then trap '' TERM; fi", + 'if [ "${CLAUDE_STUB_SPAWN_CHILD:-false}" = "true" ]; then', + ' sleep "${CLAUDE_STUB_DELAY_SECONDS:-10}" &', + " printf '%s\n' \"$!\" > \"${CLAUDE_STUB_CHILD_PID_FILE}\"", + ' wait', + 'fi', + 'if [ "${CLAUDE_STUB_PROBE_RESULT_PATH:-false}" = "true" ]; then', + ' for candidate in "${PROJECT_DIR}"/.observer-tmp/ecc-observer-result.*; do', + ' [ -e "$candidate" ] || [ -L "$candidate" ] || continue', + " printf 'found\n' > \"${CLAUDE_STUB_ATTACK_FILE}\"", + ' rm -f "$candidate"', + ` printf '%s\n' '${ANALYSIS_COMPLETE_RECORD}' > "$candidate"`, + ' done', + 'fi', + 'if [ "${CLAUDE_STUB_DELAY_SECONDS:-0}" != "0" ]; then', + ' sleep "${CLAUDE_STUB_DELAY_SECONDS}"', + 'fi', + "printf '%s' \"${CLAUDE_STUB_OUTPUT:-}\"", + "printf '%s' \"${CLAUDE_STUB_STDERR:-}\" >&2", + 'exit "${CLAUDE_STUB_EXIT:-0}"', + '', + ].join('\n')); fs.chmodSync(claudeStub, 0o755); - // analyze_observations resolves the real session-guardian.sh via its own - // ${BASH_SOURCE[0]}-derived SCRIPT_DIR, so we drive the real guardian with - // all of its gates disabled/isolated (see env below) rather than stubbing it. + // Source a sandbox copy so the sibling guardian is deterministic. + const observerFixture = path.join(observerFixtureDir, 'observer-loop.sh'); + const guardianStub = path.join(observerFixtureDir, 'session-guardian.sh'); + fs.copyFileSync(observerLoopPath, observerFixture); + fs.writeFileSync(guardianStub, '#!/usr/bin/env bash\nexit 0\n'); + fs.chmodSync(guardianStub, 0o755); - // Driver sources observer-loop.sh (guard stops the main loop) then runs - // the single function under test. const driver = path.join(sandbox, 'driver.sh'); fs.writeFileSync( driver, - `#!/usr/bin/env bash\nsource ${JSON.stringify(observerLoopPath)}\nanalyze_observations\n` + `#!/usr/bin/env bash\nsource ${JSON.stringify(observerFixture)}\nanalyze_observations\n` ); fs.chmodSync(driver, 0o755); const observationsFile = path.join(projectDir, 'observations.jsonl'); - fs.writeFileSync(observationsFile, '{"a":1}\n{"a":2}\n{"a":3}\n'); + const logFile = path.join(projectDir, 'observer.log'); + const claudePidFile = path.join(projectDir, 'claude-stub.pid'); + const claudeChildPidFile = path.join(projectDir, 'claude-stub-child.pid'); + const attackFile = path.join(projectDir, 'result-path-attack-found'); + fs.writeFileSync(observationsFile, ORIGINAL_OBSERVATIONS); + if (existingLog) fs.writeFileSync(logFile, existingLog); - // Defensive: never leak CLAUDE_PLUGIN_ROOT into the ECC test shell (it - // contaminates this project's hook-root resolution). - const childEnv = Object.assign({}, process.env); - delete childEnv.CLAUDE_PLUGIN_ROOT; - childEnv.PATH = binDir + path.delimiter + process.env.PATH; - childEnv.CLAUDE_STUB_EXIT = String(claudeExitCode); - childEnv.OBSERVATIONS_FILE = observationsFile; - childEnv.MIN_OBSERVATIONS = '1'; - childEnv.PROJECT_DIR = projectDir; - childEnv.LOG_FILE = path.join(projectDir, 'observer.log'); - childEnv.PROJECT_NAME = 'test-project'; - childEnv.PROJECT_ID = 'test-project'; - childEnv.INSTINCTS_DIR = path.join(projectDir, 'instincts'); - childEnv.CONFIG_DIR = projectDir; - childEnv.CLV2_IS_WINDOWS = 'false'; - childEnv.ECC_OBSERVER_TIMEOUT_SECONDS = '2'; - // Make the real session-guardian.sh deterministically proceed (exit 0): - // disable the active-hours and idle gates, isolate the cooldown log, and - // zero the cooldown interval so a fresh project always passes. - childEnv.OBSERVER_ACTIVE_HOURS_START = '0'; - childEnv.OBSERVER_ACTIVE_HOURS_END = '0'; - childEnv.OBSERVER_MAX_IDLE_SECONDS = '0'; - childEnv.OBSERVER_INTERVAL_SECONDS = '0'; - childEnv.OBSERVER_LAST_RUN_LOG = path.join(projectDir, 'observer-last-run.log'); + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => key !== 'CLAUDE_PLUGIN_ROOT' && !key.startsWith('ECC_OBSERVER_') + ) + ); + const childEnv = { + ...inheritedEnv, + PATH: binDir + path.delimiter + process.env.PATH, + CLAUDE_STUB_EXIT: String(claudeExitCode), + CLAUDE_STUB_OUTPUT: claudeOutput, + CLAUDE_STUB_STDERR: claudeStderr, + CLAUDE_STUB_DELAY_SECONDS: String(claudeDelaySeconds), + CLAUDE_STUB_IGNORE_TERM: String(claudeIgnoreTerm), + CLAUDE_STUB_SPAWN_CHILD: String(claudeSpawnChild), + CLAUDE_STUB_PROBE_RESULT_PATH: String(probeResultPath), + CLAUDE_STUB_PID_FILE: claudePidFile, + CLAUDE_STUB_CHILD_PID_FILE: claudeChildPidFile, + CLAUDE_STUB_ATTACK_FILE: attackFile, + OBSERVATIONS_FILE: observationsFile, + MIN_OBSERVATIONS: '1', + PROJECT_DIR: projectDir, + LOG_FILE: logFile, + PROJECT_NAME: 'test-project', + PROJECT_ID: 'test-project', + INSTINCTS_DIR: path.join(projectDir, 'instincts'), + CONFIG_DIR: projectDir, + CLV2_IS_WINDOWS: 'false', + ECC_OBSERVER_TIMEOUT_SECONDS: String(observerTimeoutSeconds), + ECC_OBSERVER_MAX_ANALYSIS_LINES: '500', + ECC_OBSERVER_MAX_TURNS: '20', + ECC_OBSERVER_MODEL: 'haiku', + ECC_OBSERVER_ALLOW_WINDOWS: 'false', + }; + const startedAt = Date.now(); const result = spawnSync('bash', [driver], { encoding: 'utf8', timeout: 15000, - env: childEnv + env: childEnv, }); + const durationMs = Date.now() - startedAt; + assert.ifError(result.error); assert.strictEqual( - result.status, 0, + result.status, + 0, `driver should exit 0, got ${result.status}; stderr: ${result.stderr}` ); const archiveDir = path.join(projectDir, 'observations.archive'); - let archivedCount = 0; - if (fs.existsSync(archiveDir)) { - archivedCount = fs.readdirSync(archiveDir) - .filter(f => /^processed-.*\.jsonl$/.test(f)).length; - } - let log = ''; - try { log = fs.readFileSync(childEnv.LOG_FILE, 'utf8'); } catch { /* none */ } + const archivedContents = fs.existsSync(archiveDir) + ? fs.readdirSync(archiveDir) + .filter(file => /^processed-.*\.jsonl$/.test(file)) + .sort() + .map(file => fs.readFileSync(path.join(archiveDir, file), 'utf8')) + : []; + const liveContent = fs.existsSync(observationsFile) + ? fs.readFileSync(observationsFile, 'utf8') + : null; + const log = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; + const observerTempDir = path.join(projectDir, '.observer-tmp'); + const tempEntries = fs.existsSync(observerTempDir) + ? fs.readdirSync(observerTempDir) + : []; + const claudePid = fs.existsSync(claudePidFile) + ? Number(fs.readFileSync(claudePidFile, 'utf8').trim()) + : null; + const claudeChildPid = fs.existsSync(claudeChildPidFile) + ? Number(fs.readFileSync(claudeChildPidFile, 'utf8').trim()) + : null; - return { liveExists: fs.existsSync(observationsFile), archivedCount, log }; + return { + archivedContents, + attackFound: fs.existsSync(attackFile), + claudeStillRunning: processExists(claudePid), + claudeChildStillRunning: processExists(claudeChildPid), + durationMs, + liveContent, + log, + tempEntries, + }; } finally { cleanupDir(sandbox); } } -console.log('\n=== Observer-loop Archive-on-Failure Tests (#2370) ===\n'); +function assertOriginalBatchIsRetryable(state) { + assert.strictEqual( + state.liveContent, + ORIGINAL_OBSERVATIONS, + 'the live batch must remain byte-for-byte intact for retry' + ); + assert.deepStrictEqual(state.archivedContents, []); +} +console.log('\n=== Observer-loop Archive-on-Failure Tests (#2370, #2673) ===\n'); console.log('--- behavioral ---'); test('failed analysis retains observations and archives nothing', () => { - // Shell-driven behavioral check; skip on Windows where the bash driver's - // $0 path handling differs (matches observer-memory.test.js convention). - if (process.platform === 'win32') { - return; - } - const { liveExists, archivedCount, log } = runAnalyzeOnce(1); - assert.ok(liveExists, 'live observations.jsonl must be retained when analysis fails'); - assert.strictEqual(archivedCount, 0, 'nothing should be archived when analysis fails'); - assert.ok( - /retaining observations for retry/.test(log), - `failure log should note retention; got: ${log}` - ); + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeExitCode: 1, + claudeOutput: `${ANALYSIS_COMPLETE_RECORD}\n`, + }); + assertOriginalBatchIsRetryable(state); + assert.match(state.log, /retaining observations for retry/); + assert.deepStrictEqual(state.tempEntries, []); }); -test('successful analysis archives the batch (happy path preserved)', () => { - // Shell-driven behavioral check; skip on Windows (see note above). - if (process.platform === 'win32') { - return; - } - const { liveExists, archivedCount } = runAnalyzeOnce(0); - assert.ok(!liveExists, 'live observations.jsonl should be moved after a successful analysis'); - assert.strictEqual(archivedCount, 1, 'exactly one processed-*.jsonl should be archived on success'); +test('zero-exit analysis without a completion record retains observations', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: 'Analysis blocked because the sampled file was not found.\n', + }); + assertOriginalBatchIsRetryable(state); + assert.match(state.log, /completion record missing.*retaining observations for retry/i); + assert.deepStrictEqual(state.tempEntries, []); +}); + +test('mentioning the completion record in prose does not authorize archival', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: `I would emit ${ANALYSIS_COMPLETE_RECORD} after analysis, but the read failed.\n`, + }); + assertOriginalBatchIsRetryable(state); +}); + +test('completion record followed by failure text does not authorize archival', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: `${ANALYSIS_COMPLETE_RECORD}\nLater failure: instinct write did not complete.\n`, + }); + assertOriginalBatchIsRetryable(state); +}); + +test('a completion record from an older log entry cannot authorize this run', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + existingLog: `prior run\n${ANALYSIS_COMPLETE_RECORD}\n`, + claudeOutput: 'Current run could not read its analysis file.\n', + }); + assertOriginalBatchIsRetryable(state); +}); + +test('a completion record written only to stderr does not authorize archival', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: 'Analysis did not complete.\n', + claudeStderr: `${ANALYSIS_COMPLETE_RECORD}\n`, + }); + assertOriginalBatchIsRetryable(state); + assert.ok(state.log.includes(ANALYSIS_COMPLETE_RECORD)); +}); + +test('duplicate exact completion records do not authorize archival', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: `${ANALYSIS_COMPLETE_RECORD}\n${ANALYSIS_COMPLETE_RECORD}\n`, + }); + assertOriginalBatchIsRetryable(state); +}); + +test('replacing the result pathname cannot forge completion', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: 'Analysis did not complete.\n', + probeResultPath: true, + }); + assertOriginalBatchIsRetryable(state); + assert.strictEqual(state.attackFound, false, 'the open result inode must not remain path-addressable'); +}); + +test('successful analysis archives the original batch byte-for-byte', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: `Analysis finished.\n\n${ANALYSIS_COMPLETE_RECORD}\n\n`, + }); + assert.strictEqual(state.liveContent, null); + assert.deepStrictEqual(state.archivedContents, [ORIGINAL_OBSERVATIONS]); + assert.deepStrictEqual(state.tempEntries, []); +}); + +test('completion record accepts a CRLF line ending', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeOutput: `Analysis complete.\r\n${ANALYSIS_COMPLETE_RECORD}\r\n\r\n`, + }); + assert.strictEqual(state.liveContent, null); + assert.deepStrictEqual(state.archivedContents, [ORIGINAL_OBSERVATIONS]); +}); + +test('watchdog force-stops a process that ignores TERM and retains observations', () => { + skipOnWindows(); + const state = runAnalyzeOnce({ + claudeDelaySeconds: 10, + claudeIgnoreTerm: true, + claudeSpawnChild: true, + observerTimeoutSeconds: 1, + }); + assertOriginalBatchIsRetryable(state); + assert.ok(state.durationMs < 8000, `watchdog should return promptly; took ${state.durationMs}ms`); + assert.strictEqual(state.claudeStillRunning, false, 'timed-out Claude process must be reaped'); + assert.strictEqual(state.claudeChildStillRunning, false, 'timed-out Claude descendants must stop'); + assert.match(state.log, /timed out after 1s/); + assert.deepStrictEqual(state.tempEntries, []); }); console.log('--- static guards ---'); -test('analyze_observations returns on failure before the archive mv', () => { +test('process and semantic failure guards run before archival', () => { const content = fs.readFileSync(observerLoopPath, 'utf8'); - // Operate on full file content with explicit anchors rather than a lazy - // function-body extraction (which could truncate on a future inner "\n}" - // and pass vacuously). These tokens each occur once, inside the function. - const failIdx = content.search(/exit_code"?\s+-ne\s+0/); - const returnIdx = content.indexOf('return', failIdx); + const processGuardIdx = content.search(/exit_code"?\s+-ne\s+0/); + const semanticGuardIdx = content.indexOf('if [ "$analysis_complete" -ne 1 ]'); const archiveIdx = content.indexOf('observations.archive'); - assert.ok(failIdx !== -1, 'should find the non-zero exit_code check'); - assert.ok(archiveIdx !== -1, 'should find the archive block'); - assert.ok(returnIdx !== -1, 'failure branch should contain a return'); - assert.ok(returnIdx < archiveIdx, - 'failure branch must return before reaching the archive block'); + assert.ok(processGuardIdx !== -1); + assert.ok(semanticGuardIdx !== -1); + assert.ok(archiveIdx !== -1); + assert.ok(processGuardIdx < archiveIdx); + assert.ok(semanticGuardIdx < archiveIdx); }); -test('observer-loop.sh has a source-guard so it can be sourced in tests', () => { +test('observer-loop.sh has a source guard', () => { const content = fs.readFileSync(observerLoopPath, 'utf8'); assert.ok( - content.includes('BASH_SOURCE[0]') && content.includes('return 0 2>/dev/null'), - 'observer-loop.sh should short-circuit when sourced rather than executed' + content.includes('BASH_SOURCE[0]') && content.includes('return 0 2>/dev/null') ); }); console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); -console.log(`Total: ${passed + failed}\n`); +console.log(`Skipped: ${skipped}`); +console.log(`Total: ${passed + failed + skipped}\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/passthrough-large-stdin.test.js b/tests/hooks/passthrough-large-stdin.test.js new file mode 100644 index 000000000..48333fb3d --- /dev/null +++ b/tests/hooks/passthrough-large-stdin.test.js @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Regression coverage for #2924. + * + * Legacy direct hook entrypoints that echo stdin must preserve the complete + * hook payload. Cutting the input at an arbitrary byte/character boundary + * produces invalid JSON, while exiting before stdout drains loses everything + * past the platform pipe buffer. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const repoRoot = path.join(__dirname, '..', '..'); +const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-passthrough-')); +const DIRECT_STDIN_LIMIT_BYTES = 16 * 1024 * 1024; + +const PASSTHROUGH_HOOKS = [ + 'scripts/hooks/check-console-log.js', + 'scripts/hooks/post-edit-typecheck.js', + 'scripts/hooks/post-edit-console-warn.js', + 'scripts/hooks/post-edit-format.js', + 'scripts/hooks/pre-write-doc-warn.js' +]; + +const PAYLOADS = [ + ['1KB payload', 'x'.repeat(1024)], + ['200KB payload', 'x'.repeat(200 * 1024)], + ['2MB payload', 'x'.repeat(2 * 1024 * 1024)], + ['5MB payload', 'x'.repeat(5 * 1024 * 1024)], + ['multibyte payload beyond 1MB', '韩'.repeat(600 * 1024)] +]; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function hookPayload(padding) { + return JSON.stringify({ + session_id: `passthrough-${process.pid}`, + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: path.join(workDir, 'fixture.txt') }, + padding + }); +} + +function runDirect(script, input) { + return spawnSync(process.execPath, [path.join(repoRoot, script)], { + input, + encoding: 'utf8', + cwd: workDir, + timeout: 30000, + maxBuffer: 32 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'] + }); +} + +console.log('\nPassthrough hook large-stdin tests (#2924):'); + +let passed = 0; +let failed = 0; + +for (const script of PASSTHROUGH_HOOKS) { + for (const [label, padding] of PAYLOADS) { + if ( + test(`${path.basename(script)} preserves the complete ${label}`, () => { + const input = hookPayload(padding); + const result = runDirect(script, input); + + assert.strictEqual( + result.status, + 0, + `${script}: expected exit 0, got ${result.status}: ${result.stderr}` + ); + assert.ok( + result.stdout === input, + `${script}: expected ${Buffer.byteLength(input)} bytes, got ${Buffer.byteLength(result.stdout || '')}` + ); + assert.deepStrictEqual(JSON.parse(result.stdout), JSON.parse(input)); + }) + ) { + passed += 1; + } else { + failed += 1; + } + } +} + +const oversizedInputs = [ + ['ASCII', hookPayload('x'.repeat(DIRECT_STDIN_LIMIT_BYTES))], + ['multibyte', hookPayload('韩'.repeat(6 * 1024 * 1024))] +]; +for (const [encoding, overLimitInput] of oversizedInputs) { + for (const script of PASSTHROUGH_HOOKS) { + if ( + test(`${path.basename(script)} suppresses ${encoding} input beyond the 16MiB direct-entrypoint limit`, () => { + assert.ok(Buffer.byteLength(overLimitInput) > DIRECT_STDIN_LIMIT_BYTES); + const result = runDirect(script, overLimitInput); + + assert.strictEqual( + result.status, + 0, + `${script}: expected exit 0, got ${result.status}: ${result.stderr || result.error || ''}` + ); + assert.ok(result.stdout === '', 'oversized input must not be emitted as truncated JSON'); + }) + ) { + passed += 1; + } else { + failed += 1; + } + } +} + +if ( + test('post-edit-typecheck.js flushes the nonexistent-TypeScript-file early return', () => { + const input = JSON.stringify({ + hook_event_name: 'PostToolUse', + tool_name: 'Edit', + tool_input: { file_path: path.join(workDir, 'missing.ts') }, + padding: 'x'.repeat(2 * 1024 * 1024) + }); + const result = runDirect('scripts/hooks/post-edit-typecheck.js', input); + + assert.strictEqual(result.status, 0, result.stderr); + assert.ok(result.stdout === input, 'early return must wait for the complete stdout payload'); + JSON.parse(result.stdout); + }) +) { + passed += 1; +} else { + failed += 1; +} + +if ( + test('pre-write-doc-warn.js returns valid structured output for a large warned payload', () => { + const input = JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: 'TODO.md', content: 'x'.repeat(2 * 1024 * 1024) } + }); + const result = runDirect('scripts/hooks/pre-write-doc-warn.js', input); + + assert.strictEqual(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout); + assert.ok( + output.hookSpecificOutput.additionalContext.includes('TODO.md'), + 'large warned payload should retain the doc warning' + ); + }) +) { + passed += 1; +} else { + failed += 1; +} + +try { + fs.rmSync(workDir, { recursive: true, force: true }); +} catch { + /* best-effort cleanup */ +} + +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/hooks/posttooluse-dispatcher.test.js b/tests/hooks/posttooluse-dispatcher.test.js index 0ce83581e..c21f003f3 100644 --- a/tests/hooks/posttooluse-dispatcher.test.js +++ b/tests/hooks/posttooluse-dispatcher.test.js @@ -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'], diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js index 1de6bb9c2..02a4bf3ce 100644 --- a/tests/hooks/stop-hooks-stdout.test.js +++ b/tests/hooks/stop-hooks-stdout.test.js @@ -142,7 +142,6 @@ const STOP_HOOKS = [ // Direct-invocation legacy paths that echo stdin. const ECHOING_STOP_HOOKS = [ 'scripts/hooks/stop-format-typecheck.js', - 'scripts/hooks/check-console-log.js', 'scripts/hooks/cost-tracker.js', 'scripts/hooks/desktop-notify.js' ]; @@ -316,6 +315,17 @@ for (const script of ECHOING_STOP_HOOKS) { else failed++; } +if ( + test('check-console-log invoked directly echoes a >1MB payload uncut', () => { + const result = runDirect('scripts/hooks/check-console-log.js', oversizedPayload); + assert.strictEqual(result.status, 0); + assert.strictEqual(result.stdout, oversizedPayload, 'direct pass-through must preserve the complete payload'); + JSON.parse(result.stdout); + }) +) + passed++; +else failed++; + if ( test('check-console-log invoked directly echoes a sub-cap >64KB payload uncut', () => { const result = runDirect('scripts/hooks/check-console-log.js', realisticPayload); diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 77b0822d6..677e2b952 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -854,8 +854,8 @@ async function runTests() { })) passed++; else failed++; if (await asyncTest('hooks survive stdin exceeding 1MB limit', async () => { - // The post-edit-console-warn hook reads stdin up to 1MB then passes through - // Send > 1MB to verify truncation doesn't crash the hook + // Direct invocation preserves the complete payload. Send >1MB to verify + // the pass-through path remains stable under backpressure. const oversizedInput = JSON.stringify({ tool_input: { file_path: '/test.js' }, tool_output: { output: 'x'.repeat(1200000) } // ~1.2MB diff --git a/tests/lib/claude-settings-array.test.js b/tests/lib/claude-settings-array.test.js new file mode 100644 index 000000000..bc1a9e093 --- /dev/null +++ b/tests/lib/claude-settings-array.test.js @@ -0,0 +1,88 @@ +'use strict'; + +const assert = require('assert'); +const { spawnSync } = require('child_process'); +const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings'); + +function config(command) { + return { + hooks: { + Stop: [{ id: 'ecc:array', hooks: [{ type: 'command', command }] }], + }, + }; +} + +function materialize(command, root = '/opt/ecc') { + return materializeManagedHooks(config(command), root).Stop[0].hooks[0].command; +} + +const tests = [ + ['materializes every array command without changing the source', () => { + const source = config([ + 'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"', + 'node -e "var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(e)"', + '${CLAUDE_PLUGIN_ROOT}/scripts/start.js', + '--unchanged', + ]); + const before = JSON.parse(JSON.stringify(source)); + const command = materializeManagedHooks(source, '/opt/ecc').Stop[0].hooks[0].command; + assert.deepStrictEqual(source, before); + assert.notStrictEqual(command, source.hooks.Stop[0].hooks[0].command); + assert.ok(command.slice(0, 2).every(value => !value.includes('process.env.CLAUDE_PLUGIN_ROOT'))); + assert.deepStrictEqual(command.slice(2), ['/opt/ecc/scripts/start.js', '--unchanged']); + }], + ['array argv commands execute with the exact root in a clean process', () => { + const root = '/tmp/ECC space/\'"$`\\路径'; + const command = materialize([ + process.execPath, + '-e', + 'var e=process.env.CLAUDE_PLUGIN_ROOT;process.stdout.write(e);', + ], root); + const result = spawnSync(command[0], command.slice(1), { + env: {}, + encoding: 'utf8', + timeout: 5000, + }); + assert.ifError(result.error); + assert.strictEqual(result.status, 0, result.stderr); + assert.strictEqual(result.stdout, root); + }], + ...[0, 1].map(index => [ + `rejects an unresolved root read in array element ${index}`, + () => { + const command = ['echo first', 'echo second']; + command[index] = 'node -e "const root=process.env.CLAUDE_PLUGIN_ROOT"'; + assert.throws(() => materialize(command), /Unable to resolve CLAUDE_PLUGIN_ROOT/); + }, + ]), + ['rejects a remaining root read after resolving an array prologue', () => { + assert.throws(() => materialize([ + 'var e=process.env.CLAUDE_PLUGIN_ROOT;console.log(process.env.CLAUDE_PLUGIN_ROOT);', + ]), /Unable to resolve CLAUDE_PLUGIN_ROOT/); + }], + ['retains supported root assignments in array commands', () => { + const command = materialize([ + 'var e=process.env.CLAUDE_PLUGIN_ROOT;process.env.CLAUDE_PLUGIN_ROOT=e;', + ]); + assert.ok(!command[0].includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')); + assert.ok(command[0].includes('process.env.CLAUDE_PLUGIN_ROOT=e;')); + }], + ['invalid arrays still fail command validation', () => { + for (const command of [[], ['node', null], ['node', 3], ['node', ' ']]) { + assert.throws(() => materialize(command), /invalid command/); + } + }], +]; + +let failed = 0; +for (const [name, run] of tests) { + try { + run(); + console.log(` PASS ${name}`); + } catch (error) { + failed += 1; + console.error(` FAIL ${name}\n ${error.stack || error.message}`); + } +} +console.log(`\nResults: Passed: ${tests.length - failed}, Failed: ${failed}`); +process.exitCode = failed > 0 ? 1 : 0; diff --git a/tests/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js new file mode 100644 index 000000000..53caaf4bb --- /dev/null +++ b/tests/lib/claude-settings.test.js @@ -0,0 +1,789 @@ +/** + * Focused coverage for safely managing ECC hook entries in Claude settings. + */ + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + runWithSettingsLock, + materializeManagedHooks, + inspectManagedHooks, + mergeManagedHooks, + parseSettings, + readSettings, + repairManagedHooks, + replacePluginRootPlaceholders, + uninstallManagedHooks, + updateSettingsAtomic, + validateManagedHooks, +} = require('../../scripts/lib/install/claude-settings'); + +function test(name, fn) { + try { + fn(); + console.log(` PASS ${name}`); + return true; + } catch (error) { + console.log(` FAIL ${name}`); + console.log(` Error: ${error.stack || error.message}`); + return false; + } +} + +function entry(id, command, extra = {}) { + return { + matcher: '.*', + hooks: [{ type: 'command', command }], + id, + ...extra, + }; +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function assertAtomicParentReplacementRejected(stage) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-parent-race-')); + const targetRoot = path.join(tempDir, 'target'); + const parkedRoot = path.join(tempDir, 'parked'); + const victimRoot = path.join(tempDir, 'victim'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const victimPath = path.join(victimRoot, 'settings.json'); + const originalFsync = fs.fsyncSync; + const originalClose = fs.closeSync; + const targetContent = '{"target":true}\n'; + const victimContent = '{"victim":"preserve"}\n'; + let tempDescriptor; + let tempBasename; + let replacementAttempted = false; + let replacementBlocked = false; + let replaced = false; + const replaceParent = () => { + replacementAttempted = true; + try { + fs.renameSync(targetRoot, parkedRoot); + } catch (error) { + replacementBlocked = process.platform === 'win32' + && ['EPERM', 'EACCES'].includes(error.code); + throw error; + } + fs.symlinkSync(victimRoot, targetRoot, process.platform === 'win32' ? 'junction' : 'dir'); + replaced = true; + // A colliding path in the replacement directory must survive error cleanup. + fs.writeFileSync(path.join(victimRoot, tempBasename), 'unrelated replacement file'); + }; + try { + fs.mkdirSync(targetRoot); + fs.mkdirSync(victimRoot); + fs.writeFileSync(settingsPath, targetContent); + fs.writeFileSync(victimPath, victimContent); + fs.fsyncSync = function(descriptor) { + const result = originalFsync.call(fs, descriptor); + const stagedName = fs.readdirSync(targetRoot).find(name => ( + name.startsWith('.settings.json.') && name.endsWith('.tmp') + )); + if (stagedName) { + tempBasename = stagedName; + tempDescriptor = descriptor; + // Exercise replacement while the staging handle is still open. The + // production writer owns the descriptor and closes it on rejection. + // Intercept fsync rather than forwarding arbitrary file-creation flags. + if (!replacementAttempted && stage === 'open') replaceParent(); + } + return result; + }; + fs.closeSync = function(descriptor) { + const result = originalClose.call(fs, descriptor); + // At the rename boundary the staging handle has been closed. Windows + // can now replace the parent, exercising ECC's identity check too. + if (!replacementAttempted && stage === 'rename' && descriptor === tempDescriptor) replaceParent(); + return result; + }; + assert.throws( + () => updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } })), + error => /parent.*changed|changed.*parent/i.test(error.message) + || (replacementBlocked && ['EPERM', 'EACCES'].includes(error.code)) + ); + assert.ok(replacementAttempted, 'must attempt replacement inside the atomic writer'); + assert.throws(() => fs.fstatSync(tempDescriptor), error => error.code === 'EBADF', + 'every staging descriptor must be closed after rejection'); + assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), victimContent); + if (replacementBlocked) { + assert.strictEqual(stage, 'open', 'rename-stage replacement happens after closing the staging handle'); + assert.strictEqual(replaced, false); + assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), targetContent); + assert.deepStrictEqual(fs.readdirSync(targetRoot), ['settings.json']); + assert.deepStrictEqual(fs.readdirSync(victimRoot), ['settings.json']); + } else { + assert.ok(replaced, 'a permitted replacement must reach the parent identity check'); + assert.strictEqual(fs.readFileSync(path.join(parkedRoot, 'settings.json'), 'utf8'), targetContent); + assert.strictEqual(fs.readFileSync(path.join(victimRoot, tempBasename), 'utf8'), 'unrelated replacement file'); + } + } finally { + fs.fsyncSync = originalFsync; + fs.closeSync = originalClose; + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function runTests() { + console.log('\n=== Testing install/claude-settings.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('validates and clones a managed hook map without mutating it', () => { + const managed = { + SessionStart: [entry('session:start', 'node start.js')], + Stop: [entry('session:stop', 'node stop.js')], + }; + const validated = validateManagedHooks(managed); + + assert.deepStrictEqual(validated, managed); + assert.notStrictEqual(validated, managed); + assert.notStrictEqual(validated.SessionStart[0], managed.SessionStart[0]); + })) passed++; else failed++; + + if (test('strictly rejects invalid managed hook maps and globally duplicate ids', () => { + const invalidValues = [ + null, + [], + {}, + { SessionStart: [] }, + { SessionStart: {} }, + { SessionStart: [null] }, + { SessionStart: [[]] }, + { SessionStart: [{}] }, + { SessionStart: [{ id: ' ' }] }, + { BogusEvent: [entry('bad:event', 'bad')] }, + { SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] }, + { SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] }, + ]; + + for (const invalid of invalidValues) { + assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i); + } + assert.throws( + () => validateManagedHooks({ + Stop: [entry('shared', 'a')], + SubagentStop: [entry('shared', 'b')], + }), + /expected globally unique id "shared"/ + ); + })) passed++; else failed++; + + if (test('materializes hook roots and rejects unresolved environment references', () => { + const source = { + hooks: { + Stop: [entry( + 'ecc:stop', + 'var e=process.env.CLAUDE_PLUGIN_ROOT; ' + + 'process.env.CLAUDE_PLUGIN_ROOT=r; ${CLAUDE_PLUGIN_ROOT}' + )], + }, + }; + const before = clone(source); + const materialized = materializeManagedHooks(source, '/opt/ecc'); + const command = materialized.Stop[0].hooks[0].command; + const encodedRoot = command.match(/Buffer\.from\('([^']+)','base64'\)/)[1]; + + assert.deepStrictEqual(source, before); + assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')); + assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}')); + assert.strictEqual(Buffer.from(encodedRoot, 'base64').toString('utf8'), '/opt/ecc'); + assert.throws(() => materializeManagedHooks({}, '/opt/ecc'), /hooks object/); + assert.throws(() => materializeManagedHooks(source, ''), /target root/); + assert.throws( + () => materializeManagedHooks({ + hooks: { + Stop: [entry('ecc:stop', 'node -e "const e=process.env.CLAUDE_PLUGIN_ROOT"')], + }, + }, '/opt/ecc'), + /Unable to resolve CLAUDE_PLUGIN_ROOT/ + ); + })) passed++; else failed++; + + if (test('replaces every plugin-root placeholder recursively and immutably', () => { + const source = { + SessionStart: [{ + id: 'session:start', + command: '${CLAUDE_PLUGIN_ROOT}/start.js:${CLAUDE_PLUGIN_ROOT}', + nested: ['${CLAUDE_PLUGIN_ROOT}/nested.js', 3, null], + }], + }; + const before = clone(source); + + const resolved = replacePluginRootPlaceholders(source, '/opt/ecc'); + + assert.deepStrictEqual(source, before); + assert.deepStrictEqual(resolved, { + SessionStart: [{ + id: 'session:start', + command: '/opt/ecc/start.js:/opt/ecc', + nested: ['/opt/ecc/nested.js', 3, null], + }], + }); + })) passed++; else failed++; + + if (test('parseSettings accepts an object and validates every hooks event array', () => { + assert.deepStrictEqual( + parseSettings('{"theme":"dark","hooks":{"Stop":[]}}', 'memory settings'), + { theme: 'dark', hooks: { Stop: [] } } + ); + assert.throws(() => parseSettings('{', 'memory settings'), /Failed to parse memory settings/); + assert.throws(() => parseSettings('null', 'memory settings'), /expected a JSON object/); + assert.throws(() => parseSettings('[]', 'memory settings'), /expected a JSON object/); + assert.throws( + () => parseSettings('{"hooks":{"Stop":{}}}', 'memory settings'), + /hooks\.Stop.*array/ + ); + assert.throws( + () => parseSettings('{"hooks":[]}', 'memory settings'), + /"hooks".*object/ + ); + })) passed++; else failed++; + + if (test('readSettings returns an empty object for ENOENT and rejects bad files', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-')); + try { + assert.deepStrictEqual(readSettings(path.join(tempDir, 'missing.json')), {}); + + const malformedPath = path.join(tempDir, 'malformed.json'); + fs.writeFileSync(malformedPath, '{', 'utf8'); + assert.throws(() => readSettings(malformedPath), /Failed to parse Claude settings/); + + const invalidPath = path.join(tempDir, 'invalid.json'); + fs.writeFileSync(invalidPath, '{"hooks":{"Stop":false}}', 'utf8'); + assert.throws(() => readSettings(invalidPath), /hooks\.Stop.*array/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('readSettings propagates non-ENOENT read errors without converting them to empty settings', () => { + const denied = new Error('denied'); + denied.code = 'EACCES'; + assert.throws( + () => readSettings('/private/settings.json', { + readFileSync() { + throw denied; + }, + }), + error => error === denied + ); + })) passed++; else failed++; + + if (test('atomic settings updates retry after a concurrent change and preserve secure mode', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-atomic-')); + const settingsPath = path.join(tempDir, 'settings.json'); + let commitAttempts = 0; + try { + const result = updateSettingsAtomic( + settingsPath, + settings => ({ settings: { ...settings, managed: true } }), + { + beforeCommit() { + commitAttempts += 1; + if (commitAttempts === 1) { + fs.writeFileSync(settingsPath, '{"theme":"concurrent"}\n', { mode: 0o600 }); + } + }, + } + ); + + assert.deepStrictEqual(result.settings, { theme: 'concurrent', managed: true }); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), result.settings); + assert.strictEqual(commitAttempts, 2); + if (process.platform !== 'win32') { + assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600); + } + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + for (const stage of ['open', 'rename']) { + if (test(`atomic settings updates reject parent replacement at ${stage} without touching its files`, () => { + assertAtomicParentReplacementRejected(stage); + })) passed++; else failed++; + } + + if (test('atomic settings updates preserve edits made while the replacement file is staged', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-late-edit-')); + const settingsPath = path.join(tempDir, 'settings.json'); + const originalFsync = fs.fsyncSync; + let changed = false; + let fsyncCalls = 0; + try { + fs.writeFileSync(settingsPath, '{"theme":"initial"}\n'); + fs.fsyncSync = function(descriptor) { + const result = originalFsync.call(fs, descriptor); + // Lock creation is the first fsync; only change settings after the + // atomic writer has staged its first replacement payload. + fsyncCalls += 1; + if (!changed && fsyncCalls === 2) { + changed = true; + fs.writeFileSync(settingsPath, '{"theme":"late-edit"}\n'); + } + return result; + }; + updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } })); + assert.ok(changed); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { theme: 'late-edit', managed: true }); + } finally { + fs.fsyncSync = originalFsync; + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('atomic settings updates recover a stale invalid lock after its lease', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-stale-lock-')); + const settingsPath = path.join(tempDir, 'settings.json'); + const lockPath = `${settingsPath}.ecc.lock`; + try { + fs.writeFileSync(lockPath, '', { mode: 0o600 }); + const stale = new Date(Date.now() - (10 * 60 * 1000)); + fs.utimesSync(lockPath, stale, stale); + updateSettingsAtomic( + settingsPath, + settings => ({ settings: { ...settings, recovered: true } }) + ); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { + recovered: true, + }); + assert.ok(!fs.existsSync(lockPath)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('atomic settings updates serialize nested ECC writers and release the lock', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-')); + const settingsPath = path.join(tempDir, 'settings.json'); + const lockPath = `${settingsPath}.ecc.lock`; + try { + updateSettingsAtomic(settingsPath, settings => { + assert.throws( + () => updateSettingsAtomic( + settingsPath, + nested => ({ settings: { ...nested, nested: true } }) + ), + /Another ECC process is updating Claude settings/ + ); + return { settings: { ...settings, outer: true } }; + }); + + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { + outer: true, + }); + assert.ok(!fs.existsSync(lockPath)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('atomic settings updates honor an already-held lock', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-lock-held-')); + const settingsPath = path.join(tempDir, 'settings.json'); + const lockPath = `${settingsPath}.ecc.lock`; + try { + fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid }), { mode: 0o600 }); + updateSettingsAtomic( + settingsPath, + settings => ({ settings: { ...settings, held: true } }), + { lockHeld: true } + ); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { held: true }); + assert.ok(fs.existsSync(lockPath)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('settings lock release failures do not replace the primary update error', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-release-error-')); + const settingsPath = path.join(tempDir, 'settings.json'); + const lockPath = `${settingsPath}.ecc.lock`; + try { + let caught; + try { + runWithSettingsLock(settingsPath, () => { + fs.rmSync(lockPath, { force: true }); + throw new Error('primary settings failure'); + }); + } catch (error) { + caught = error; + } + assert.ok(caught); + assert.strictEqual(caught.message, 'primary settings failure'); + assert.ok(caught.releaseError); + assert.strictEqual(caught.releaseError.code, 'ENOENT'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('atomic settings updates refuse a symlinked destination', () => { + if (process.platform === 'win32') { + console.log(' (file symlink support is environment-dependent on Windows; skipping)'); + return; + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-settings-symlink-')); + const realPath = path.join(tempDir, 'real.json'); + const settingsPath = path.join(tempDir, 'settings.json'); + try { + fs.writeFileSync(realPath, '{"theme":"dark"}\n', { mode: 0o600 }); + fs.symlinkSync(realPath, settingsPath); + assert.throws( + () => updateSettingsAtomic( + settingsPath, + settings => ({ settings: { ...settings, managed: true } }) + ), + error => error.code === 'ELOOP' || error.code === 'ECC_SETTINGS_CHANGED' + ); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(realPath, 'utf8')), { theme: 'dark' }); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('fresh merge appends managed entries while preserving unrelated settings and hooks', () => { + const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user-hook' }] }; + const settings = { + theme: 'dark', + hooks: { + SessionStart: [userEntry], + Notification: [{ id: 'user:notification', command: 'notify' }], + }, + }; + const managed = { + SessionStart: [entry('ecc:start', 'node /opt/ecc/start.js')], + Stop: [entry('ecc:stop', 'node /opt/ecc/stop.js')], + }; + const settingsBefore = clone(settings); + const managedBefore = clone(managed); + + const result = mergeManagedHooks(settings, managed); + + assert.deepStrictEqual(settings, settingsBefore); + assert.deepStrictEqual(managed, managedBefore); + assert.deepStrictEqual(result.settings, { + theme: 'dark', + hooks: { + SessionStart: [userEntry, managed.SessionStart[0]], + Notification: settings.hooks.Notification, + Stop: managed.Stop, + }, + }); + assert.deepStrictEqual(result.added, [ + { event: 'SessionStart', id: 'ecc:start' }, + { event: 'Stop', id: 'ecc:stop' }, + ]); + assert.deepStrictEqual(result.updated, []); + })) passed++; else failed++; + + if (test('fresh merge treats a different entry with the same event and id as a conflict', () => { + const settings = { + hooks: { + Stop: [entry('ecc:stop', 'user-modified')], + }, + }; + const managed = { + Stop: [entry('ecc:stop', 'managed')], + }; + + assert.throws( + () => mergeManagedHooks(settings, managed), + /Refusing to overwrite.*Stop.*ecc:stop/ + ); + assert.deepStrictEqual(settings.hooks.Stop[0], entry('ecc:stop', 'user-modified')); + })) passed++; else failed++; + + if (test('fresh merge adopts an identical existing event and id without duplicating it', () => { + const managed = { Stop: [entry('ecc:stop', 'managed')] }; + const result = mergeManagedHooks({ hooks: clone(managed) }, managed); + + assert.deepStrictEqual(result.settings.hooks.Stop, managed.Stop); + assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]); + })) passed++; else failed++; + + if (test('upgrade replaces an entry only while it still equals previous managed content', () => { + const previousManagedHooks = { Stop: [entry('ecc:stop', 'version-1')] }; + const managedHooks = { Stop: [entry('ecc:stop', 'version-2')] }; + const result = mergeManagedHooks( + { hooks: { Stop: [entry('ecc:stop', 'version-1')] } }, + managedHooks, + { previousManagedHooks } + ); + + assert.deepStrictEqual(result.settings.hooks.Stop, managedHooks.Stop); + assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]); + })) passed++; else failed++; + + if (test('upgrade fails closed when previous managed content has drifted', () => { + const settings = { hooks: { Stop: [entry('ecc:stop', 'customer-edit')] } }; + const before = clone(settings); + + assert.throws( + () => mergeManagedHooks( + settings, + { Stop: [entry('ecc:stop', 'version-2')] }, + { previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } } + ), + /drifted|Refusing to overwrite/ + ); + assert.deepStrictEqual(settings, before); + })) passed++; else failed++; + + if (test('upgrade is idempotent when the desired entry is already installed', () => { + const desired = { Stop: [entry('ecc:stop', 'version-2')] }; + const result = mergeManagedHooks( + { hooks: clone(desired) }, + desired, + { previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } } + ); + + assert.deepStrictEqual(result.settings.hooks, desired); + assert.deepStrictEqual(result.unchanged, [{ event: 'Stop', id: 'ecc:stop' }]); + })) passed++; else failed++; + + if (test('upgrade removes unchanged entries that are no longer managed', () => { + const previousManagedHooks = { + Stop: [ + entry('ecc:keep', 'version-1'), + entry('ecc:removed', 'old-command'), + ], + }; + const desired = { Stop: [entry('ecc:keep', 'version-2')] }; + const userEntry = entry('user:stop', 'keep-user'); + const result = mergeManagedHooks({ + hooks: { Stop: [userEntry, ...previousManagedHooks.Stop] }, + }, desired, { previousManagedHooks }); + + assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry, desired.Stop[0]]); + assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:removed' }]); + })) passed++; else failed++; + + if (test('upgrade removes multiple retired hooks without deleting their neighbor', () => { + const previousManagedHooks = { + Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')], + }; + const userEntry = entry('user:c', 'keep-user'); + const result = mergeManagedHooks({ + hooks: { Stop: [...previousManagedHooks.Stop, userEntry] }, + }, { SessionStart: [entry('ecc:start', 'start')] }, { previousManagedHooks }); + + assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]); + assert.deepStrictEqual(result.removed, [ + { event: 'Stop', id: 'ecc:a' }, + { event: 'Stop', id: 'ecc:b' }, + ]); + })) passed++; else failed++; + + if (test('upgrade refuses to remove a retired entry after user drift', () => { + const previousManagedHooks = { Stop: [entry('ecc:removed', 'old-command')] }; + const settings = { hooks: { Stop: [entry('ecc:removed', 'user-edited')] } }; + + assert.throws( + () => mergeManagedHooks(settings, { SessionStart: [entry('ecc:start', 'start')] }, { + previousManagedHooks, + }), + /Refusing to remove.*ecc:removed.*drifted/ + ); + })) passed++; else failed++; + + if (test('merge fails closed when settings contains ambiguous duplicate event ids', () => { + assert.throws( + () => mergeManagedHooks( + { + hooks: { + Stop: [ + entry('ecc:stop', 'version-1'), + entry('ecc:stop', 'another-copy'), + ], + }, + }, + { Stop: [entry('ecc:stop', 'version-2')] }, + { previousManagedHooks: { Stop: [entry('ecc:stop', 'version-1')] } } + ), + /multiple.*ecc:stop/i + ); + })) passed++; else failed++; + + if (test('merge treats the same id under another event as a separate user entry', () => { + const result = mergeManagedHooks( + { hooks: { SessionStart: [entry('shared:id', 'existing')] } }, + { Stop: [entry('shared:id', 'desired')] } + ); + assert.deepStrictEqual(result.settings.hooks, { + SessionStart: [entry('shared:id', 'existing')], + Stop: [entry('shared:id', 'desired')], + }); + })) passed++; else failed++; + + if (test('repair mode overwrites a drifted same-event managed id and preserves neighbors', () => { + const userEntry = { id: 'user:hook', command: 'keep-me' }; + const result = repairManagedHooks( + { hooks: { Stop: [userEntry, entry('ecc:stop', 'drifted')] } }, + { Stop: [entry('ecc:stop', 'repaired')] } + ); + + assert.deepStrictEqual(result.settings.hooks.Stop, [ + userEntry, + entry('ecc:stop', 'repaired'), + ]); + assert.deepStrictEqual(result.updated, [{ event: 'Stop', id: 'ecc:stop' }]); + })) passed++; else failed++; + + if (test('inspect reports exact, missing, and drifted managed entries plus the actual subset', () => { + const expected = { + SessionStart: [entry('ecc:start', 'start')], + Stop: [ + entry('ecc:stop', 'expected'), + entry('ecc:missing', 'missing'), + ], + }; + const actualStart = entry('ecc:start', 'start'); + const actualDrift = entry('ecc:stop', 'changed'); + const result = inspectManagedHooks({ + hooks: { + SessionStart: [actualStart, { id: 'user:start', command: 'user' }], + Stop: [actualDrift], + }, + }, expected); + + assert.strictEqual(result.status, 'missing'); + assert.deepStrictEqual(result.matched, [{ event: 'SessionStart', id: 'ecc:start' }]); + assert.deepStrictEqual(result.missing, [{ event: 'Stop', id: 'ecc:missing' }]); + assert.deepStrictEqual(result.drifted, [{ + event: 'Stop', + id: 'ecc:stop', + expected: expected.Stop[0], + actual: actualDrift, + }]); + assert.deepStrictEqual(result.managedHooks, { + SessionStart: [actualStart], + Stop: [actualDrift], + }); + })) passed++; else failed++; + + if (test('inspect fails closed on duplicate matching ids in one settings event', () => { + assert.throws( + () => inspectManagedHooks( + { hooks: { Stop: [entry('ecc:stop', 'a'), entry('ecc:stop', 'b')] } }, + { Stop: [entry('ecc:stop', 'expected')] } + ), + /multiple.*ecc:stop/i + ); + })) passed++; else failed++; + + if (test('inspect and uninstall key ownership by event plus id', () => { + const recorded = { Stop: [entry('ecc:stop', 'managed')] }; + const moved = { hooks: { SessionStart: [entry('ecc:stop', 'managed')] } }; + + const inspection = inspectManagedHooks(moved, recorded); + assert.strictEqual(inspection.status, 'missing'); + assert.deepStrictEqual(inspection.missing, [{ event: 'Stop', id: 'ecc:stop' }]); + + const uninstall = uninstallManagedHooks(moved, recorded); + assert.deepStrictEqual(uninstall.settings, moved); + assert.deepStrictEqual(uninstall.missing, [{ event: 'Stop', id: 'ecc:stop' }]); + })) passed++; else failed++; + + if (test('uninstall removes exact recorded entries, retains drift, and cleans empty events', () => { + const recorded = { + SessionStart: [entry('ecc:start', 'start')], + Stop: [entry('ecc:stop', 'recorded')], + Notification: [entry('ecc:notify', 'notify')], + }; + const userEntry = { matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] }; + const driftedStop = entry('ecc:stop', 'customer-edit'); + const settings = { + theme: 'dark', + hooks: { + SessionStart: [recorded.SessionStart[0]], + Stop: [userEntry, driftedStop], + Notification: [recorded.Notification[0]], + }, + }; + const before = clone(settings); + + const result = uninstallManagedHooks(settings, recorded); + + assert.deepStrictEqual(settings, before); + assert.deepStrictEqual(result.settings, { + theme: 'dark', + hooks: { + Stop: [userEntry, driftedStop], + }, + }); + assert.deepStrictEqual(result.removed, [ + { event: 'SessionStart', id: 'ecc:start' }, + { event: 'Notification', id: 'ecc:notify' }, + ]); + assert.deepStrictEqual(result.retained, [{ + event: 'Stop', + id: 'ecc:stop', + expected: recorded.Stop[0], + actual: driftedStop, + reason: 'modified', + }]); + })) passed++; else failed++; + + if (test('uninstall removes consecutive managed hooks without deleting a user neighbor', () => { + const recorded = { Stop: [entry('ecc:a', 'a'), entry('ecc:b', 'b')] }; + const userEntry = entry('user:c', 'keep-user'); + const result = uninstallManagedHooks({ + hooks: { Stop: [...recorded.Stop, userEntry] }, + }, recorded); + + assert.deepStrictEqual(result.settings.hooks.Stop, [userEntry]); + assert.deepStrictEqual(result.removed, [ + { event: 'Stop', id: 'ecc:a' }, + { event: 'Stop', id: 'ecc:b' }, + ]); + })) passed++; else failed++; + + if (test('uninstall removes hooks entirely after the final managed event is emptied', () => { + const recorded = { Stop: [entry('ecc:stop', 'recorded')] }; + const result = uninstallManagedHooks({ theme: 'dark', hooks: clone(recorded) }, recorded); + + assert.deepStrictEqual(result.settings, { theme: 'dark' }); + assert.deepStrictEqual(result.removed, [{ event: 'Stop', id: 'ecc:stop' }]); + assert.deepStrictEqual(result.retained, []); + })) passed++; else failed++; + + if (test('uninstall accepts structurally valid hooks from an older runtime contract', () => { + const recorded = { + LegacyEvent: [{ + id: 'ecc:legacy', + hooks: [{ type: 'legacy-handler', payload: { version: 1 } }], + }], + }; + const result = uninstallManagedHooks({ hooks: clone(recorded) }, recorded); + + assert.deepStrictEqual(result.settings, {}); + assert.deepStrictEqual(result.removed, [{ event: 'LegacyEvent', id: 'ecc:legacy' }]); + })) passed++; else failed++; + + if (test('all settings transforms reject non-array hook events before changing data', () => { + const settings = { hooks: { Stop: 'invalid' } }; + const managed = { Stop: [entry('ecc:stop', 'expected')] }; + + assert.throws(() => mergeManagedHooks(settings, managed), /hooks\.Stop.*array/); + assert.throws(() => repairManagedHooks(settings, managed), /hooks\.Stop.*array/); + assert.throws(() => inspectManagedHooks(settings, managed), /hooks\.Stop.*array/); + assert.throws(() => uninstallManagedHooks(settings, managed), /hooks\.Stop.*array/); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js index 716a91b3a..2f44361e2 100644 --- a/tests/lib/hook-consent.test.js +++ b/tests/lib/hook-consent.test.js @@ -27,10 +27,23 @@ function test(name, fn) { } function buildHookPlan() { + const managedHooks = { + SessionStart: [{ + id: 'session:start', + matcher: '.*', + hooks: [{ type: 'command', command: 'node /target/scripts/hooks/session-start.js' }], + }], + }; return { operations: [ { kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' }, - { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' }, + { + kind: 'update-claude-settings', + moduleId: 'hooks-runtime', + sourceRelativePath: 'hooks/hooks.json', + destinationPath: '/target/settings.json', + managedHooks, + }, { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'scripts/hooks/session-start.js', destinationPath: '/target/scripts/hooks/session-start.js' }, ], selectedModuleIds: ['rules-core', 'hooks-runtime'], @@ -46,7 +59,13 @@ function buildHookPlan() { }, operations: [ { kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' }, - { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' }, + { + kind: 'update-claude-settings', + moduleId: 'hooks-runtime', + sourceRelativePath: 'hooks/hooks.json', + destinationPath: '/target/settings.json', + managedHooks, + }, ], resolution: { selectedModules: ['rules-core', 'hooks-runtime'], skippedModules: [] }, }, @@ -69,6 +88,7 @@ function runTests() { if (test('matches hook runtime operations by module id and source path', () => { assert.strictEqual(isHookRuntimeOperation({ moduleId: 'hooks-runtime' }), true); + assert.strictEqual(isHookRuntimeOperation({ kind: 'update-claude-settings' }), true); assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'hooks/hooks.json' }), true); assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: '.cursor/hooks.json' }), true); assert.strictEqual(isHookRuntimeOperation({ destinationPath: '/root/.claude/hooks/hooks.json' }), true); diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index 4a65ce5ef..6f64b540c 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -9,6 +9,7 @@ const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); +const { spawnSync } = require('child_process'); const { applyInstallPlan, @@ -19,6 +20,7 @@ const { listAvailableLanguages, } = require('../../scripts/lib/install-executor'); const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply'); +const { withHookConsent } = require('../../scripts/lib/install/hook-consent'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -166,6 +168,91 @@ function runTests() { } })) passed++; else failed++; + if (test('Claude settings write preserves unrelated changes made after preflight', () => { + const tempDir = createTempDir('install-executor-settings-race-'); + try { + const homeDir = path.join(tempDir, 'home'); + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + const rawPlan = createManifestInstallPlan({ + sourceRoot: REPO_ROOT, + homeDir, + projectRoot, + target: 'claude', + moduleIds: ['hooks-runtime'], + }); + const plan = withHookConsent(rawPlan, 'enabled'); + const settingsPath = path.join(homeDir, '.claude', 'settings.json'); + + applyInstallPlanDirect(plan, { + beforeOperationWrite({ operation }) { + if (operation.kind === 'update-claude-settings') { + fs.writeFileSync(settingsPath, '{"theme":"added-after-preflight"}\n'); + } + }, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.strictEqual(settings.theme, 'added-after-preflight'); + assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start')); + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + + if (test('failed hook disable checkpoints the previous enabled consent state', () => { + const tempDir = createTempDir('install-executor-disable-failure-'); + try { + const homeDir = path.join(tempDir, 'home'); + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + const enabledPlan = withHookConsent(createManifestInstallPlan({ + sourceRoot: REPO_ROOT, + homeDir, + projectRoot, + target: 'claude', + profileId: 'core', + }), 'enabled'); + applyInstallPlanDirect(enabledPlan); + + const declinedPlan = withHookConsent(createManifestInstallPlan({ + sourceRoot: REPO_ROOT, + homeDir, + projectRoot, + target: 'claude', + profileId: 'core', + }), 'declined'); + let injectedFailure = false; + assert.throws( + () => applyInstallPlanDirect(declinedPlan, { + beforeOperationWrite({ operation }) { + if (!injectedFailure && operation.kind === 'copy-file') { + injectedFailure = true; + throw new Error('injected copy failure'); + } + }, + }), + /injected copy failure/ + ); + + const state = JSON.parse(fs.readFileSync(declinedPlan.installStatePath, 'utf8')); + assert.strictEqual(state.request.hookConsent, 'enabled'); + assert.ok(state.resolution.selectedModules.includes('hooks-runtime')); + assert.ok(state.operations.some(operation => ( + operation.kind === 'update-claude-settings' + ))); + const settings = JSON.parse(fs.readFileSync( + path.join(homeDir, '.claude', 'settings.json'), + 'utf8' + )); + assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start')); + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('rejects unknown legacy install targets before planning', () => { assert.throws( () => createLegacyInstallPlan({ target: 'not-a-target' }), @@ -400,6 +487,122 @@ function runTests() { } })) passed++; else failed++; + if (test('plans one resolved Claude settings hook registration for home and project targets', () => { + const tempDir = createTempDir('install-executor-claude-hooks-'); + try { + for (const target of ['claude', 'claude-project']) { + const quoted = process.platform === 'win32' ? 'quoted' : '"quoted"'; + const homeDir = path.join(tempDir, `${target} home ${quoted} $dollar %percent%`); + const projectRoot = path.join(tempDir, `${target} project ${quoted} $dollar %percent%`); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + + const plan = createManifestInstallPlan({ + sourceRoot: REPO_ROOT, + homeDir, + projectRoot, + target, + moduleIds: ['hooks-runtime'], + }); + const expectedRoot = target === 'claude' + ? path.join(homeDir, '.claude') + : path.join(projectRoot, '.claude'); + const settingsOperations = plan.operations.filter(operation => ( + operation.kind === 'update-claude-settings' + )); + + assert.strictEqual(settingsOperations.length, 1, `${target} should plan one settings update`); + const operation = settingsOperations[0]; + assert.strictEqual(operation.moduleId, 'hooks-runtime'); + assert.strictEqual( + operation.sourceRelativePath.split(path.sep).join('/'), + 'hooks/hooks.json' + ); + assert.strictEqual(operation.destinationPath, path.join(expectedRoot, 'settings.json')); + assert.ok(operation.managedHooks); + assert.ok(operation.managedHooks.SessionStart.some(entry => ( + entry.id === 'session:start' + ))); + const commands = Object.values(operation.managedHooks) + .flat() + .flatMap(entry => entry.hooks || []) + .map(hook => hook.command) + .filter(command => typeof command === 'string'); + const encodedRoot = Buffer.from(expectedRoot, 'utf8').toString('base64'); + assert.ok(commands.some(command => command.includes(encodedRoot))); + assert.ok(commands.every(command => !command.includes(expectedRoot))); + assert.ok( + commands.every(command => !command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')), + `${target} commands should not depend on an unset CLAUDE_PLUGIN_ROOT` + ); + if (process.platform !== 'win32') { + for (const command of commands) { + const syntaxCheck = spawnSync('/bin/sh', ['-n', '-c', command], { + encoding: 'utf8', + }); + assert.strictEqual( + syntaxCheck.status, + 0, + `${target} hook command should remain shell-safe: ${syntaxCheck.stderr}` + ); + } + } + assert.ok(!plan.operations.some(candidate => ( + candidate.kind === 'copy-file' + && candidate.sourceRelativePath.split(path.sep).join('/') === 'hooks/hooks.json' + ))); + + const stateOperation = plan.statePreview.operations.find(candidate => ( + candidate.kind === 'update-claude-settings' + )); + assert.deepStrictEqual(stateOperation.managedHooks, operation.managedHooks); + } + } finally { + cleanup(tempDir); + } + })) passed++; else failed++; + + if (test('Claude commit-attribution atomic write failures abort installation', () => { + const tempDir = createTempDir('install-executor-attribution-failure-'); + const originalRenameSync = fs.renameSync; + try { + const homeDir = path.join(tempDir, 'home'); + const projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + const rawPlan = createManifestInstallPlan({ + sourceRoot: REPO_ROOT, + homeDir, + projectRoot, + target: 'claude', + moduleIds: ['hooks-runtime'], + }); + const plan = withHookConsent(rawPlan, 'enabled'); + const settingsPath = path.join(homeDir, '.claude', 'settings.json'); + let settingsCommitCount = 0; + fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) { + if (path.resolve(String(destinationPath)) === path.resolve(settingsPath)) { + settingsCommitCount += 1; + if (settingsCommitCount === 2) { + throw new Error('injected attribution rename failure'); + } + } + return originalRenameSync.call(fs, sourcePath, destinationPath); + }; + + assert.throws( + () => applyInstallPlanDirect(plan), + /injected attribution rename failure/ + ); + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start')); + assert.strictEqual(Object.hasOwn(settings, 'includeCoAuthoredBy'), false); + } finally { + fs.renameSync = originalRenameSync; + cleanup(tempDir); + } + })) passed++; else failed++; + if (test('creates legacy compatibility manifest plans from language selections', () => { const projectRoot = createTempDir('install-executor-project-'); const homeDir = createTempDir('install-executor-home-'); diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 51d39f9e1..ab20e54ca 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -23,6 +23,10 @@ const { readInstallState, writeInstallState, } = require('../../scripts/lib/install-state'); +const { + assertClaudeSettingsPath, + materializeManagedHooks, +} = require('../../scripts/lib/install/claude-settings'); const REPO_ROOT = path.join(__dirname, '..', '..'); const CURRENT_PACKAGE_VERSION = JSON.parse( @@ -52,6 +56,10 @@ function cleanup(dirPath) { fs.rmSync(dirPath, { recursive: true, force: true }); } +function formatJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + function writeState(filePath, options) { const state = createInstallState(options); writeInstallState(filePath, state); @@ -100,6 +108,61 @@ function writeCursorState(projectRoot, overrides = {}) { }; } +function writeClaudeState(homeDir, overrides = {}) { + const targetRoot = overrides.targetRoot || path.join(homeDir, '.claude'); + const installStatePath = overrides.installStatePath + || path.join(targetRoot, 'ecc', 'install-state.json'); + const options = { + adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, + targetRoot, + installStatePath, + request: { + profile: null, + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: true, + hookConsent: 'enabled', + ...(overrides.request || {}), + }, + resolution: { + selectedModules: ['legacy-claude-install'], + skippedModules: [], + ...(overrides.resolution || {}), + }, + operations: overrides.operations || [], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + ...(overrides.source || {}), + }, + }; + + writeState(installStatePath, options); + return { + targetRoot, + installStatePath, + state: options, + }; +} + +function managedHookEntry(id, command) { + return { + id, + matcher: '.*', + hooks: [{ type: 'command', command }], + }; +} + +function currentManagedHooks(targetRoot) { + return materializeManagedHooks( + JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'hooks', 'hooks.json'), 'utf8')), + targetRoot + ); +} + function createOpencodeStateOptions(homeDir, overrides = {}) { const targetRoot = overrides.targetRoot || path.join(homeDir, '.config', 'opencode'); const installStatePath = overrides.installStatePath || path.join(targetRoot, 'ecc-install-state.json'); @@ -171,8 +234,10 @@ function withTemporarilyMovedPath(filePath, callback) { function managedOperation(kind, destinationPath, overrides = {}) { const operation = { kind, - moduleId: 'test-module', - sourceRelativePath: 'rules/common/coding-style.md', + moduleId: kind === 'update-claude-settings' ? 'hooks-runtime' : 'test-module', + sourceRelativePath: kind === 'update-claude-settings' + ? 'hooks/hooks.json' + : 'rules/common/coding-style.md', destinationPath, strategy: kind, ownership: 'managed', @@ -3231,6 +3296,465 @@ function runTests() { } })) passed++; else failed++; + if (test('doctor inspects update-claude-settings hooks by event and id', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = currentManagedHooks(targetRoot); + const stopEntry = managedHooks.Stop[0]; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: { + Stop: [ + { id: 'user:stop', matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] }, + ...managedHooks.Stop, + ], + ...Object.fromEntries(Object.entries(managedHooks).filter(([event]) => event !== 'Stop')), + }, + })); + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + let report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + assert.strictEqual(report.results[0].status, 'ok'); + + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: { + ...managedHooks, + Stop: [ + { id: 'user:stop', matcher: 'Bash', hooks: [{ type: 'command', command: 'user' }] }, + { ...stopEntry, description: 'drifted' }, + ...managedHooks.Stop.slice(1), + ], + }, + })); + report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + assert.strictEqual(report.results[0].status, 'warning'); + assert.ok(report.results[0].issues.some(issue => issue.code === 'drifted-managed-files')); + + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: { + ...managedHooks, + Stop: managedHooks.Stop.filter(entry => entry.id !== stopEntry.id), + }, + })); + report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + assert.strictEqual(report.results[0].status, 'error'); + assert.ok(report.results[0].issues.some(issue => issue.code === 'missing-managed-files')); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('doctor and repair surface malformed Claude settings errors', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = currentManagedHooks(targetRoot); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, '{ invalid json\n'); + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + const issue = report.results[0].issues.find(candidate => ( + candidate.code === 'invalid-claude-settings' + )); + assert.strictEqual(report.results[0].status, 'error'); + assert.ok(issue, 'doctor should report an invalid Claude settings issue'); + assert.match(issue.message, /Failed to inspect Claude settings/); + + const repair = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + assert.strictEqual(repair.results[0].status, 'error'); + assert.match(repair.results[0].error, /Failed to inspect Claude settings/); + assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('repair restores managed Claude hooks while preserving user settings and hooks', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const userHook = { + id: 'user:stop', + matcher: 'Bash', + hooks: [{ type: 'command', command: 'user-command' }], + }; + const managedHooks = currentManagedHooks(targetRoot); + const stopEntry = managedHooks.Stop[0]; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: { + ...managedHooks, + Stop: [ + userHook, + { ...stopEntry, description: 'drifted' }, + ...managedHooks.Stop.slice(1), + ], + }, + })); + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(result.results[0].status, 'repaired'); + assert.ok(result.results[0].repairedPaths.includes(settingsPath)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { + theme: 'dark', + hooks: { + ...managedHooks, + Stop: [userHook, ...managedHooks.Stop], + }, + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('repair creates missing Claude settings with private permissions', () => { + if (process.platform === 'win32') { + console.log(' (POSIX file modes unsupported on this platform; skipping)'); + return; + } + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = currentManagedHooks(targetRoot); + fs.mkdirSync(targetRoot, { recursive: true }); + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(result.results[0].status, 'repaired'); + const descriptor = fs.openSync(settingsPath, 'r'); + try { + assert.strictEqual(fs.fstatSync(descriptor).mode & 0o777, 0o600); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(descriptor, 'utf8')).hooks, managedHooks); + } finally { + fs.closeSync(descriptor); + } + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('repair removes retired managed hooks using the recorded ownership snapshot', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const currentHooks = currentManagedHooks(targetRoot); + const retiredHook = managedHookEntry('ecc:retired', 'node retired.js'); + const recordedHooks = { + ...currentHooks, + Stop: [...currentHooks.Stop, retiredHook], + }; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: recordedHooks, + })); + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + managedHooks: recordedHooks, + }), + ], + }); + + const result = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(result.results[0].status, 'repaired'); + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(!repaired.hooks.Stop.some(entry => entry.id === 'ecc:retired')); + assert.deepStrictEqual(repaired.hooks, currentHooks); + const state = readInstallState(path.join(targetRoot, 'ecc', 'install-state.json')); + const settingsOperation = state.operations.find(operation => ( + operation.kind === 'update-claude-settings' + )); + assert.deepStrictEqual(settingsOperation.managedHooks, currentHooks); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('uninstall removes only unchanged managed Claude hooks and reports drift as partial', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = { + SessionStart: [managedHookEntry('ecc:start', 'node managed-start.js')], + Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')], + }; + const userHook = { + id: 'user:stop', + matcher: 'Bash', + hooks: [{ type: 'command', command: 'user-command' }], + }; + const driftedHook = managedHookEntry('ecc:stop', 'node user-edited-stop.js'); + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: { + SessionStart: managedHooks.SessionStart, + Stop: [userHook, driftedHook], + }, + })); + const { installStatePath } = writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const result = uninstallInstalledStates({ + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(result.results[0].status, 'partial'); + assert.deepStrictEqual(result.results[0].retainedPaths, [settingsPath]); + assert.ok(fs.existsSync(installStatePath)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { + theme: 'dark', + hooks: { + Stop: [userHook, driftedHook], + }, + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('uninstall clears empty hook containers but preserves unrelated Claude settings', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = { + Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')], + }; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(settingsPath, formatJson({ + theme: 'dark', + hooks: managedHooks, + })); + const { installStatePath } = writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const result = uninstallInstalledStates({ + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(result.results[0].status, 'uninstalled'); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')), { + theme: 'dark', + }); + assert.ok(!fs.existsSync(installStatePath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('Claude settings lifecycle refuses a final-symlink destination', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const victimPath = path.join(targetRoot, 'victim.json'); + const settingsPath = path.join(targetRoot, 'settings.json'); + const managedHooks = { + Stop: [managedHookEntry('ecc:stop', 'node managed-stop.js')], + }; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(victimPath, formatJson({ sentinel: true, hooks: managedHooks })); + try { + fs.symlinkSync(victimPath, settingsPath, 'file'); + } catch { + console.log(' (file symlink unsupported on this platform; skipping)'); + return; + } + writeClaudeState(homeDir, { + operations: [ + managedOperation('update-claude-settings', settingsPath, { + sourceRelativePath: 'hooks/hooks.json', + strategy: 'update-claude-settings', + managedHooks, + }), + ], + }); + + const doctor = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + const repair = repairInstalledStates({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['claude'], + }); + const uninstall = uninstallInstalledStates({ + homeDir, + projectRoot, + targets: ['claude'], + }); + + assert.strictEqual(doctor.results[0].status, 'error'); + assert.ok(doctor.results[0].issues.some(issue => ( + issue.code === 'unsafe-managed-destination' + ))); + assert.strictEqual(repair.results[0].status, 'error'); + assert.match(repair.results[0].error, /final symlink/); + assert.strictEqual(uninstall.results[0].status, 'error'); + assert.match(uninstall.results[0].error, /final symlink/); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(victimPath, 'utf8')), { + sentinel: true, + hooks: managedHooks, + }); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('Claude settings path validation refuses a non-canonical destination', () => { + const homeDir = createTempDir('install-lifecycle-claude-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const targetRoot = path.join(homeDir, '.claude'); + const destinationPath = path.join(targetRoot, 'settings.local.json'); + fs.mkdirSync(targetRoot, { recursive: true }); + assert.throws( + () => assertClaudeSettingsPath(destinationPath, targetRoot), + /outside the canonical settings file/ + ); + assert.ok(!fs.existsSync(destinationPath)); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/lib/install-state.test.js b/tests/lib/install-state.test.js index 8baa6c3e5..653a6bb10 100644 --- a/tests/lib/install-state.test.js +++ b/tests/lib/install-state.test.js @@ -84,6 +84,105 @@ function runTests() { assert.strictEqual(state.operations.length, 1); })) passed++; else failed++; + if (test('validates managed hook metadata for Claude settings operations', () => { + const baseOptions = { + adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, + targetRoot: '/home/test/.claude', + installStatePath: '/home/test/.claude/ecc/install-state.json', + request: { + profile: 'core', + modules: ['hooks-runtime'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + hookConsent: 'enabled', + }, + resolution: { selectedModules: ['hooks-runtime'], skippedModules: [] }, + source: { repoVersion: CURRENT_PACKAGE_VERSION, repoCommit: 'abc123', manifestVersion: 1 }, + }; + const operation = { + kind: 'update-claude-settings', + moduleId: 'hooks-runtime', + sourceRelativePath: 'hooks/hooks.json', + destinationPath: '/home/test/.claude/settings.json', + strategy: 'merge-hook-ids', + ownership: 'managed', + scaffoldOnly: false, + managedHooks: { + SessionStart: [{ + id: 'session:start', + matcher: '.*', + hooks: [{ type: 'command', command: 'node start.js' }], + }], + }, + }; + + assert.doesNotThrow(() => createInstallState({ ...baseOptions, operations: [operation] })); + assert.throws( + () => createInstallState({ + ...baseOptions, + operations: [{ ...operation, moduleId: 'not-hooks-runtime' }], + }), + /moduleId.*hooks-runtime/ + ); + assert.throws( + () => createInstallState({ + ...baseOptions, + operations: [{ ...operation, sourceRelativePath: 'attacker.json' }], + }), + /sourceRelativePath.*hooks\/hooks\.json/ + ); + assert.throws( + () => createInstallState({ + ...baseOptions, + operations: [{ + ...operation, + destinationPath: '/home/test/.claude/settings.local.json', + }], + }), + /destinationPath.*canonical Claude settings path/ + ); + assert.throws( + () => createInstallState({ + ...baseOptions, + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + targetRoot: '/repo/.cursor', + installStatePath: '/repo/.cursor/ecc-install-state.json', + operations: [{ + ...operation, + destinationPath: '/repo/.cursor/settings.json', + }], + }), + /only valid for Claude targets/ + ); + assert.throws( + () => createInstallState({ + ...baseOptions, + operations: [{ + ...operation, + managedHooks: { + SessionStart: [{ + matcher: '.*', + hooks: [{ type: 'command', command: 'node start.js' }], + }], + }, + }], + }), + /managedHooks.*Invalid hook entry/ + ); + assert.doesNotThrow(() => createInstallState({ + ...baseOptions, + operations: [{ + ...operation, + managedHooks: { + SessionStart: [{ id: 'shared', hooks: [] }], + LegacyEvent: [{ id: 'shared', hooks: [{ type: 'legacy' }] }], + }, + }], + })); + })) passed++; else failed++; + if (test('writes and reads install-state from disk', () => { const testDir = createTestDir(); const statePath = path.join(testDir, 'ecc-install-state.json'); diff --git a/tests/lib/missing-dependency.test.js b/tests/lib/missing-dependency.test.js new file mode 100644 index 000000000..61f4af46b --- /dev/null +++ b/tests/lib/missing-dependency.test.js @@ -0,0 +1,70 @@ +/** + * Tests for scripts/lib/missing-dependency.js + */ + +const assert = require('assert'); + +const { describeMissingDependencyError } = require('../../scripts/lib/missing-dependency'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function moduleNotFoundError(moduleName, requireStack) { + const error = new Error( + `Cannot find module '${moduleName}'\nRequire stack:\n${requireStack.map(entry => `- ${entry}`).join('\n')}` + ); + error.code = 'MODULE_NOT_FOUND'; + return error; +} + +function runTests() { + console.log('\n=== Testing missing-dependency.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('describes a missing production dependency with an install command', () => { + const error = moduleNotFoundError('ajv', [ + 'scripts/lib/install/config.js', + 'scripts/install-plan.js', + ]); + const message = describeMissingDependencyError(error); + assert.ok(message.includes("'ajv'")); + assert.ok(message.includes('npm install')); + assert.ok(message.includes('ajv@8.20.0')); + })) passed++; else failed++; + + if (test('recognizes every declared production dependency', () => { + for (const moduleName of ['ajv', 'sql.js', 'js-yaml', '@iarna/toml']) { + const error = moduleNotFoundError(moduleName, ['some/file.js']); + assert.ok(describeMissingDependencyError(error), `expected a message for ${moduleName}`); + } + })) passed++; else failed++; + + if (test('returns null for an unrelated MODULE_NOT_FOUND error', () => { + const error = moduleNotFoundError('./lib/some-local-file', ['scripts/foo.js']); + assert.strictEqual(describeMissingDependencyError(error), null); + })) passed++; else failed++; + + if (test('returns null for a non-MODULE_NOT_FOUND error', () => { + assert.strictEqual(describeMissingDependencyError(new Error('boom')), null); + })) passed++; else failed++; + + if (test('returns null for a falsy error', () => { + assert.strictEqual(describeMissingDependencyError(null), null); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/multi-harness-setup.test.js b/tests/lib/multi-harness-setup.test.js index f6098e4b5..24b5a5f45 100644 --- a/tests/lib/multi-harness-setup.test.js +++ b/tests/lib/multi-harness-setup.test.js @@ -569,6 +569,76 @@ function writeManagedState(plan, overrides = {}) { } }); + await test('preserves an initially identical user file without shifting later write checks', async () => { + const root = tempDir('ecc-guided-identical-preserved-'); + const projection = require('../../scripts/lib/install-state-store-sync'); + const originalProjection = projection.projectCanonicalInstallState; + // This case verifies canonical ownership, not the optional derived cache. + projection.projectCanonicalInstallState = async () => ({ status: 'projected' }); + try { + const source = path.join(root, 'source.md'); + const userFile = path.join(root, '.kimi-code', 'rules', 'existing.md'); + const newFile = path.join(root, '.kimi-code', 'rules', 'new.md'); + writeFile(source, 'ecc\n'); + writeFile(userFile, 'ecc\n'); + const plan = managedPlan(root, [ + stateOperation(userFile, { sourcePath: source }), + stateOperation(newFile, { sourcePath: source }), + ]); + const result = await applyMultiHarnessPlan({ + harnesses: [{ id: 'kimi', preview: preflightManagedPlan(plan) }], + request: { harnesses: ['kimi'] }, + }); + assert.strictEqual(result.status, 'complete'); + assert.strictEqual(fs.readFileSync(userFile, 'utf8'), 'ecc\n'); + assert.strictEqual(fs.readFileSync(newFile, 'utf8'), 'ecc\n'); + const state = JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); + assert.ok(!state.operations.some(operation => operation.destinationPath === userFile)); + assert.ok(state.operations.some(operation => operation.destinationPath === newFile)); + } finally { + projection.projectCanonicalInstallState = originalProjection; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + for (const existing of [false, true]) { + await test(`applies ordered JSON merges to the same ${existing ? 'existing' : 'new'} destination`, async () => { + const root = tempDir('ecc-guided-repeated-json-'); + const projection = require('../../scripts/lib/install-state-store-sync'); + const originalProjection = projection.projectCanonicalInstallState; + projection.projectCanonicalInstallState = async () => ({ status: 'projected' }); + try { + const destination = path.join(root, '.kimi-code', 'mcp.json'); + if (existing) writeFile(destination, JSON.stringify({ userSetting: true })); + const plan = managedPlan(root, [ + stateOperation(destination, { + kind: 'merge-json', + mergePayload: { servers: { first: { command: 'first' } }, sequence: 'first' }, + strategy: 'merge-json', + }), + stateOperation(destination, { + kind: 'merge-json', + mergePayload: { servers: { second: { command: 'second' } }, sequence: 'second' }, + strategy: 'merge-json', + }), + ]); + const result = await applyMultiHarnessPlan({ + harnesses: [{ id: 'kimi', preview: preflightManagedPlan(plan) }], + request: { harnesses: ['kimi'] }, + }); + assert.strictEqual(result.status, 'complete', JSON.stringify(result.failure)); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(destination, 'utf8')), { + ...(existing ? { userSetting: true } : {}), + servers: { first: { command: 'first' }, second: { command: 'second' } }, + sequence: 'second', + }); + } finally { + projection.projectCanonicalInstallState = originalProjection; + fs.rmSync(root, { recursive: true, force: true }); + } + }); + } + await test('refuses conflicting JSON created after preview but before apply', async () => { const root = tempDir('ecc-guided-late-json-collision-'); try { diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js new file mode 100644 index 000000000..43f01994a --- /dev/null +++ b/tests/lib/powershell-destructive-command.test.js @@ -0,0 +1,863 @@ +'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('resolves scalar values supplied to aliases and shell stdin', () => { + for (const command of [ + "$cmd='Remove-Item'; Set-Alias zap $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; New-Alias -Name zap -Value $cmd; zap -Force C:/tmp/demo", + "$cmd='Remove-Item'; Set-Alias -Name zap -Value:$cmd; zap -Force C:/tmp/demo", + '$cmd=\'Remove-Item\'; Set-Alias zap "$cmd"; zap -Force C:/tmp/demo', + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -", + "$payload='Remove-Item -Force C:/tmp/demo'; Write-Output $payload | pwsh -Command -", + '$payload=\'Remove-Item -Force C:/tmp/demo\'; "$payload" | pwsh -Command -', + '$payload=\'Remove-Item -Force C:/tmp/demo\'; Write-Output "$payload" | pwsh -Command -', + ]) { + expectRules(command, [RULES.REMOVE_FORCE]); + } + expectSafe('Set-Alias zap $runtimeCommand'); + expectSafe("$cmd='Write-Output'; Set-Alias zap $cmd; zap ok"); + expectSafe("$payload='Write-Output ok'; $payload | pwsh -Command -"); + expectSafe("$payload='Remove-Item -Force C:/tmp/demo'; '$payload' | pwsh -Command -"); + expectSafe("$cmd='Remove-Item'; Set-Alias zap '$cmd'; zap -Force C:/tmp/demo"); +}); + +test('binds remaining alias positional arguments after named parameters', () => { + for (const definition of [ + 'Set-Alias -Name zap $cmd', + 'New-Alias -Name:zap $cmd', + 'Set-Alias -Value $cmd zap', + 'New-Alias zap -Value:$cmd', + ]) { + expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [ + RULES.REMOVE_FORCE, + ]); + expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]); + expectRules(`${definition}; zap -Force C:/tmp/demo; $cmd='Write-Output'`, [ + RULES.DYNAMIC_EXECUTION, + ]); + expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`); + expectSafe(definition); + } + expectRules('Set-Alias -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectRules('Set-Alias -Value Remove-Item zap; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); + expectSafe("$cmd='Remove-Item'; Set-Alias -Name zap '$cmd'; zap -Force C:/tmp/demo"); +}); + +test('binds alias auxiliary parameters independently of their layout', () => { + const parameters = [ + '-Scope Global', '-Sc:Global', '-Description demo', '-Desc:demo', + '-Option AllScope', '-Opt:AllScope', '-Option ReadOnly, Private', + '-Force', '-Fo:$false', '-PassThru', '-Pass:$false', + '-Verbose', '-vb:$false', '-Debug', '-db:$false', + '-Confirm:$false', '-cf:$false', '-WhatIf:$false', '-wi:$false', + '-ErrorAction Stop', '-ea:Stop', '-WarningAction Continue', '-wa:Continue', + '-InformationAction Continue', '-infa:Continue', '-ProgressAction Continue', + '-proga:Continue', '-ErrorVariable errors', '-ev:errors', + '-WarningVariable warnings', '-wv:warnings', '-InformationVariable info', '-iv:info', + '-OutVariable output', '-ov:output', '-OutBuffer 1', '-ob:1', + '-PipelineVariable item', '-pv:item', + ]; + for (const command of ['Set-Alias', 'New-Alias', 'sal', 'nal']) { + for (const parameter of parameters) { + for (const args of [ + `${parameter} -Name zap $cmd`, + `-Name zap ${parameter} $cmd`, + `-Name zap $cmd ${parameter}`, + `${parameter} zap -Value $cmd`, + `${parameter} zap $cmd`, + ]) { + const definition = `${command} ${args}`; + expectRules(`$cmd='Remove-Item'; ${definition}; zap -Force C:/tmp/demo`, [RULES.REMOVE_FORCE]); + expectRules(`${definition}; zap -Force C:/tmp/demo`, [RULES.DYNAMIC_EXECUTION]); + expectSafe(`$cmd='Write-Output'; ${definition}; zap ok`); + expectSafe(definition); + } + } + } + expectRules('Set-Alias -Scope Global -Name zap Remove-Item; zap -Force C:/tmp/demo', [RULES.REMOVE_FORCE]); +}); + +test('gates invoked aliases with unsupported or ambiguous parameter binding', () => { + for (const definition of [ + 'Set-Alias -Unknown demo -Name zap Write-Output', + 'Set-Alias -Unknown demo zap Write-Output', + 'Set-Alias -Name zap -V Write-Output', + 'Set-Alias -Name zap -Option AllScope extra Write-Output', + 'Set-Alias -Name zap @parameters', + ]) { + expectRules(`${definition}; zap ok`, [RULES.DYNAMIC_EXECUTION]); + expectSafe(`${definition}; Write-Output ok`); + } +}); + +test('gates unresolved aliases and stdin without using later or reassigned scalars', () => { + for (const command of [ + 'Set-Alias zap $cmd; zap -Force C:/tmp/demo', + "Set-Alias zap $cmd; zap -Force C:/tmp/demo; $cmd='Write-Output'", + "$cmd='Remove-Item'; Set-Alias zap $cmd; $cmd='Write-Output'; zap -Force C:/tmp/demo", + '$payload | pwsh -Command -', + 'Write-Output $payload | pwsh -Command -', + "$payload | pwsh -Command -; $payload='Write-Output ok'", + "$payload='Remove-Item -Force C:/tmp/demo'; $payload | pwsh -Command -; $payload='Write-Output ok'", + ]) { + expectRules(command, [RULES.DYNAMIC_EXECUTION]); + } +}); + +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); +} diff --git a/tests/pi/pi-extension-adapter.test.js b/tests/pi/pi-extension-adapter.test.js index aff53a8c8..984db7ed0 100644 --- a/tests/pi/pi-extension-adapter.test.js +++ b/tests/pi/pi-extension-adapter.test.js @@ -45,7 +45,11 @@ const fs = require("fs") const os = require("os") const path = require("path") const { spawnSync, execFile } = require("child_process") +const { resolveHookRuntime } = require( + path.join(__dirname, "..", "..", ".pi", "extensions", "hook-runtime.js") +) +/** Run a single adapter test and report the result. */ async function runTest(name, fn) { try { await fn() @@ -70,9 +74,9 @@ function stripComments(source) { } /** - * Mirrors the adapter's own hook invocation (`runEccHook` in - * .pi/extensions/index.ts): same binary (`process.execPath`), same argv - * shape, same stdin-JSON payload, same env keys. No shell is used anywhere. + * Invokes ECC's hook runner like the adapter (`runEccHook` in + * .pi/extensions/index.ts): it uses the test host's Node executable with the + * same argv shape and JSON payload on stdin. No shell is used. */ function runHookRunner(eccRoot, hookId, relScript, profiles, payload, extraEnv, cwd) { const runner = path.join(eccRoot, "scripts", "hooks", "run-with-flags.js") @@ -283,6 +287,7 @@ function isDisabledByEnvMirror(value) { return typeof value === "string" && DISABLED_VALUES_MIRROR.has(value.trim().toLowerCase()) } +/** Run the Pi adapter regression suite. */ async function main() { console.log("\n=== Testing .pi/extensions/index.ts (Pi thin adapter) ===\n") @@ -320,8 +325,8 @@ async function main() { "expected the adapter to invoke hooks via child_process.execFile(...)" ) assert.ok( - extensionSource.includes("process.execPath"), - "expected hooks to be spawned with process.execPath, not a hardcoded 'node' string" + extensionSource.includes("resolveHookRuntime"), + "expected the adapter to select a hook runtime before execFile(...)" ) const shellExecPattern = /(? { + const runtimeSource = fs.readFileSync( + path.join(repoRoot, ".pi", "extensions", "hook-runtime.js"), + "utf8" + ) + const runHookStart = extensionSource.indexOf("function runEccHook") + const runHookEnd = extensionSource.indexOf("function resolveHookCwd") + const runHookSource = extensionSource.slice(runHookStart, runHookEnd) + const beforeRunHookSource = extensionSource.slice(0, runHookStart) + assert.ok( + extensionSource.includes('from "./hook-runtime.js"'), + "expected the adapter to import the shared hook runtime selector" + ) + assert.ok( + !beforeRunHookSource.includes("resolveHookRuntime()") && + /try\s*\{\s*hookRuntime = resolveHookRuntime\(\)\s*\}\s*catch/.test(runHookSource) && + /execFile\(\s*hookRuntime,/.test(runHookSource), + "expected runEccHook to resolve its runtime inside the guarded hook path rather than " + + "during module initialization" + ) + assert.ok( + runtimeSource.includes("process.versions?.bun") && + runtimeSource.includes("path.basename(execPath)") && + runtimeSource.includes("path.isAbsolute(overridePath)") && + runtimeSource.includes( + 'throw new Error("ECC_HOOK_NODE must be an absolute path: " + overridePath)' + ), + "expected the selector to reject Bun/OMP runtimes, require absolute overrides, and " + + "fall back to PATH node" + ) + }], + + ["resolves hook runtimes across Node, compiled OMP, and explicit override cases", () => { + assert.strictEqual( + resolveHookRuntime({ execPath: "/usr/bin/node", override: "" }), + "/usr/bin/node" + ) + assert.strictEqual( + resolveHookRuntime({ execPath: "/usr/bin/nodejs", override: "" }), + "/usr/bin/nodejs" + ) + assert.strictEqual( + resolveHookRuntime({ + execPath: "/usr/bin/node", + bunVersion: "1.4.0", + override: "", + }), + "node" + ) + assert.strictEqual( + resolveHookRuntime({ + execPath: "/usr/bin/node", + releaseName: "bun", + override: "", + }), + "node" + ) + assert.strictEqual( + resolveHookRuntime({ + execPath: "/home/user/.omp/bin/omp", + releaseName: "node", + override: "", + }), + "node" + ) + assert.throws( + () => + resolveHookRuntime({ + execPath: "/usr/bin/node", + override: "./node", + }), + /ECC_HOOK_NODE must be an absolute path: \.\/node/ + ) + assert.strictEqual( + resolveHookRuntime({ + execPath: "/usr/bin/node", + override: " /opt/node/bin/node ", + }), + "/opt/node/bin/node" + ) + assert.strictEqual( + resolveHookRuntime({ + execPath: "/home/user/.omp/bin/omp", + bunVersion: "1.4.0", + override: " /opt/node/bin/node ", + }), + "/opt/node/bin/node" + ) + }], ["registers Pi's documented pi.on(...) lifecycle, not the undocumented app.events bus", () => { assert.ok( diff --git a/tests/pi/pi-hook-runtime.test.js b/tests/pi/pi-hook-runtime.test.js new file mode 100644 index 000000000..6ac4c9fcb --- /dev/null +++ b/tests/pi/pi-hook-runtime.test.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +'use strict'; + +// Run the real adapter against a recording process boundary. A simulated OMP +// executable is never launched, so a regression cannot create a process storm. +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); +const { EventEmitter } = require('events'); +const ts = require('typescript'); + +const extensionDir = path.resolve(__dirname, '../../.pi/extensions'); +const extensionSource = fs.readFileSync(path.join(extensionDir, 'index.ts'), 'utf8'); +const compiled = ts.transpileModule(extensionSource, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } +}).outputText; + +/** Load the adapter and its runtime selector with the same host metadata. */ +function loadAdapter(host, spawnError) { + const launches = []; + const handlers = new Map(); + const warnings = []; + const runtimeModule = { exports: {} }; + const simulatedProcess = { env: {}, release: { name: 'node' }, versions: {}, ...host }; + vm.runInNewContext(fs.readFileSync(path.join(extensionDir, 'hook-runtime.js'), 'utf8'), { + module: runtimeModule, require, process: simulatedProcess + }); + const adapterModule = { exports: {} }; + const recordExecFile = (file, args, options, callback) => { + const call = { file, args, options }; + launches.push(call); + const child = new EventEmitter(); + child.stdin = new EventEmitter(); + child.stdin.end = input => { + call.input = JSON.parse(input); + callback(spawnError || null, ''); + }; + return child; + }; + vm.runInNewContext(compiled, { + module: adapterModule, + exports: adapterModule.exports, + __dirname: extensionDir, + process: simulatedProcess, + require: name => { + if (name === 'node:child_process') return { execFile: recordExecFile }; + if (name === './hook-runtime.js') return runtimeModule.exports; + return require(name); + } + }); + adapterModule.exports.default({ + on: (name, handler) => handlers.set(name, handler), + registerCommand: () => {} + }); + const context = { + cwd: path.resolve(__dirname, '../..'), + sessionManager: { getSessionId: () => 'runtime-regression' }, + ui: { notify: message => warnings.push(message) } + }; + return { launches, warnings, run: () => handlers.get('session_start')({ reason: 'resume' }, context) }; +} + +/** Exercise the actual lifecycle entrypoint without spawning host executables. */ +async function main() { + let passed = 0; + let failed = 0; + const explicitNode = path.resolve('test node runtime', 'node'); + const cases = [ + ['normal Node', { execPath: process.execPath }, process.execPath], + ['compiled OMP reporting Node', { execPath: '/fake/omp' }, 'node'], + ['compiled OMP on Bun', { execPath: '/fake/omp', versions: { bun: '1.4.0' } }, 'node'], + ['Bun with a Node basename', { execPath: '/fake/node', versions: { bun: '1.4.0' } }, 'node'], + ['explicit absolute Node path with spaces', { + execPath: '/fake/omp', env: { ECC_HOOK_NODE: explicitNode } + }, explicitNode] + ]; + for (const [name, host, expected] of cases) { + try { + const adapter = loadAdapter(host); + await adapter.run(); + assert.strictEqual(adapter.launches.length, 1); + const launch = adapter.launches[0]; + assert.strictEqual(launch.file, expected); + assert.strictEqual(launch.args[1], 'session:start'); + assert.strictEqual(launch.input.source, 'resume'); + assert.strictEqual(launch.input.session_id, 'runtime-regression'); + assert.ok(launch.options.timeout > 0 && launch.options.timeout <= 30000); + assert.ok(launch.options.maxBuffer > 0 && launch.options.maxBuffer <= 16 * 1024 * 1024); + assert.ok(!launch.options.shell); + assert.strictEqual(adapter.warnings.length, 0); + console.log(` ✓ ${name} launches exactly one bounded Node hook`); + passed++; + } catch (error) { + console.error(` ✗ ${name}: ${error.message}`); + failed++; + } + } + for (const [name, host, spawnError, expectedLaunches] of [ + ['invalid override', { execPath: '/fake/omp', env: { ECC_HOOK_NODE: './omp' } }, null, 0], + ['missing PATH node', { execPath: '/fake/omp' }, new Error('spawn node ENOENT'), 1] + ]) { + try { + const adapter = loadAdapter(host, spawnError); + await adapter.run(); + assert.strictEqual(adapter.launches.length, expectedLaunches); + assert.strictEqual(adapter.warnings.length, 1); + assert.match(adapter.warnings[0], /hook skipped/); + console.log(` ✓ ${name} warns without retrying the host executable`); + passed++; + } catch (error) { + console.error(` ✗ ${name}: ${error.message}`); + failed++; + } + } + console.log(`\nPassed: ${passed}\nFailed: ${failed}`); + process.exitCode = failed ? 1 : 0; +} + +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/tests/run-all.js b/tests/run-all.js index fd79cb4af..22d0ff5a4 100644 --- a/tests/run-all.js +++ b/tests/run-all.js @@ -43,6 +43,21 @@ function discoverTestFiles() { .sort(); } +function escapeAnnotation(value, property = false) { + const escaped = value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + return property ? escaped.replace(/:/g, '%3A').replace(/,/g, '%2C') : escaped; +} + +function annotateFailure(displayPath, reason, output) { + if (process.env.GITHUB_ACTIONS !== 'true') return; + const context = output.split(/\r?\n/) + .filter(line => /^\s*(?:FAIL\b|not ok\b|[A-Za-z]*Error\b|[\u2717\u274c])/i.test(line)) + .slice(0, 3) + .join('\n'); + const message = [reason, context].filter(Boolean).join(': ').slice(0, 1000); + console.log(`::error file=${escapeAnnotation(`tests/${displayPath}`, true)}::${escapeAnnotation(message)}`); +} + const testFiles = discoverTestFiles(); const BOX_W = 58; // inner width between ║ delimiters @@ -96,22 +111,29 @@ for (const testFile of testFiles) { if (stderr) console.log(stderr); // Parse results from combined output - const combined = stdout + stderr; + const combined = `${stdout}\n${stderr}`; const passedMatch = combined.match(/Passed:\s*(\d+)/); const failedMatch = combined.match(/Failed:\s*(\d+)/); if (passedMatch) totalPassed += parseInt(passedMatch[1], 10); - if (failedMatch) totalFailed += parseInt(failedMatch[1], 10); + const reportedFailures = failedMatch ? parseInt(failedMatch[1], 10) : 0; + const processFailed = Boolean(result.error) || result.status !== 0; + totalFailed += processFailed ? Math.max(reportedFailures, 1) : reportedFailures; + let failureReason; if (result.error) { - console.log(`✗ ${displayPath} failed to start: ${result.error.message}`); - totalFailed += failedMatch ? 0 : 1; - continue; + failureReason = `failed to start: ${result.error.message}`; + } else if (result.status !== 0) { + failureReason = result.signal + ? `terminated by signal ${result.signal}` + : `exited with status ${result.status}`; + } else if (reportedFailures > 0) { + failureReason = `reported ${reportedFailures} failed tests`; } - if (result.status !== 0) { - console.log(`✗ ${displayPath} exited with status ${result.status}`); - totalFailed += failedMatch ? 0 : 1; + if (failureReason) { + console.log(`✗ ${displayPath} ${failureReason}`); + annotateFailure(displayPath, failureReason, combined); } } diff --git a/tests/scripts/ecc.test.js b/tests/scripts/ecc.test.js index 82f58e306..ea6a0fbcd 100644 --- a/tests/scripts/ecc.test.js +++ b/tests/scripts/ecc.test.js @@ -3,10 +3,12 @@ */ const assert = require('assert'); +const crypto = require('crypto'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); +const { createInstallState, writeInstallState } = require('../../scripts/lib/install-state'); const SCRIPT = path.join(__dirname, '..', '..', 'scripts', 'ecc.js'); @@ -14,23 +16,21 @@ function runCli(args, options = {}) { const envOverrides = { ...(options.env || {}), }; - - if (typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides)) { - envOverrides.USERPROFILE = envOverrides.HOME; - } - - if (typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides)) { - envOverrides.HOME = envOverrides.USERPROFILE; - } + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== 'ECC_DRY_RUN') + ); + const homeAlias = typeof envOverrides.HOME === 'string' && !('USERPROFILE' in envOverrides) + ? { USERPROFILE: envOverrides.HOME } + : typeof envOverrides.USERPROFILE === 'string' && !('HOME' in envOverrides) + ? { HOME: envOverrides.USERPROFILE } + : {}; + const env = { ...inheritedEnv, ...envOverrides, ...homeAlias }; return spawnSync('node', [SCRIPT, ...args], { encoding: 'utf8', cwd: options.cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024, - env: { - ...process.env, - ...envOverrides, - }, + env, }); } @@ -153,6 +153,79 @@ function main() { const payload = parseJson(result.stdout); assert.deepStrictEqual(payload.records, []); }], + ['keeps uninstall read-only when global --dry-run precedes the command', () => { + const homeDir = createTempDir('ecc-cli-uninstall-home-'); + const projectRoot = createTempDir('ecc-cli-uninstall-project-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + const statePath = path.join(targetRoot, 'ecc-install-state.json'); + const managedPath = path.join(targetRoot, 'managed-rule.md'); + const managedContent = 'managed\n'; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(managedPath, managedContent); + writeInstallState(statePath, createInstallState({ + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + targetRoot, + installStatePath: statePath, + request: { + profile: null, + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: ['typescript'], + legacyMode: true, + }, + resolution: { + selectedModules: ['legacy-cursor-install'], + skippedModules: [], + }, + source: { + repoVersion: null, + repoCommit: null, + manifestVersion: 1, + }, + operations: [{ + kind: 'copy-file', + moduleId: 'rules-core', + sourceRelativePath: 'rules/common/coding-style.md', + destinationPath: managedPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentSha256: crypto.createHash('sha256').update(managedContent).digest('hex'), + }], + })); + + const jsonResult = runCli(['--dry-run', 'uninstall', '--target', 'cursor', '--json'], { + cwd: projectRoot, + env: { HOME: homeDir }, + }); + + assert.strictEqual(jsonResult.status, 0, jsonResult.stderr); + const preview = parseJson(jsonResult.stdout); + assert.strictEqual(preview.dryRun, true); + assert.strictEqual(preview.results[0].status, 'planned'); + assert.deepStrictEqual( + preview.results[0].plannedRemovals.map(candidate => fs.realpathSync(candidate)).sort(), + [managedPath, statePath].map(candidate => fs.realpathSync(candidate)).sort() + ); + + const humanResult = runCli(['--dry-run', 'uninstall', '--target', 'cursor'], { + cwd: projectRoot, + env: { HOME: homeDir }, + }); + assert.strictEqual(humanResult.status, 0, humanResult.stderr); + assert.match(humanResult.stdout, /Status: WOULD UNINSTALL/); + assert.match(humanResult.stdout, /Would remove: 2/); + assert.doesNotMatch(humanResult.stdout, /Status: UNINSTALLED|Removed paths:/); + assert.ok(fs.existsSync(managedPath), 'global dry-run must preserve managed files'); + assert.ok(fs.existsSync(statePath), 'global dry-run must preserve install-state'); + } finally { + fs.rmSync(homeDir, { force: true, recursive: true }); + fs.rmSync(projectRoot, { force: true, recursive: true }); + } + }], ['delegates auto-update command', () => { const homeDir = createTempDir('ecc-cli-home-'); const projectRoot = createTempDir('ecc-cli-project-'); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 0931d5c0a..270339cb9 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -115,6 +115,31 @@ function runTests() { assert.ok(result.stdout.includes('--modules ')); })) passed++; else failed++; + if (test('Claude hook dry-run validates settings without mutating malformed input', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + const claudeRoot = path.join(homeDir, '.claude'); + const settingsPath = path.join(claudeRoot, 'settings.json'); + + try { + fs.mkdirSync(claudeRoot, { recursive: true }); + fs.writeFileSync(settingsPath, '{ malformed\n'); + + const result = run( + ['--profile', 'core', '--enable-hooks', '--dry-run', '--json'], + { cwd: projectDir, homeDir } + ); + + assert.notStrictEqual(result.code, 0); + assert.match(result.stderr, /Failed to parse Claude settings/); + assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ malformed\n'); + assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('guided dispatcher reports sanitized load and rejection failures', () => { for (const failureMode of ['load', 'reject']) { const result = runWithGuidedDispatcherFailure(failureMode); @@ -487,6 +512,19 @@ function runTests() { const parsed = JSON.parse(result.stdout); assert.strictEqual(parsed.dryRun, true); assert.ok(parsed.plan.selectedModuleIds.includes('workflow-quality')); + const settingsOperations = parsed.plan.operations.filter(operation => ( + operation.kind === 'update-claude-settings' + )); + assert.strictEqual(settingsOperations.length, 1); + assert.strictEqual( + settingsOperations[0].destinationPath, + path.join(homeDir, '.claude', 'settings.json') + ); + assert.ok(settingsOperations[0].managedHooks.SessionStart); + assert.ok(!parsed.plan.operations.some(operation => ( + operation.kind === 'copy-file' + && String(operation.sourceRelativePath || '').replace(/\\/g, '/') === 'hooks/hooks.json' + ))); assert.ok( parsed.plan.operations.some(operation => ( String(operation.sourceRelativePath || '').replace(/\\/g, '/').startsWith('skills/delivery-gate/') @@ -532,7 +570,8 @@ function runTests() { assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'ecc', 'common', 'coding-style.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md'))); - assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json'))); + assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json'))); + assert.ok(readJson(path.join(claudeRoot, 'settings.json')).hooks.SessionStart); assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'session-manager.js'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json'))); @@ -747,7 +786,7 @@ function runTests() { assert.ok(result.stderr.includes('Unknown install module: ghost-module')); })) passed++; else failed++; - if (test('installs claude hooks and defaults commit attribution off', () => { + if (test('registers Claude hooks in settings and defaults commit attribution off', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -756,58 +795,87 @@ function runTests() { assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); - assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied'); - assert.deepStrictEqual( - readJson(path.join(claudeRoot, 'settings.json')), - { includeCoAuthoredBy: false } + assert.strictEqual( + fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), + false, + 'hooks.json should not be copied for Claude targets' ); + const settings = readJson(path.join(claudeRoot, 'settings.json')); + assert.strictEqual(settings.includeCoAuthoredBy, false); + assert.ok(settings.hooks.SessionStart.some(entry => entry.id === 'session:start')); + + const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + const settingsOperation = state.operations.find(operation => ( + operation.kind === 'update-claude-settings' + )); + assert.ok(settingsOperation, 'state should record the settings update operation'); + assert.deepStrictEqual(settingsOperation.managedHooks, settings.hooks); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('installs claude hooks with the safe plugin bootstrap contract', () => { - const homeDir = createTempDir('install-apply-home-'); - const projectDir = createTempDir('install-apply-project-'); + if (test('resolves Claude home and project hook commands to their installed roots', () => { + for (const target of ['claude', 'claude-project']) { + const homeDir = createTempDir(`install-apply-${target}-home-`); + const projectDir = createTempDir(`install-apply-${target}-project-`); - try { - const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); - assert.strictEqual(result.code, 0, result.stderr); + try { + const result = run( + ['--target', target, '--profile', 'core', '--enable-hooks'], + { cwd: projectDir, homeDir } + ); + assert.strictEqual(result.code, 0, result.stderr); - const claudeRoot = path.join(homeDir, '.claude'); - const installedHooks = readJson(path.join(claudeRoot, 'hooks', 'hooks.json')); + const claudeRoot = target === 'claude' + ? path.join(homeDir, '.claude') + : path.join(projectDir, '.claude'); + const settings = readJson(path.join(claudeRoot, 'settings.json')); + const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); + const installedRoot = state.target.root; + assert.strictEqual(fs.realpathSync(installedRoot), fs.realpathSync(claudeRoot)); + const installedBashDispatcherEntry = settings.hooks.PreToolUse.find( + entry => entry.id === 'pre:bash:dispatcher' + ); + assert.ok(installedBashDispatcherEntry); + const command = installedBashDispatcherEntry.hooks[0].command; + assert.ok(command.startsWith('node -e ')); + assert.ok(command.includes('plugin-hook-bootstrap.js')); + assert.ok(command.includes('pre-bash-dispatcher.js')); + assert.ok( + command.includes(Buffer.from(installedRoot, 'utf8').toString('base64')), + `${target} command should encode its absolute root without shell interpolation` + ); + assert.ok(!command.includes(claudeRoot)); + assert.ok(!command.includes('var e=process.env.CLAUDE_PLUGIN_ROOT;')); + assert.ok(!command.includes('${CLAUDE_PLUGIN_ROOT}')); - const installedBashDispatcherEntry = installedHooks.hooks.PreToolUse.find(entry => entry.id === 'pre:bash:dispatcher'); - assert.ok(installedBashDispatcherEntry, 'hooks/hooks.json should include the consolidated Bash dispatcher hook'); - assert.strictEqual(typeof installedBashDispatcherEntry.hooks[0].command, 'string', 'hooks/hooks.json should install string-form commands for Claude Code schema compatibility'); - assert.ok( - installedBashDispatcherEntry.hooks[0].command.startsWith('node -e '), - 'hooks/hooks.json should use the inline node bootstrap contract' - ); - assert.ok( - installedBashDispatcherEntry.hooks[0].command.includes('plugin-hook-bootstrap.js'), - 'hooks/hooks.json should route plugin-managed hooks through the shared bootstrap' - ); - assert.ok( - installedBashDispatcherEntry.hooks[0].command.includes('CLAUDE_PLUGIN_ROOT'), - 'hooks/hooks.json should still consult CLAUDE_PLUGIN_ROOT for runtime resolution' - ); - assert.ok( - installedBashDispatcherEntry.hooks[0].command.includes('pre-bash-dispatcher.js'), - 'hooks/hooks.json should point the Bash preflight contract at the consolidated dispatcher' - ); - assert.ok( - !installedBashDispatcherEntry.hooks[0].command.includes('\\"'), - 'hooks/hooks.json should avoid escaped double quotes that break Windows Git Bash parsing' - ); - assert.ok( - !installedBashDispatcherEntry.hooks[0].command.includes('${CLAUDE_PLUGIN_ROOT}'), - 'hooks/hooks.json should not retain raw CLAUDE_PLUGIN_ROOT shell placeholders after install' - ); - } finally { - cleanup(homeDir); - cleanup(projectDir); + const smokeEntry = settings.hooks.PreToolUse.find( + entry => entry.id === 'pre:write:doc-file-warning' + ); + const smokeResult = spawnSync(smokeEntry.hooks[0].command, { + input: JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Write', + tool_input: { file_path: 'README.md' }, + }), + encoding: 'utf8', + cwd: projectDir, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + ECC_DISABLED_HOOKS: 'pre:write:doc-file-warning', + }, + shell: true, + timeout: DEFAULT_INSTALL_APPLY_TIMEOUT_MS, + }); + assert.strictEqual(smokeResult.status, 0, smokeResult.stderr); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } } })) passed++; else failed++; @@ -840,12 +908,16 @@ function runTests() { assert.deepStrictEqual( settings.hooks.UserPromptSubmit, [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }], - 'existing hooks should be left untouched' + 'unrelated existing hooks should be preserved' ); assert.deepStrictEqual( - settings.hooks.PreToolUse, - [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }], - 'managed Claude hooks should not be injected into settings.json' + settings.hooks.PreToolUse[0], + { matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }, + 'existing event entries should retain their order and content' + ); + assert.ok( + settings.hooks.PreToolUse.some(entry => entry.id === 'pre:bash:dispatcher'), + 'managed Claude hooks should be registered alongside user hooks' ); } finally { cleanup(homeDir); @@ -927,7 +999,7 @@ function runTests() { } })) passed++; else failed++; - if (test('reinstall keeps commit attribution disabled when only managed hooks are installed', () => { + if (test('reinstall is idempotent for managed hooks and keeps commit attribution disabled', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -938,17 +1010,17 @@ function runTests() { const secondInstall = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); - assert.deepStrictEqual( - readJson(path.join(homeDir, '.claude', 'settings.json')), - { includeCoAuthoredBy: false } - ); + const settings = readJson(path.join(homeDir, '.claude', 'settings.json')); + assert.strictEqual(settings.includeCoAuthoredBy, false); + const ids = Object.values(settings.hooks).flat().map(entry => entry.id); + assert.strictEqual(ids.length, new Set(ids).size, 'managed hook IDs should not duplicate'); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('reinstall leaves pre-existing hook-based settings.json untouched apart from co-author preference', () => { + if (test('reinstall preserves pre-existing hook entries while registering managed hooks', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -967,10 +1039,9 @@ function runTests() { assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); const afterSecondInstall = readJson(settingsPath); - assert.deepStrictEqual(afterSecondInstall, { - ...legacySettings, - includeCoAuthoredBy: false, - }); + assert.strictEqual(afterSecondInstall.includeCoAuthoredBy, false); + assert.deepStrictEqual(afterSecondInstall.hooks.PreToolUse[0], legacySettings.hooks.PreToolUse[0]); + assert.ok(afterSecondInstall.hooks.PreToolUse.some(entry => entry.id === 'pre:bash:dispatcher')); } finally { cleanup(homeDir); cleanup(projectDir); @@ -995,7 +1066,9 @@ function runTests() { assert.strictEqual(install.code, 0, install.stderr); const afterInstall = readJson(settingsPath); - assert.deepStrictEqual(afterInstall, customSettings); + assert.strictEqual(afterInstall.includeCoAuthoredBy, true); + assert.strictEqual(afterInstall.theme, 'dark'); + assert.ok(afterInstall.hooks.SessionStart.some(entry => entry.id === 'session:start')); } finally { cleanup(homeDir); cleanup(projectDir); @@ -1022,14 +1095,17 @@ function runTests() { assert.strictEqual(install.code, 0, install.stderr); const afterInstall = readJson(settingsPath); - assert.deepStrictEqual(afterInstall, customSettings); + assert.deepStrictEqual(afterInstall.attribution, customSettings.attribution); + assert.strictEqual(afterInstall.theme, 'dark'); + assert.ok(!Object.hasOwn(afterInstall, 'includeCoAuthoredBy')); + assert.ok(afterInstall.hooks.SessionStart.some(entry => entry.id === 'session:start')); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('ignores malformed existing settings.json during claude install', () => { + if (test('malformed Claude settings aborts before any install mutation', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -1040,17 +1116,17 @@ function runTests() { fs.writeFileSync(settingsPath, '{ invalid json\n'); const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); - assert.strictEqual(result.code, 0, result.stderr); + assert.notStrictEqual(result.code, 0); + assert.match(result.stderr, /Failed to parse Claude settings/); assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n'); - assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied'); - assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written'); + assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('ignores non-object existing settings.json during claude install', () => { + if (test('non-object Claude settings aborts before any install mutation', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-'); @@ -1061,76 +1137,44 @@ function runTests() { fs.writeFileSync(settingsPath, '[]\n'); const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); - assert.strictEqual(result.code, 0, result.stderr); + assert.notStrictEqual(result.code, 0); + assert.match(result.stderr, /expected a JSON object/); assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n'); - assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied'); - assert.ok(fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should still be written'); + assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']); } finally { cleanup(homeDir); cleanup(projectDir); } })) passed++; else failed++; - if (test('fails when source hooks.json root is not an object before copying files', () => { - const tempDir = createTempDir('install-apply-invalid-hooks-'); - const targetRoot = path.join(tempDir, '.claude'); - const installStatePath = path.join(targetRoot, 'ecc', 'install-state.json'); - const sourceHooksPath = path.join(tempDir, 'hooks.json'); + if (test('same-id Claude hook conflict aborts before any install mutation', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); try { - fs.writeFileSync(sourceHooksPath, '[]\n'); - - assert.throws(() => { - applyInstallPlan({ - targetRoot, - installStatePath, - hookConsent: 'enabled', - statePreview: { - schemaVersion: 'ecc.install.v1', - installedAt: new Date().toISOString(), - target: { - id: 'claude-home', - kind: 'home', - root: targetRoot, - installStatePath, - }, - request: { - profile: 'core', - modules: [], - includeComponents: [], - excludeComponents: [], - legacyLanguages: [], - legacyMode: false, - }, - resolution: { - selectedModules: ['hooks-runtime'], - skippedModules: [], - }, - source: { - repoVersion: null, - repoCommit: null, - manifestVersion: 1, - }, - operations: [], - }, - adapter: { target: 'claude' }, - operations: [{ - kind: 'copy-file', - moduleId: 'hooks-runtime', - sourcePath: sourceHooksPath, - sourceRelativePath: 'hooks/hooks.json', - destinationPath: path.join(targetRoot, 'hooks', 'hooks.json'), - strategy: 'preserve-relative-path', - ownership: 'managed', - scaffoldOnly: false, + const claudeRoot = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeRoot, { recursive: true }); + const settingsPath = path.join(claudeRoot, 'settings.json'); + const existing = { + theme: 'dark', + hooks: { + PreToolUse: [{ + id: 'pre:bash:dispatcher', + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo user-owned' }], }], - }); - }, /Invalid hooks config at .*expected a JSON object/); + }, + }; + fs.writeFileSync(settingsPath, `${JSON.stringify(existing, null, 2)}\n`); - assert.ok(!fs.existsSync(path.join(targetRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid'); - assert.ok(!fs.existsSync(installStatePath), 'install state should not be written when source hooks are invalid'); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.notStrictEqual(result.code, 0); + assert.match(result.stderr, /Refusing to overwrite.*pre:bash:dispatcher/); + assert.deepStrictEqual(readJson(settingsPath), existing); + assert.deepStrictEqual(fs.readdirSync(claudeRoot), ['settings.json']); } finally { - cleanup(tempDir); + cleanup(homeDir); + cleanup(projectDir); } })) passed++; else failed++; @@ -1254,6 +1298,39 @@ function runTests() { assert.strictEqual(state.request.hookConsent, 'declined'); assert.ok(!state.resolution.selectedModules.includes('hooks-runtime')); assert.ok(state.resolution.selectedModules.includes('rules-core')); + assert.ok(!state.operations.some(operation => ( + operation.kind === 'update-claude-settings' + ))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('--no-hooks removes hooks registered by a previous enabled install', () => { + const projectDir = createTempDir('install-apply-disable-hooks-'); + const homeDir = createTempDir('install-apply-disable-hooks-home-'); + try { + const enabled = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(enabled.code, 0, enabled.stderr); + + const settingsPath = path.join(homeDir, '.claude', 'settings.json'); + const settings = readJson(settingsPath); + settings.theme = 'dark'; + fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`); + + const disabled = run(['--profile', 'core', '--no-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(disabled.code, 0, disabled.stderr); + assert.deepStrictEqual(readJson(settingsPath), { + includeCoAuthoredBy: false, + theme: 'dark', + }); + + const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json')); + assert.strictEqual(state.request.hookConsent, 'declined'); + assert.ok(!state.operations.some(operation => ( + operation.kind === 'update-claude-settings' + ))); } finally { cleanup(homeDir); cleanup(projectDir); diff --git a/tests/scripts/manual-hook-install-docs.test.js b/tests/scripts/manual-hook-install-docs.test.js index 8dc531efd..7851fdcc2 100644 --- a/tests/scripts/manual-hook-install-docs.test.js +++ b/tests/scripts/manual-hook-install-docs.test.js @@ -8,6 +8,12 @@ const path = require('path'); const README = path.join(__dirname, '..', '..', 'README.md'); const HOOKS_README = path.join(__dirname, '..', '..', 'hooks', 'README.md'); +const HOOK_REGISTRATION_PHRASE = + 'registers the resolved hook entries in `~/.claude/settings.json`'; + +function normalizeWhitespace(text) { + return text.replace(/\s+/g, ' '); +} function test(name, fn) { try { @@ -44,9 +50,13 @@ function runTests() { 'README should document the supported PowerShell hook install path' ); assert.ok( - readme.includes('%USERPROFILE%\\\\.claude'), + readme.includes('%USERPROFILE%\\.claude'), 'README should call out the correct Windows Claude config root' ); + assert.ok( + normalizeWhitespace(readme).includes(HOOK_REGISTRATION_PHRASE), + 'README should explain that manual installs register hooks in Claude settings' + ); })) passed++; else failed++; if (test('hooks/README mirrors supported manual install guidance', () => { @@ -62,6 +72,10 @@ function runTests() { hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime --enable-hooks'), 'hooks/README should document the supported PowerShell hook install path' ); + assert.ok( + normalizeWhitespace(hooksReadme).includes(HOOK_REGISTRATION_PHRASE), + 'hooks/README should explain that manual installs register hooks in Claude settings' + ); })) passed++; else failed++; console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); diff --git a/tests/scripts/ownership-guard.test.js b/tests/scripts/ownership-guard.test.js new file mode 100644 index 000000000..4c16afd2e --- /dev/null +++ b/tests/scripts/ownership-guard.test.js @@ -0,0 +1,184 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { createManifestInstallPlan } = require('../../scripts/lib/install/plan'); +const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply'); +const { listInstallTargetAdapters } = require('../../scripts/lib/install-targets/registry'); +const { uninstallInstalledStates } = require('../../scripts/lib/install-lifecycle'); + +let passed = 0; +let failed = 0; +function test(name, fn) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-ownership-')); + try { + const projectRoot = path.join(root, 'project'); + const homeDir = path.join(root, 'home'); + fs.mkdirSync(projectRoot); + fs.mkdirSync(homeDir); + fn({ projectRoot, homeDir, env: {} }); + passed++; + console.log(` PASS ${name}`); + } catch (error) { + failed++; + console.error(` FAIL ${name}: ${error.stack}`); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function readState(plan) { + return JSON.parse(fs.readFileSync(plan.installStatePath, 'utf8')); +} + +for (const adapter of listInstallTargetAdapters()) { + test(`${adapter.target}: preserve user files through preview, install, reinstall and uninstall`, context => { + const nativeTarget = ['codex', 'gemini', 'opencode'].includes(adapter.target); + const resolved = createManifestInstallPlan({ + ...context, target: adapter.target, + moduleIds: [nativeTarget ? 'platform-configs' : 'rules-core'], + // This test exercises ownership of source files, not plugin compilation. + exemptValidationCodes: ['opencode-plugin-not-built'], + }); + const operation = resolved.operations.find(item => item.kind === 'copy-file'); + assert.ok(operation, 'target must produce a real copy operation'); + const plan = { + ...resolved, operations: [operation], + statePreview: { ...resolved.statePreview, operations: [operation] }, + }; + const destination = operation.destinationPath; + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, 'User-authored content\n'); + const preview = previewInstallPlan(plan); + assert.ok(preview.skippedOperations.some(item => item.destinationPath === destination)); + assert.ok(!preview.statePreview.operations.some(item => item.destinationPath === destination)); + assert.ok(!fs.existsSync(plan.installStatePath), 'preview must not create state'); + for (let attempt = 0; attempt < 2; attempt++) { + const installed = applyInstallPlan(plan); + assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'User-authored content\n'); + assert.ok(installed.warnings.some(warning => warning.includes('Skipped user-owned file'))); + assert.ok(!readState(plan).operations.some(item => item.destinationPath === destination)); + } + const result = uninstallInstalledStates({ ...context, targets: [adapter.target] }); + assert.strictEqual(result.summary.errorCount, 0); + assert.strictEqual(fs.readFileSync(destination, 'utf8'), 'User-authored content\n'); + assert.ok(!fs.existsSync(plan.installStatePath)); + }); +} + +test('Antigravity transforms preserve a conflicting agent and still update managed files', context => { + const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['agents-core'] }); + const userOperation = plan.operations.find(item => ( + item.sourceRelativePath.replace(/\\/g, '/') === 'agents/architect.md' + )); + assert.ok(userOperation, 'agent plan must include the architect source on every platform'); + fs.mkdirSync(path.dirname(userOperation.destinationPath), { recursive: true }); + fs.writeFileSync(userOperation.destinationPath, 'My architect\n'); + applyInstallPlan(plan); + const managed = plan.operations.find(item => item.destinationPath !== userOperation.destinationPath); + assert.ok(managed, 'agent plan must also include a separately managed file'); + const original = fs.readFileSync(managed.destinationPath, 'utf8'); + fs.writeFileSync(managed.destinationPath, 'old managed version\n'); + applyInstallPlan(plan); + assert.strictEqual(fs.readFileSync(managed.destinationPath, 'utf8'), original); + assert.strictEqual(fs.readFileSync(userOperation.destinationPath, 'utf8'), 'My architect\n'); + assert.ok(!readState(plan).operations.some(item => item.destinationPath === userOperation.destinationPath)); +}); + +for (const field of ['id', 'root', 'installStatePath']) { + test(`rejects previous state with mismatched target ${field}`, context => { + const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] }); + applyInstallPlan(plan); + const operation = plan.operations[0]; + const state = readState(plan); + const mismatched = { ...state, target: { ...state.target, [field]: `${state.target[field]}-other` } }; + fs.writeFileSync(plan.installStatePath, JSON.stringify(mismatched)); + fs.writeFileSync(operation.destinationPath, 'User file\n'); + assert.throws(() => applyInstallPlan(plan), /install-state target does not match/); + assert.strictEqual(fs.readFileSync(operation.destinationPath, 'utf8'), 'User file\n'); + assert.deepStrictEqual(readState(plan), mismatched); + }); +} + +test('preserves multiple user files created at the write boundary without checkpoint ownership', context => { + const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] }); + const collisions = plan.operations.slice(0, 2).map(operation => operation.destinationPath); + assert.throws(() => applyInstallPlan(plan, { + beforeOperationWrite({ operation }) { + if (operation.destinationPath === collisions[0]) { + for (const collision of collisions) { + fs.mkdirSync(path.dirname(collision), { recursive: true }); + fs.writeFileSync(collision, 'Concurrent user file\n'); + } + } + }, + }), /user-owned file appeared/); + for (const collision of collisions) { + assert.strictEqual(fs.readFileSync(collision, 'utf8'), 'Concurrent user file\n'); + assert.ok(!readState(plan).operations.some(item => item.destinationPath === collision)); + } + uninstallInstalledStates({ ...context, targets: ['antigravity'] }); + for (const collision of collisions) { + assert.strictEqual(fs.readFileSync(collision, 'utf8'), 'Concurrent user file\n'); + } +}); + +test('failed reinstall preserves prior hashes for modified managed files it never wrote', context => { + const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] }); + applyInstallPlan(plan); + const modified = plan.operations[1].destinationPath; + const prior = readState(plan).operations.find(operation => operation.destinationPath === modified); + fs.writeFileSync(modified, 'My modified managed file\n'); + assert.throws(() => applyInstallPlan(plan, { + beforeOperationWrite() { throw new Error('injected early failure'); }, + }), /injected early failure/); + assert.strictEqual(readState(plan).operations.find(operation => operation.destinationPath === modified).contentSha256, + prior.contentSha256, 'unattempted managed files must keep their prior digest'); + uninstallInstalledStates({ ...context, targets: ['antigravity'] }); + assert.strictEqual(fs.readFileSync(modified, 'utf8'), 'My modified managed file\n'); +}); + +test('partial install checkpoints never claim skipped user files', context => { + const plan = createManifestInstallPlan({ ...context, target: 'antigravity', moduleIds: ['rules-core'] }); + const userOperation = plan.operations[0]; + fs.mkdirSync(path.dirname(userOperation.destinationPath), { recursive: true }); + fs.writeFileSync(userOperation.destinationPath, 'Keep this file\n'); + assert.throws(() => applyInstallPlan(plan, { + beforeOperationWrite() { throw new Error('injected write failure'); }, + }), /injected write failure/); + assert.ok(!readState(plan).operations.some(item => item.destinationPath === userOperation.destinationPath)); + uninstallInstalledStates({ ...context, targets: ['antigravity'] }); + assert.strictEqual(fs.readFileSync(userOperation.destinationPath, 'utf8'), 'Keep this file\n'); +}); + +test('global CLI dry-run preserves installed files, state and canonical database', context => { + const plan = createManifestInstallPlan({ ...context, target: 'cursor', moduleIds: ['rules-core'] }); + applyInstallPlan(plan); + const stateBefore = fs.readFileSync(plan.installStatePath); + const operation = plan.operations[0]; + const fileBefore = fs.readFileSync(operation.destinationPath); + const env = { + ...process.env, HOME: context.homeDir, USERPROFILE: context.homeDir, + CODEX_HOME: path.join(context.homeDir, '.codex'), + XDG_CONFIG_HOME: path.join(context.homeDir, '.config'), + ECC_DRY_RUN: '0', + }; + const cli = path.join(__dirname, '../../scripts/ecc.js'); + for (const args of [['--dry-run', 'uninstall'], ['uninstall', '--dry-run']]) { + const stdout = execFileSync(process.execPath, [cli, ...args, '--target', 'cursor'], { + cwd: context.projectRoot, env, encoding: 'utf8', timeout: 30000, + }); + assert.match(stdout, /WOULD UNINSTALL/); + assert.match(stdout, /Would remove:/); + assert.doesNotMatch(stdout, /Status: UNINSTALLED|Removed paths:/); + assert.deepStrictEqual(fs.readFileSync(plan.installStatePath), stateBefore); + assert.deepStrictEqual(fs.readFileSync(operation.destinationPath), fileBefore); + assert.deepStrictEqual(fs.readdirSync(context.homeDir), [], 'dry-run must not initialize canonical state'); + } +}); + +console.log(`Results: Passed: ${passed}, Failed: ${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js new file mode 100644 index 000000000..6f7d5ce26 --- /dev/null +++ b/tests/scripts/plugin-install-without-node-modules.test.js @@ -0,0 +1,130 @@ +/** + * Regression test for https://github.com/affaan-m/ECC/issues/2822 + * + * When ECC is installed through the Claude Code plugin marketplace, the + * marketplace directory is a plain git clone: `npm install` never runs, so + * node_modules never exists. This copies just the runtime files (scripts/, + * schemas/, manifests/) into a temp directory with no node_modules anywhere + * in its ancestor chain, which reproduces that install exactly, and asserts + * that the user-facing entry points named in the issue still work. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function copyRuntimeFiles(destDir) { + for (const entry of ['scripts', 'schemas', 'manifests', 'package.json']) { + fs.cpSync(path.join(REPO_ROOT, entry), path.join(destDir, entry), { recursive: true }); + } +} + +// Node's module resolution walks up the directory tree looking for +// node_modules, so if any ancestor of pluginDir happened to have one, a +// require('ajv') from inside pluginDir could resolve there instead of +// hitting the MODULE_NOT_FOUND path this test exists to exercise. Confirm +// the fixture is actually isolated before trusting any of the results below. +function assertNoNodeModulesInAncestry(dir) { + let current = dir; + while (true) { + if (fs.existsSync(path.join(current, 'node_modules'))) { + throw new Error( + `Fixture is not isolated: ${path.join(current, 'node_modules')} exists, so this test ` + + 'would resolve dependencies from there instead of exercising the missing-dependency path.' + ); + } + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } +} + +function run(scriptRelativePath, args, cwd) { + try { + const stdout = execFileSync('node', [path.join(cwd, scriptRelativePath), ...args], { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 10000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (error) { + return { + code: error.status ?? 1, + stdout: error.stdout || '', + stderr: error.stderr || '', + }; + } +} + +function runTests() { + console.log('\n=== Testing plugin install without node_modules (issue #2822) ===\n'); + + let passed = 0; + let failed = 0; + + // No node_modules exists anywhere above os.tmpdir(), so this faithfully + // reproduces a plugin-marketplace git clone with no dependencies installed. + const pluginDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-plugin-install-')); + + try { + if (test('fixture has no node_modules anywhere in its ancestor chain', () => { + assertNoNodeModulesInAncestry(pluginDir); + })) passed++; else failed++; + + copyRuntimeFiles(pluginDir); + + if (test('install-plan.js --list-profiles runs without ajv installed', () => { + const result = run('scripts/install-plan.js', ['--list-profiles'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Cannot find module'), `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Install profiles')); + })) passed++; else failed++; + + if (test('install-plan.js --list-modules runs without ajv installed', () => { + const result = run('scripts/install-plan.js', ['--list-modules'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Install modules')); + })) passed++; else failed++; + + if (test('control-pane.js --help runs without sql.js installed', () => { + const result = run('scripts/control-pane.js', ['--help'], pluginDir); + assert.strictEqual(result.code, 0, `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Cannot find module'), `stderr: ${result.stderr}`); + assert.ok(result.stdout.includes('Usage:')); + })) passed++; else failed++; + + if (test('install-plan.js --config gives an actionable error when ajv is genuinely missing', () => { + const configPath = path.join(pluginDir, 'ecc-install.json'); + fs.writeFileSync(configPath, JSON.stringify({ version: 1, profile: 'minimal' })); + + const result = run('scripts/install-plan.js', ['--config', configPath], pluginDir); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes("Missing dependency 'ajv'"), `stderr: ${result.stderr}`); + assert.ok(result.stderr.includes('npm install'), `stderr: ${result.stderr}`); + assert.ok(!result.stderr.includes('Require stack'), `stderr should not leak a raw stack trace: ${result.stderr}`); + })) passed++; else failed++; + } finally { + fs.rmSync(pluginDir, { recursive: true, force: true }); + } + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/scripts/uninstall.test.js b/tests/scripts/uninstall.test.js index 7369b2d5b..75759969d 100644 --- a/tests/scripts/uninstall.test.js +++ b/tests/scripts/uninstall.test.js @@ -47,9 +47,13 @@ function writeState(filePath, options) { } function run(args = [], options = {}) { - const env = options.homeDir - ? { ...process.env, HOME: options.homeDir, CODEX_HOME: path.join(options.homeDir, '.codex') } - : Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== 'CODEX_HOME')) + const inheritedEnv = Object.fromEntries( + Object.entries(process.env).filter(([key]) => key !== 'ECC_DRY_RUN' && key !== 'CODEX_HOME') + ); + const homeEnv = options.homeDir + ? { HOME: options.homeDir, USERPROFILE: options.homeDir, CODEX_HOME: path.join(options.homeDir, '.codex') } + : {}; + const env = { ...inheritedEnv, ...(options.env || {}), ...homeEnv }; try { const stdout = execFileSync('node', [SCRIPT, ...args], { @@ -291,6 +295,138 @@ function runTests() { } })) passed++; else failed++; + // #2952: the global `ecc --dry-run uninstall` prefix sets ECC_DRY_RUN=1. + // The uninstaller must honor it exactly like the subcommand-level flag. + if (test('honors global ECC_DRY_RUN=1 without mutating managed files (#2952)', () => { + const homeDir = createTempDir('uninstall-home-'); + const projectRoot = createTempDir('uninstall-project-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + fs.mkdirSync(targetRoot, { recursive: true }); + const normalizedTargetRoot = fs.realpathSync(targetRoot); + const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json'); + const renderedPath = path.join(normalizedTargetRoot, 'generated.md'); + fs.writeFileSync(renderedPath, '# generated\n'); + + writeState(statePath, { + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + targetRoot: normalizedTargetRoot, + installStatePath: statePath, + request: { + profile: null, + modules: ['platform-configs'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['platform-configs'], + skippedModules: [], + }, + operations: [ + { + kind: 'render-template', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/generated.md.template', + destinationPath: renderedPath, + strategy: 'render-template', + ownership: 'managed', + scaffoldOnly: false, + renderedContent: '# generated\n', + }, + ], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + // No --dry-run flag: the global flag form must still be a no-op. + const uninstallResult = run(['--target', 'cursor', '--json'], { + cwd: projectRoot, + homeDir, + env: { ECC_DRY_RUN: '1' }, + }); + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + + const parsed = JSON.parse(uninstallResult.stdout); + assert.strictEqual(parsed.dryRun, true, 'ECC_DRY_RUN=1 must enable dry-run mode'); + assert.ok(parsed.results[0].plannedRemovals.includes(renderedPath)); + assert.ok(fs.existsSync(renderedPath), 'managed file must survive the dry run'); + assert.ok(fs.existsSync(statePath), 'install-state must survive the dry run'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('phrases dry-run human output as an unmistakable preview (#2952)', () => { + const homeDir = createTempDir('uninstall-home-'); + const projectRoot = createTempDir('uninstall-project-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + fs.mkdirSync(targetRoot, { recursive: true }); + const normalizedTargetRoot = fs.realpathSync(targetRoot); + const statePath = path.join(normalizedTargetRoot, 'ecc-install-state.json'); + const renderedPath = path.join(normalizedTargetRoot, 'generated.md'); + fs.writeFileSync(renderedPath, '# generated\n'); + + writeState(statePath, { + adapter: { id: 'cursor-project', target: 'cursor', kind: 'project' }, + targetRoot: normalizedTargetRoot, + installStatePath: statePath, + request: { + profile: null, + modules: ['platform-configs'], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['platform-configs'], + skippedModules: [], + }, + operations: [ + { + kind: 'render-template', + moduleId: 'platform-configs', + sourceRelativePath: '.cursor/generated.md.template', + destinationPath: renderedPath, + strategy: 'render-template', + ownership: 'managed', + scaffoldOnly: false, + renderedContent: '# generated\n', + }, + ], + source: { + repoVersion: CURRENT_PACKAGE_VERSION, + repoCommit: 'abc123', + manifestVersion: CURRENT_MANIFEST_VERSION, + }, + }); + + const uninstallResult = run(['--target', 'cursor', '--dry-run'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + + assert.ok(uninstallResult.stdout.includes('dry run'), 'summary header must carry the dry-run marker'); + assert.ok(uninstallResult.stdout.includes('WOULD UNINSTALL (dry run)'), 'status must use conditional wording'); + assert.ok(uninstallResult.stdout.includes('Would remove:'), 'path count must use conditional wording'); + assert.ok(!uninstallResult.stdout.includes('Status: UNINSTALLED'), 'dry run must not claim UNINSTALLED'); + assert.ok(!uninstallResult.stdout.includes('Removed paths:'), 'dry run must not claim removal'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('reports preserved legacy Antigravity files as an incomplete uninstall', () => { const homeDir = createTempDir('uninstall-home-'); const projectRoot = createTempDir('uninstall-project-'); @@ -415,6 +551,74 @@ function runTests() { } })) passed++; else failed++; + if (test('global dry-run environment previews legacy Codex cleanup without removing artifacts', () => { + const homeDir = createTempDir('uninstall-legacy-codex-dry-run-home-'); + const projectRoot = createTempDir('uninstall-legacy-codex-dry-run-project-'); + + try { + const codexHome = path.join(homeDir, '.codex'); + const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md'); + fs.mkdirSync(path.dirname(promptPath), { recursive: true }); + + const statePath = beginLegacySyncState({ + codexHome, + backupDir: path.join(codexHome, 'backups', 'ecc-test'), + }); + recordLegacySyncPath({ statePath, filePath: promptPath }); + fs.writeFileSync(promptPath, '# ECC generated prompt\n'); + finalizeLegacySyncState({ statePath }); + + const uninstallResult = run(['--legacy-codex-sync'], { + cwd: projectRoot, + homeDir, + env: { ECC_DRY_RUN: '1' }, + }); + + assert.strictEqual(uninstallResult.code, 0, uninstallResult.stderr); + assert.match(uninstallResult.stdout, /Status: PLANNED/); + assert.match(uninstallResult.stdout, /Planned changes:/); + assert.doesNotMatch(uninstallResult.stdout, /Status: UNINSTALLED|Removed paths:/); + assert.ok(fs.existsSync(promptPath), 'global dry-run must preserve legacy artifacts'); + assert.ok(fs.existsSync(statePath), 'global dry-run must preserve legacy state'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('rejects an invalid global dry-run value before legacy cleanup', () => { + const homeDir = createTempDir('uninstall-legacy-codex-invalid-dry-run-home-'); + const projectRoot = createTempDir('uninstall-legacy-codex-invalid-dry-run-project-'); + + try { + const codexHome = path.join(homeDir, '.codex'); + const promptPath = path.join(codexHome, 'prompts', 'ecc-plan.md'); + fs.mkdirSync(path.dirname(promptPath), { recursive: true }); + + const statePath = beginLegacySyncState({ + codexHome, + backupDir: path.join(codexHome, 'backups', 'ecc-test'), + }); + recordLegacySyncPath({ statePath, filePath: promptPath }); + fs.writeFileSync(promptPath, '# ECC generated prompt\n'); + finalizeLegacySyncState({ statePath }); + + const uninstallResult = run(['--legacy-codex-sync'], { + cwd: projectRoot, + homeDir, + env: { ECC_DRY_RUN: 'true' }, + }); + + assert.strictEqual(uninstallResult.code, 1); + assert.match(uninstallResult.stderr, /ECC_DRY_RUN must be "1" or "0" when set/); + assert.ok(fs.existsSync(promptPath), 'invalid dry-run input must preserve legacy artifacts'); + assert.ok(fs.existsSync(statePath), 'invalid dry-run input must preserve legacy state'); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('does not misclassify a clean Codex home as a legacy install', () => { const homeDir = createTempDir('uninstall-clean-codex-home-'); const projectRoot = createTempDir('uninstall-clean-codex-project-'); diff --git a/yarn.lock b/yarn.lock index b54251237..8867e1184 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2005,9 +2005,9 @@ __metadata: linkType: hard "toml@npm:^4.1.1": - version: 4.1.1 - resolution: "toml@npm:4.1.1" - checksum: 10c0/077bc02ac1ce82091ea073f675d7e2a1df487d1b18bbc7e653daba4956d545954b7095e979b8792f0837339b901ee190ad4464342e5e377c36bbdeca8903e079 + version: 4.3.0 + resolution: "toml@npm:4.3.0" + checksum: 10c0/4a0ad64d4ef0b47f672a7b6bd7e776b9e03ad845998968fb105f8d561c3648eaff0217baca3f562d17e865e5bd7393441a8e54528920f0c171e41fdc8dfb39d6 languageName: node linkType: hard