From d9f6091ee807a8c1bbdcf1a13fe944347d1a000e Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Fri, 4 Sep 2026 15:02:57 -0400
Subject: [PATCH 001/141] fix: close PowerShell destructive command gate bypass
---
.../ecc-039-powershell-gateguard-plan.md | 255 +++
hooks/hooks.json | 14 +-
scripts/hooks/gateguard-fact-force.js | 61 +-
scripts/hooks/governance-capture.js | 48 +-
scripts/hooks/posttooluse-dispatcher.js | 5 +-
scripts/lib/powershell-destructive-command.js | 1802 +++++++++++++++++
tests/hooks/gateguard-fact-force.test.js | 173 ++
tests/hooks/governance-capture.test.js | 213 ++
tests/hooks/hooks.test.js | 101 +
tests/hooks/posttooluse-dispatcher.test.js | 14 +-
.../powershell-destructive-command.test.js | 692 +++++++
11 files changed, 3352 insertions(+), 26 deletions(-)
create mode 100644 docs/security/ecc-039-powershell-gateguard-plan.md
create mode 100644 scripts/lib/powershell-destructive-command.js
create mode 100644 tests/lib/powershell-destructive-command.test.js
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..c77889f60
--- /dev/null
+++ b/docs/security/ecc-039-powershell-gateguard-plan.md
@@ -0,0 +1,255 @@
+# 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 and under Gate 2 review
+
+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 complete locally and remains uncommitted for Gate 2.
+It adds the shared classifier, dedicated PowerShell hook routes, exact
+GateGuard/governance rule parity, redacted evidence, case-insensitive tool
+matching, and post-tool governance dispatch.
+
+- Focused classifier and hook suites: 529 passed, 0 failed.
+- Full repository suite: 4,215 passed, 0 failed.
+- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.55%
+ 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/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 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 +1302,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 +1314,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 +1330,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..2d161d232 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,20 @@ function summarizeCommand(command) {
};
}
+ 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 +164,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 +200,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 +219,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
toolName,
hookPhase,
...commandSummary,
- matchedPatterns: approvalFindings.map(f => f.pattern),
+ matchedPatterns,
severity: 'high',
},
resolvedAt: null,
@@ -220,7 +250,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/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/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
new file mode 100644
index 000000000..fe8d1d43d
--- /dev/null
+++ b/scripts/lib/powershell-destructive-command.js
@@ -0,0 +1,1802 @@
+'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',
+ '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 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, "'") : content.replace(/`(.)/gs, '$1');
+}
+
+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);
+ } 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 word = '';
+ let wordHasQuotedContent = false;
+ let wordHasUnquotedContent = false;
+ let quote = null;
+ let parenDepth = 0;
+ let callOperatorPending = false;
+
+ const flushWord = () => {
+ if (word) {
+ segment.push(word);
+ segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent);
+ }
+ word = '';
+ wordHasQuotedContent = false;
+ wordHasUnquotedContent = false;
+ };
+ const flushSegment = () => {
+ flushWord();
+ if (segment.length) {
+ Object.defineProperties(segment, {
+ invokedByCallOperator: { value: callOperatorPending },
+ quotedTokens: { value: segmentQuotedTokens },
+ });
+ statement.push(segment);
+ callOperatorPending = false;
+ }
+ segment = [];
+ segmentQuotedTokens = [];
+ };
+ 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 += "'";
+ index += 1;
+ } else if (char === "'") {
+ quote = null;
+ } else {
+ word += char;
+ wordHasQuotedContent = true;
+ }
+ continue;
+ }
+
+ if (char === '`') {
+ if (index + 1 >= input.length) {
+ word += '`';
+ continue;
+ }
+ const escaped = input[index + 1];
+ index += 1;
+ if (escaped === '\n' || escaped === '\r') {
+ flushWord();
+ if (escaped === '\r' && input[index + 1] === '\n') index += 1;
+ } else {
+ word += escaped;
+ if (quote) wordHasQuotedContent = true;
+ else wordHasUnquotedContent = true;
+ }
+ continue;
+ }
+
+ if (quote === '"') {
+ if (char === '"') {
+ quote = null;
+ } else {
+ word += char;
+ wordHasQuotedContent = true;
+ }
+ continue;
+ }
+
+ if (char === "'" || char === '"') {
+ quote = char;
+ wordHasQuotedContent = true;
+ continue;
+ }
+
+ if (char === '(') {
+ parenDepth += 1;
+ word += char;
+ wordHasUnquotedContent = true;
+ continue;
+ }
+ if (char === ')' && parenDepth > 0) {
+ parenDepth -= 1;
+ word += 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;
+ }
+
+ word += 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 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]|[^"])*)")\s*(?=;|\r?\n|$)`,
+ 'g'
+ );
+ let match;
+ while ((match = pattern.exec(input)) !== null) {
+ if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue;
+ const value = match[2] !== undefined
+ ? match[2].replace(/''/g, "'")
+ : match[3].replace(/`(.)/gs, '$1');
+ state.staticScalars.set(match[1].toLowerCase(), 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 staticPipelineInput(tokens) {
+ if (!tokens || tokens.length === 0) return null;
+ if (tokens.length === 1) {
+ const value = String(tokens[0] || '');
+ return value || null;
+ }
+ const command = commandBasename(tokens[0]);
+ if ((command === 'write-output' || command === 'echo') && tokens.length === 2) {
+ const value = String(tokens[1] || '');
+ return tokens.quotedTokens?.[1] === true || /\s/.test(value) ? value : null;
+ }
+ 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 decoded = decodeUtf16LeBase64(tokens[index + 1]);
+ if (decoded !== null) addNestedScan(decoded, depth, findings, analysis, {}, scanState);
+ return;
+ }
+
+ if (isCommandFlag(token)) {
+ const payload = tokens.slice(index + 1).join(' ');
+ const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
+ 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*(?:>>?|<)/.test(words[0])) {
+ const redirection = words.shift();
+ if (/^\d*(?:>>?|<)$/.test(redirection)) words.shift();
+ }
+ if (words.length === 0) return;
+
+ let firstCommand = commandBasename(words[0].replace(/^@+/, '').replace(/^\(+/, ''));
+ if (firstCommand === 'if') {
+ const elseIndex = words.findIndex((word, index) => 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 staticAliasDefinition(tokens, quotedTokens = []) {
+ let name = null;
+ let value = null;
+ const positional = [];
+ for (let index = 1; index < tokens.length; index += 1) {
+ const token = tokens[index];
+ if (!quotedTokens[index] && isParameterPrefix(token, 'name')) {
+ name = parameterValue(token) || tokens[++index] || null;
+ } else if (!quotedTokens[index] && isParameterPrefix(token, 'value')) {
+ value = parameterValue(token) || tokens[++index] || null;
+ } else if (!String(token).startsWith('-')) {
+ positional.push(token);
+ }
+ }
+ name ||= positional[0] || null;
+ value ||= positional[1] || null;
+ if (!/^[A-Za-z_][\w-]*$/.test(name || '') || !/^[A-Za-z_][\w./\\-]*$/.test(value || '')) {
+ return null;
+ }
+ return { name: name.toLowerCase(), value };
+}
+
+function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) {
+ const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi;
+ while (pattern.exec(unquoted) !== null) {
+ const argumentSource = source.slice(pattern.lastIndex);
+ const literal = argumentSource.match(/^\s*(?:'(?:''|[^'])*'|"(?:`[\s\S]|[^"])*")/);
+ const payload = literal ? staticStringResult(literal[0].trim()) : null;
+ 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 (commandName === 'set-alias' || commandName === 'new-alias') {
+ const definition = staticAliasDefinition(tokens, executable.quotedTokens);
+ if (definition) 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/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index 54a19c0e0..f62b5c803 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);
@@ -2860,6 +2892,147 @@ function runTests() {
passed++;
else failed++;
+ // --- PowerShell tool consumer contract ---
+ if (
+ test('normalizes PowerShell tool-name casing before destructive classification', () => {
+ for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
+ clearState();
+ const result = runPowerShellHook({
+ tool_name: toolName,
+ tool_input: { command: 'Remove-Item -Force C:/tmp/demo' }
+ });
+ assert.strictEqual(result.code, 0, `${toolName} hook should exit 0`);
+ const output = parseOutput(result.stdout);
+ assert.ok(output, `${toolName} should produce JSON output`);
+ assert.strictEqual(
+ output.hookSpecificOutput?.permissionDecision,
+ 'deny',
+ `${toolName} should be denied`
+ );
+ assert.match(
+ output.hookSpecificOutput.permissionDecisionReason,
+ /Destructive command detected/
+ );
+ }
+ })
+ )
+ passed++;
+ else failed++;
+
+ if (
+ test('denies the first routine PowerShell command and allows its retry', () => {
+ clearState();
+ const input = {
+ tool_name: 'PowerShell',
+ tool_input: { command: 'Get-Date' }
+ };
+
+ const first = runPowerShellHook(input);
+ assert.strictEqual(first.code, 0, 'first PowerShell hook should exit 0');
+ const firstOutput = parseOutput(first.stdout);
+ assert.ok(firstOutput, 'first PowerShell attempt should produce JSON output');
+ assert.strictEqual(
+ firstOutput.hookSpecificOutput?.permissionDecision,
+ 'deny',
+ 'first routine PowerShell command should be denied'
+ );
+ assert.match(
+ firstOutput.hookSpecificOutput.permissionDecisionReason,
+ /pre:powershell:gateguard-fact-force/,
+ 'recovery guidance should name the independently configurable PowerShell hook ID'
+ );
+
+ const retry = runPowerShellHook(input);
+ assert.strictEqual(retry.code, 0, 'PowerShell retry should exit 0');
+ const retryOutput = parseOutput(retry.stdout);
+ assert.ok(retryOutput, 'PowerShell retry should produce JSON output');
+ if (retryOutput.hookSpecificOutput) {
+ assert.notStrictEqual(
+ retryOutput.hookSpecificOutput.permissionDecision,
+ 'deny',
+ 'routine PowerShell retry should be allowed'
+ );
+ } else {
+ assert.strictEqual(retryOutput.tool_name, 'PowerShell');
+ }
+ })
+ )
+ passed++;
+ else failed++;
+
+ if (
+ test('denies direct and nested destructive PowerShell commands', () => {
+ const commands = [
+ 'Remove-Item -Recurse C:/tmp/demo',
+ 'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
+ 'pwsh -Command "Remove-Item -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'
+ ];
+
+ 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..528c593e6 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -185,6 +185,219 @@ 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 -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(command),
+ '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 = "$password='governance-secret-sentinel'; 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-secret-sentinel'));
+ }
+ })) 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(command),
+ '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..442cca64b 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');
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/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
new file mode 100644
index 000000000..0589c0225
--- /dev/null
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -0,0 +1,692 @@
+'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('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('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(
+ "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]);
+});
+
+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",
+ ]) {
+ expectRules(command, [RULES.REMOVE_FORCE]);
+ }
+ expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
+ 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]
+ );
+});
+
+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);
+}
From 3a384ca69823b17c41e07f4f5018582502719102 Mon Sep 17 00:00:00 2001
From: wellkilo
Date: Sat, 5 Sep 2026 23:37:49 +0800
Subject: [PATCH 002/141] fix: require observer analysis completion sentinel
---
.../agents/observer-loop.sh | 192 ++++++++-
tests/hooks/observer-loop-archive.test.js | 370 +++++++++++++-----
2 files changed, 450 insertions(+), 112 deletions(-)
diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh
index 74b8f5110..247d60b7a 100755
--- a/skills/continuous-learning-v2/agents/observer-loop.sh
+++ b/skills/continuous-learning-v2/agents/observer-loop.sh
@@ -9,6 +9,12 @@ set +e
unset CLAUDECODE
SLEEP_PID=""
+CLAUDE_PID=""
+CLAUDE_PROCESS_GROUP=0
+WATCHDOG_PID=""
+ACTIVE_ANALYSIS_FILE=""
+ACTIVE_RESULT_FILE=""
+RESULT_FDS_OPEN=0
USR1_FIRED=0
PENDING_ANALYSIS=0
ANALYZING=0
@@ -25,7 +31,81 @@ 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_RESULT_FILE" ] && rm -f "$ACTIVE_RESULT_FILE"
+ ACTIVE_ANALYSIS_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 +229,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"
@@ -206,6 +296,12 @@ Rules:
- If a pattern seems universal (not project-specific), set scope to global instead of project
- Examples of global patterns: always validate user input, prefer explicit error handling
- Examples of project patterns: use React functional components, follow Django REST framework conventions
+
+Completion contract:
+- After successfully reading and analyzing the sampled observations, and after completing any required instinct writes, output this exact JSON record as the final non-empty line:
+{"status":"analysis_complete"}
+- Do not output that record if reading, analysis, or a required write is blocked or fails
+- A completed analysis with no qualifying pattern must still output the record
PROMPT
# Read the prompt into memory before the Claude subprocess is spawned.
@@ -216,7 +312,7 @@ PROMPT
rm -f "$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 +345,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 +381,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 &
+ 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/tests/hooks/observer-loop-archive.test.js b/tests/hooks/observer-loop-archive.test.js
index 56676e76b..c63cc6a75 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,310 @@ 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')
+ );
+ 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),
+ };
+ 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);
From 63dea9c9250251347cedde72fa51e52b8e44354d Mon Sep 17 00:00:00 2001
From: wellkilo
Date: Sat, 5 Sep 2026 23:56:57 +0800
Subject: [PATCH 003/141] fix: harden observer completion handling
---
.../continuous-learning-v2/agents/observer-loop.sh | 13 ++++++++++++-
tests/hooks/observer-loop-archive.test.js | 10 ++++++++--
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh
index 247d60b7a..bce0d2d7c 100755
--- a/skills/continuous-learning-v2/agents/observer-loop.sh
+++ b/skills/continuous-learning-v2/agents/observer-loop.sh
@@ -13,6 +13,7 @@ CLAUDE_PID=""
CLAUDE_PROCESS_GROUP=0
WATCHDOG_PID=""
ACTIVE_ANALYSIS_FILE=""
+ACTIVE_PROMPT_FILE=""
ACTIVE_RESULT_FILE=""
RESULT_FDS_OPEN=0
USR1_FIRED=0
@@ -99,8 +100,10 @@ cleanup_analysis_resources() {
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=""
}
@@ -256,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"
cleanup_analysis_resources
@@ -407,7 +418,7 @@ PROMPT
signal_claude_process "$CLAUDE_PID" KILL
fi
fi
- ) /dev/null 2>&1 &
+ ) /dev/null 2>&1 7<&- 8>&- 9<&- &
WATCHDOG_PID=$!
wait_for_claude_analysis "$CLAUDE_PID"
diff --git a/tests/hooks/observer-loop-archive.test.js b/tests/hooks/observer-loop-archive.test.js
index c63cc6a75..f35089282 100644
--- a/tests/hooks/observer-loop-archive.test.js
+++ b/tests/hooks/observer-loop-archive.test.js
@@ -112,7 +112,7 @@ function runAnalyzeOnce(options = {}) {
' [ -e "$candidate" ] || [ -L "$candidate" ] || continue',
" printf 'found\n' > \"${CLAUDE_STUB_ATTACK_FILE}\"",
' rm -f "$candidate"',
- " printf '%s\n' '${ANALYSIS_COMPLETE_RECORD}' > \"$candidate\"",
+ ` printf '%s\n' '${ANALYSIS_COMPLETE_RECORD}' > "$candidate"`,
' done',
'fi',
'if [ "${CLAUDE_STUB_DELAY_SECONDS:-0}" != "0" ]; then',
@@ -148,7 +148,9 @@ function runAnalyzeOnce(options = {}) {
if (existingLog) fs.writeFileSync(logFile, existingLog);
const inheritedEnv = Object.fromEntries(
- Object.entries(process.env).filter(([key]) => key !== 'CLAUDE_PLUGIN_ROOT')
+ Object.entries(process.env).filter(
+ ([key]) => key !== 'CLAUDE_PLUGIN_ROOT' && !key.startsWith('ECC_OBSERVER_')
+ )
);
const childEnv = {
...inheritedEnv,
@@ -173,6 +175,10 @@ function runAnalyzeOnce(options = {}) {
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();
From 3ad828db476679503c9ce30ec071517d6e4d5d8b Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:12:58 -0400
Subject: [PATCH 004/141] fix: address PowerShell review bypasses
---
scripts/hooks/governance-capture.js | 7 +++
scripts/lib/powershell-destructive-command.js | 44 +++++++++++++++++--
tests/hooks/gateguard-fact-force.test.js | 2 +
tests/hooks/governance-capture.test.js | 18 +++++++-
.../powershell-destructive-command.test.js | 11 +++++
5 files changed, 77 insertions(+), 5 deletions(-)
diff --git a/scripts/hooks/governance-capture.js b/scripts/hooks/governance-capture.js
index 2d161d232..5f75baeaf 100644
--- a/scripts/hooks/governance-capture.js
+++ b/scripts/hooks/governance-capture.js
@@ -133,6 +133,13 @@ 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
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index fe8d1d43d..de04c2872 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -28,6 +28,7 @@ const RULE_IDS = Object.freeze({
const DELETE_COMMANDS = new Set([
'remove-item',
'remove-itemproperty',
+ 'rp',
'ri',
'rm',
'rmdir',
@@ -507,6 +508,33 @@ function staticStringResult(body) {
return quote === "'" ? content.replace(/''/g, "'") : content.replace(/`(.)/gs, '$1');
}
+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) {
+ value += input[index + 1];
+ index += 2;
+ 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();
@@ -1083,8 +1111,19 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
}
if (isCommandFlag(token)) {
- const payload = tokens.slice(index + 1).join(' ');
+ let payload = tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
+ const payloadReference = tokens.quotedTokens?.[index + 1] === true
+ ? 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,
@@ -1558,8 +1597,7 @@ function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, stat
const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi;
while (pattern.exec(unquoted) !== null) {
const argumentSource = source.slice(pattern.lastIndex);
- const literal = argumentSource.match(/^\s*(?:'(?:''|[^'])*'|"(?:`[\s\S]|[^"])*")/);
- const payload = literal ? staticStringResult(literal[0].trim()) : null;
+ const payload = leadingStaticStringResult(argumentSource);
if (payload === null) {
findings.add(RULE_IDS.DYNAMIC_EXECUTION);
} else {
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index f62b5c803..a92df0b0c 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2964,8 +2964,10 @@ function runTests() {
test('denies direct and nested destructive PowerShell commands', () => {
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"',
+ "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index 528c593e6..2ab6e6099 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -325,7 +325,7 @@ async function runTests() {
})) passed += 1; else failed += 1;
if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => {
- const command = "$password='governance-secret-sentinel'; Remove-Item -Force C:/tmp/demo";
+ 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,
@@ -337,10 +337,24 @@ async function runTests() {
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-secret-sentinel'));
+ 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',
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index 0589c0225..5e3592676 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -98,6 +98,7 @@ 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,
]);
@@ -455,10 +456,13 @@ test('classifies static execution primitives', () => {
"$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",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
+ expectRules('pwsh -Command $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
+ 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,
@@ -492,6 +496,13 @@ test('classifies static execution primitives', () => {
);
});
+test('scans malformed InvokeScript string arguments in bounded time', () => {
+ const command = `$ExecutionContext.InvokeCommand.InvokeScript("${'`!'.repeat(10000)}`;
+ const startedAt = Date.now();
+ expectRules(command, [RULES.DYNAMIC_EXECUTION]);
+ assert.ok(Date.now() - startedAt < 1000, 'malformed string scan should remain bounded');
+});
+
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]);
From 8eeac94af3d76359baa42f5001ad8af81b18ad17 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:32:15 -0400
Subject: [PATCH 005/141] fix: preserve PowerShell expansion semantics
---
scripts/hooks/gateguard-fact-force.js | 11 ++---
scripts/lib/powershell-destructive-command.js | 40 ++++++++++++++++---
tests/hooks/gateguard-fact-force.test.js | 1 +
.../powershell-destructive-command.test.js | 14 ++++++-
4 files changed, 52 insertions(+), 14 deletions(-)
diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js
index 7b43c5369..6fc3b7a28 100644
--- a/scripts/hooks/gateguard-fact-force.js
+++ b/scripts/hooks/gateguard-fact-force.js
@@ -738,13 +738,10 @@ function classifyDestructiveCommand(toolName, command) {
const normalizedTool = String(toolName || '').toLowerCase();
if (normalizedTool !== 'bash' && normalizedTool !== 'powershell') return [];
- const findings = [];
- if (isDestructiveBash(command)) {
- findings.push('gateguard.bash-compatible-destructive');
- }
- if (normalizedTool === 'powershell') {
- findings.push(...classifyPowerShellDestructiveCommand(command));
- }
+ const findings = [
+ ...(isDestructiveBash(command) ? ['gateguard.bash-compatible-destructive'] : []),
+ ...(normalizedTool === 'powershell' ? classifyPowerShellDestructiveCommand(command) : []),
+ ];
return [...new Set(findings)];
}
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index de04c2872..6ab7f3600 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -505,7 +505,24 @@ function staticStringResult(body) {
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, "'") : content.replace(/`(.)/gs, '$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;
+ else if (escaped !== '\n') value += escaped;
+ }
+ return value;
}
function leadingStaticStringResult(source) {
@@ -524,8 +541,10 @@ function leadingStaticStringResult(source) {
continue;
}
if (quote === '"' && char === '`' && index + 1 < input.length) {
- value += input[index + 1];
+ const escaped = input[index + 1];
index += 2;
+ if (escaped === '\r' && input[index] === '\n') index += 1;
+ else if (escaped !== '\n') value += escaped;
continue;
}
if (char === quote) return value;
@@ -845,9 +864,11 @@ function parseStatements(input) {
let statement = [];
let segment = [];
let segmentQuotedTokens = [];
+ let segmentQuoteKinds = [];
let word = '';
let wordHasQuotedContent = false;
let wordHasUnquotedContent = false;
+ let wordQuoteKind = null;
let quote = null;
let parenDepth = 0;
let callOperatorPending = false;
@@ -856,10 +877,14 @@ function parseStatements(input) {
if (word) {
segment.push(word);
segmentQuotedTokens.push(wordHasQuotedContent && !wordHasUnquotedContent);
+ segmentQuoteKinds.push(
+ wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null
+ );
}
word = '';
wordHasQuotedContent = false;
wordHasUnquotedContent = false;
+ wordQuoteKind = null;
};
const flushSegment = () => {
flushWord();
@@ -867,12 +892,14 @@ function parseStatements(input) {
Object.defineProperties(segment, {
invokedByCallOperator: { value: callOperatorPending },
quotedTokens: { value: segmentQuotedTokens },
+ quoteKinds: { value: segmentQuoteKinds },
});
statement.push(segment);
callOperatorPending = false;
}
segment = [];
segmentQuotedTokens = [];
+ segmentQuoteKinds = [];
};
const flushStatement = () => {
flushSegment();
@@ -927,6 +954,7 @@ function parseStatements(input) {
if (char === "'" || char === '"') {
quote = char;
wordHasQuotedContent = true;
+ wordQuoteKind = wordQuoteKind === null || wordQuoteKind === char ? char : 'mixed';
continue;
}
@@ -1047,7 +1075,7 @@ function collectStaticScalarAssignments(input, state) {
assignmentCounts.set(name, (assignmentCounts.get(name) || 0) + 1);
}
const pattern = new RegExp(
- String.raw`(?:^|[;\r\n])\s*${variable}\s*=\s*(?:'((?:''|[^'])*)'|"((?:\x60[\s\S]|[^"])*)")\s*(?=;|\r?\n|$)`,
+ String.raw`(?:^|[;\r\n])\s*${variable}\s*=\s*(?:'((?:''|[^'])*)'|"((?:\x60[\s\S]|[^\x60"])*)")\s*(?=;|\r?\n|$)`,
'g'
);
let match;
@@ -1055,7 +1083,7 @@ function collectStaticScalarAssignments(input, state) {
if (match[3] !== undefined && /(^|[^`])\$/.test(match[3])) continue;
const value = match[2] !== undefined
? match[2].replace(/''/g, "'")
- : match[3].replace(/`(.)/gs, '$1');
+ : decodeDoubleQuotedString(match[3]);
state.staticScalars.set(match[1].toLowerCase(), value);
}
for (const [name, count] of assignmentCounts) {
@@ -1113,7 +1141,7 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
if (isCommandFlag(token)) {
let payload = tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
- const payloadReference = tokens.quotedTokens?.[index + 1] === true
+ const payloadReference = tokens.quoteKinds?.[index + 1] === "'"
? null
: variableReference(payload);
if (payloadReference) {
@@ -1594,7 +1622,7 @@ function staticAliasDefinition(tokens, quotedTokens = []) {
}
function scanInvokeScriptCalls(source, unquoted, depth, findings, analysis, state) {
- const pattern = /\$executioncontext\.invokecommand\.invokescript\s*\(/gi;
+ const pattern = /(?:\$\{executioncontext\}|\$executioncontext)\.invokecommand\.invokescript\s*\(/gi;
while (pattern.exec(unquoted) !== null) {
const argumentSource = source.slice(pattern.lastIndex);
const payload = leadingStaticStringResult(argumentSource);
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index a92df0b0c..a5ca170fc 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2968,6 +2968,7 @@ function runTests() {
'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
'pwsh -Command "Remove-Item -Force C:/tmp/demo"',
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
+ "$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index 5e3592676..b6fa6d2c2 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -457,6 +457,8 @@ test('classifies static execution primitives', () => {
"$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 `\n-Force C:/tmp/demo\"; pwsh -Command $payload",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
}
@@ -494,13 +496,23 @@ test('classifies static execution primitives', () => {
"$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]
+ );
});
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]);
- assert.ok(Date.now() - startedAt < 1000, 'malformed string scan should remain bounded');
+ 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', () => {
From f43195a2559994111734cd2595c83557f5844a12 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:57:10 -0400
Subject: [PATCH 006/141] fix: expand nested PowerShell command scalars
---
scripts/lib/powershell-destructive-command.js | 108 ++++++++++++++++--
tests/hooks/gateguard-fact-force.test.js | 2 +
tests/hooks/governance-capture.test.js | 8 ++
.../powershell-destructive-command.test.js | 9 ++
4 files changed, 116 insertions(+), 11 deletions(-)
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index 6ab7f3600..a2da868ba 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -525,6 +525,58 @@ function decodeDoubleQuotedString(content) {
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;
+ else if (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;
@@ -818,6 +870,12 @@ function extractExecutableContainers(input, options = {}) {
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))) {
@@ -865,7 +923,9 @@ function parseStatements(input) {
let segment = [];
let segmentQuotedTokens = [];
let segmentQuoteKinds = [];
+ let segmentTokenSources = [];
let word = '';
+ let wordSource = '';
let wordHasQuotedContent = false;
let wordHasUnquotedContent = false;
let wordQuoteKind = null;
@@ -880,8 +940,10 @@ function parseStatements(input) {
segmentQuoteKinds.push(
wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null
);
+ segmentTokenSources.push(wordSource);
}
word = '';
+ wordSource = '';
wordHasQuotedContent = false;
wordHasUnquotedContent = false;
wordQuoteKind = null;
@@ -893,6 +955,7 @@ function parseStatements(input) {
invokedByCallOperator: { value: callOperatorPending },
quotedTokens: { value: segmentQuotedTokens },
quoteKinds: { value: segmentQuoteKinds },
+ tokenSources: { value: segmentTokenSources },
});
statement.push(segment);
callOperatorPending = false;
@@ -900,6 +963,7 @@ function parseStatements(input) {
segment = [];
segmentQuotedTokens = [];
segmentQuoteKinds = [];
+ segmentTokenSources = [];
};
const flushStatement = () => {
flushSegment();
@@ -913,11 +977,13 @@ function parseStatements(input) {
if (quote === "'") {
if (char === "'" && input[index + 1] === "'") {
word += "'";
+ wordSource += "''";
index += 1;
} else if (char === "'") {
quote = null;
} else {
word += char;
+ wordSource += char;
wordHasQuotedContent = true;
}
continue;
@@ -926,13 +992,17 @@ function parseStatements(input) {
if (char === '`') {
if (index + 1 >= input.length) {
word += '`';
+ wordSource += '`';
continue;
}
const escaped = input[index + 1];
+ wordSource += `\`${escaped}`;
index += 1;
if (escaped === '\n' || escaped === '\r') {
- flushWord();
- if (escaped === '\r' && input[index + 1] === '\n') index += 1;
+ if (escaped === '\r' && input[index + 1] === '\n') {
+ wordSource += '\n';
+ index += 1;
+ }
} else {
word += escaped;
if (quote) wordHasQuotedContent = true;
@@ -946,6 +1016,7 @@ function parseStatements(input) {
quote = null;
} else {
word += char;
+ wordSource += char;
wordHasQuotedContent = true;
}
continue;
@@ -961,12 +1032,14 @@ function parseStatements(input) {
if (char === '(') {
parenDepth += 1;
word += char;
+ wordSource += char;
wordHasUnquotedContent = true;
continue;
}
if (char === ')' && parenDepth > 0) {
parenDepth -= 1;
word += char;
+ wordSource += char;
wordHasUnquotedContent = true;
continue;
}
@@ -990,6 +1063,7 @@ function parseStatements(input) {
}
word += char;
+ wordSource += char;
wordHasUnquotedContent = true;
}
@@ -1141,16 +1215,28 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
if (isCommandFlag(token)) {
let payload = tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
- const payloadReference = tokens.quoteKinds?.[index + 1] === "'"
- ? null
- : variableReference(payload);
- if (payloadReference) {
- const staticValue = scanState?.staticScalars.get(payloadReference);
- if (staticValue === undefined) {
- findings.add(RULE_IDS.DYNAMIC_EXECUTION);
- return;
+ const payloadIndex = index + 1;
+ const hasOnePayloadToken = tokens.length === payloadIndex + 1;
+ if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] === '"') {
+ const expanded = expandStaticDoubleQuotedString(
+ tokens.tokenSources?.[payloadIndex] ?? payload,
+ scanState,
+ findings
+ );
+ if (expanded === null) return;
+ payload = expanded;
+ } 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;
}
- payload = staticValue;
}
if (pipelinePayload || (payload && payload !== '-')) {
addNestedScan(
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index a5ca170fc..9abb1899e 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2969,6 +2969,8 @@ function runTests() {
'pwsh -Command "Remove-Item -Force C:/tmp/demo"',
"$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\"",
+ 'pwsh -Command "Write-Output ready; $runtimePayload"',
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index 2ab6e6099..e4b280e10 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -239,6 +239,14 @@ async function runTests() {
command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"',
expectedRules: ['powershell.remove-item.force'],
},
+ {
+ command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"",
+ expectedRules: ['powershell.remove-item.force'],
+ },
+ {
+ command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
+ expectedRules: ['powershell.dynamic-execution'],
+ },
{
command: `pwsh -EncodedCommand ${encodedPayload}`,
expectedRules: ['powershell.remove-item.wildcard'],
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index b6fa6d2c2..482cdea48 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -458,13 +458,22 @@ test('classifies static execution primitives', () => {
"$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 `\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,
+ ]);
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'");
+ expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output `$payload\"");
expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]);
expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [
RULES.DYNAMIC_EXECUTION,
From 56552d964fbc57af5666170a7c68376596f13085 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 16:58:31 -0400
Subject: [PATCH 007/141] docs: record final ECC-039 verification
---
.../security/ecc-039-powershell-gateguard-plan.md | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/docs/security/ecc-039-powershell-gateguard-plan.md b/docs/security/ecc-039-powershell-gateguard-plan.md
index c77889f60..bac9f446a 100644
--- a/docs/security/ecc-039-powershell-gateguard-plan.md
+++ b/docs/security/ecc-039-powershell-gateguard-plan.md
@@ -7,7 +7,7 @@
- Priority: critical
- Baseline: `origin/main` at `e04ea0b9`
- Source to salvage: PR #2721 at `4a2e59ba`
-- Implementation state: implemented and under Gate 2 review
+- 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
@@ -193,14 +193,15 @@ the supported Node and package-manager CI matrix at the exact proposed head.
## Implementation and Verification Results
-The implementation is complete locally and remains uncommitted for Gate 2.
-It adds the shared classifier, dedicated PowerShell hook routes, exact
+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, and post-tool governance dispatch.
+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: 529 passed, 0 failed.
-- Full repository suite: 4,215 passed, 0 failed.
-- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.55%
+- 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.29% 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
From cb5311222d05c563c10095e05050fce558af61f2 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:29:02 -0400
Subject: [PATCH 008/141] fix: scan inline PowerShell command parameters
---
.../ecc-039-powershell-gateguard-plan.md | 2 +-
scripts/lib/powershell-destructive-command.js | 70 +++++++++++++++++--
tests/hooks/gateguard-fact-force.test.js | 6 ++
tests/hooks/governance-capture.test.js | 10 ++-
.../powershell-destructive-command.test.js | 16 +++++
5 files changed, 95 insertions(+), 9 deletions(-)
diff --git a/docs/security/ecc-039-powershell-gateguard-plan.md b/docs/security/ecc-039-powershell-gateguard-plan.md
index bac9f446a..03c971b02 100644
--- a/docs/security/ecc-039-powershell-gateguard-plan.md
+++ b/docs/security/ecc-039-powershell-gateguard-plan.md
@@ -201,7 +201,7 @@ 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.29% branches, 94.56%
+- 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
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index a2da868ba..9f0672163 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -520,7 +520,7 @@ function decodeDoubleQuotedString(content) {
const escaped = input[index + 1];
index += 1;
if (escaped === '\r' && input[index + 1] === '\n') index += 1;
- else if (escaped !== '\n') value += escaped;
+ if (escaped !== '\r' && escaped !== '\n') value += escaped;
}
return value;
}
@@ -535,7 +535,7 @@ function expandStaticDoubleQuotedString(content, state, findings) {
const escaped = input[index + 1];
index += 1;
if (escaped === '\r' && input[index + 1] === '\n') index += 1;
- else if (escaped !== '\n') value += escaped;
+ if (escaped !== '\r' && escaped !== '\n') value += escaped;
continue;
}
if (char !== '$') {
@@ -596,7 +596,7 @@ function leadingStaticStringResult(source) {
const escaped = input[index + 1];
index += 2;
if (escaped === '\r' && input[index] === '\n') index += 1;
- else if (escaped !== '\n') value += escaped;
+ if (escaped !== '\r' && escaped !== '\n') value += escaped;
continue;
}
if (char === quote) return value;
@@ -924,11 +924,14 @@ function parseStatements(input) {
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;
@@ -941,12 +944,19 @@ function parseStatements(input) {
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();
@@ -956,6 +966,7 @@ function parseStatements(input) {
quotedTokens: { value: segmentQuotedTokens },
quoteKinds: { value: segmentQuoteKinds },
tokenSources: { value: segmentTokenSources },
+ inlineValueQuoteKinds: { value: segmentInlineValueQuoteKinds },
});
statement.push(segment);
callOperatorPending = false;
@@ -964,6 +975,7 @@ function parseStatements(input) {
segmentQuotedTokens = [];
segmentQuoteKinds = [];
segmentTokenSources = [];
+ segmentInlineValueQuoteKinds = [];
};
const flushStatement = () => {
flushSegment();
@@ -981,6 +993,7 @@ function parseStatements(input) {
index += 1;
} else if (char === "'") {
quote = null;
+ if (wordInlineValueQuoteKind === "'") wordInlineValueQuoteClosed = true;
} else {
word += char;
wordSource += char;
@@ -1004,6 +1017,7 @@ function parseStatements(input) {
index += 1;
}
} else {
+ if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
word += escaped;
if (quote) wordHasQuotedContent = true;
else wordHasUnquotedContent = true;
@@ -1014,6 +1028,7 @@ function parseStatements(input) {
if (quote === '"') {
if (char === '"') {
quote = null;
+ if (wordInlineValueQuoteKind === '"') wordInlineValueQuoteClosed = true;
} else {
word += char;
wordSource += char;
@@ -1023,6 +1038,11 @@ function parseStatements(input) {
}
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';
@@ -1030,6 +1050,7 @@ function parseStatements(input) {
}
if (char === '(') {
+ if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
parenDepth += 1;
word += char;
wordSource += char;
@@ -1037,6 +1058,7 @@ function parseStatements(input) {
continue;
}
if (char === ')' && parenDepth > 0) {
+ if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
parenDepth -= 1;
word += char;
wordSource += char;
@@ -1062,6 +1084,7 @@ function parseStatements(input) {
continue;
}
+ if (wordInlineValueQuoteClosed) wordInlineValueQuoteKind = 'mixed';
word += char;
wordSource += char;
wordHasUnquotedContent = true;
@@ -1207,17 +1230,50 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
const token = tokens[index];
if (isEncodedCommandFlag(token)) {
- const decoded = decodeUtf16LeBase64(tokens[index + 1]);
+ 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)) {
- let payload = tokens.slice(index + 1).join(' ');
+ const inlinePayload = parameterValue(token);
+ let payload = inlinePayload
+ ? [inlinePayload, ...tokens.slice(index + 1)].join(' ')
+ : tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
const payloadIndex = index + 1;
- const hasOnePayloadToken = tokens.length === payloadIndex + 1;
- if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] === '"') {
+ const hasOnePayloadToken = !inlinePayload && tokens.length === payloadIndex + 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 (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] !== "'") {
const expanded = expandStaticDoubleQuotedString(
tokens.tokenSources?.[payloadIndex] ?? payload,
scanState,
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index 9abb1899e..56af9349d 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2962,11 +2962,17 @@ function runTests() {
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\"",
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index e4b280e10..bf48483a1 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -239,6 +239,10 @@ async function runTests() {
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'],
@@ -251,6 +255,10 @@ async function runTests() {
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'],
@@ -302,7 +310,7 @@ async function runTests() {
'Should not store raw command text'
);
assert.ok(
- !JSON.stringify(approvalEvent).includes(command),
+ !JSON.stringify(approvalEvent).includes(JSON.stringify(command).slice(1, -1)),
'Serialized governance evidence should not leak the raw command'
);
}
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index 482cdea48..00d53c279 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -228,6 +228,8 @@ test('classifies powershell and pwsh command payloads recursively', () => {
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]
@@ -277,6 +279,13 @@ test('classifies UTF-16LE EncodedCommand payloads', () => {
).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', () => {
@@ -460,6 +469,7 @@ test('classifies static execution primitives', () => {
"$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 `\n-Force C:/tmp/demo\"; pwsh -Command $payload",
]) {
expectRules(command, [RULES.REMOVE_FORCE]);
@@ -474,6 +484,8 @@ test('classifies static execution primitives', () => {
]);
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,
@@ -513,6 +525,10 @@ test('classifies static execution primitives', () => {
"$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', () => {
From 99668f0ef5aa43c4fbdef8cd1e026ebf3584c26c Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:38:20 -0400
Subject: [PATCH 009/141] fix: resolve nested PowerShell command tokens
---
scripts/lib/powershell-destructive-command.js | 11 +++++------
tests/hooks/gateguard-fact-force.test.js | 2 ++
tests/hooks/governance-capture.test.js | 6 +++++-
tests/lib/powershell-destructive-command.test.js | 4 ++++
4 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index 9f0672163..aea4e3f20 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -944,11 +944,11 @@ function parseStatements(input) {
wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null
);
segmentTokenSources.push(wordSource);
- segmentInlineValueQuoteKinds.push(
+ segmentInlineValueQuoteKinds = [...segmentInlineValueQuoteKinds,
wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed'
? wordInlineValueQuoteKind
: null
- );
+ ];
}
word = '';
wordSource = '';
@@ -1260,7 +1260,6 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
: tokens.slice(index + 1).join(' ');
const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
const payloadIndex = index + 1;
- const hasOnePayloadToken = !inlinePayload && tokens.length === payloadIndex + 1;
const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index];
if (inlinePayload && inlineQuoteKind !== "'") {
const inlineSource = parameterValue(tokens.tokenSources?.[index] || token);
@@ -1273,14 +1272,14 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
payload = [expanded, ...tokens.slice(index + 1)].join(' ');
} else if (inlinePayload) {
payload = [inlinePayload, ...tokens.slice(index + 1)].join(' ');
- } else if (hasOnePayloadToken && tokens.quoteKinds?.[payloadIndex] !== "'") {
+ } else if (tokens[payloadIndex] && tokens.quoteKinds?.[payloadIndex] !== "'") {
const expanded = expandStaticDoubleQuotedString(
- tokens.tokenSources?.[payloadIndex] ?? payload,
+ tokens.tokenSources?.[payloadIndex] ?? tokens[payloadIndex],
scanState,
findings
);
if (expanded === null) return;
- payload = expanded;
+ payload = [expanded, ...tokens.slice(payloadIndex + 1)].join(' ');
} else {
const payloadReference = tokens.quoteKinds?.[payloadIndex] === "'"
? null
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index 56af9349d..3735db1d9 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2976,7 +2976,9 @@ function runTests() {
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
+ "$payload='Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
'pwsh -Command "Write-Output ready; $runtimePayload"',
+ 'pwsh -Command $runtimePayload -Force C:/tmp/demo',
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
'& { Remove-Item -Force C:/tmp/demo }',
'if ($true) { Remove-Item -Force C:/tmp/demo }',
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index bf48483a1..9ee30fed3 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -251,6 +251,10 @@ async function runTests() {
command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
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'],
@@ -411,7 +415,7 @@ async function runTests() {
'Should not store raw command text'
);
assert.ok(
- !JSON.stringify(securityEvent).includes(command),
+ !JSON.stringify(securityEvent).includes(JSON.stringify(command).slice(1, -1)),
'Serialized governance evidence should not leak the raw command'
);
}
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index 00d53c279..569cbb18d 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -470,6 +470,7 @@ test('classifies static execution primitives', () => {
"$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]);
@@ -482,6 +483,9 @@ test('classifies static execution primitives', () => {
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");
From db88758cbdadf214728d5ea028fa5705453d6ffc Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Sat, 5 Sep 2026 17:47:26 -0400
Subject: [PATCH 010/141] fix: preserve linear PowerShell tokenization
---
scripts/lib/powershell-destructive-command.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index aea4e3f20..f99216016 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -944,11 +944,11 @@ function parseStatements(input) {
wordHasQuotedContent && !wordHasUnquotedContent ? wordQuoteKind : null
);
segmentTokenSources.push(wordSource);
- segmentInlineValueQuoteKinds = [...segmentInlineValueQuoteKinds,
+ segmentInlineValueQuoteKinds.push(
wordInlineValueQuoteClosed && wordInlineValueQuoteKind !== 'mixed'
? wordInlineValueQuoteKind
: null
- ];
+ );
}
word = '';
wordSource = '';
From 569e5a36bb18c3a36924618505f0c3ef66df7c0d Mon Sep 17 00:00:00 2001
From: wellkilo
Date: Mon, 7 Sep 2026 00:53:13 +0800
Subject: [PATCH 011/141] feat(install): register manual Claude hooks
---
README.md | 5 +-
hooks/README.md | 6 +-
schemas/hooks.schema.json | 5 +
schemas/install-state.schema.json | 85 +-
scripts/ci/validate-hooks.js | 29 +-
scripts/lib/install-lifecycle.js | 236 +++++-
scripts/lib/install-state.js | 33 +
scripts/lib/install-targets/claude-home.js | 49 +-
scripts/lib/install-targets/claude-project.js | 49 +-
scripts/lib/install/apply.js | 416 ++++++----
scripts/lib/install/claude-settings.js | 750 ++++++++++++++++++
scripts/lib/install/hook-consent.js | 5 +-
scripts/lib/install/plan.js | 27 +
tests/ci/validators.test.js | 137 +++-
tests/lib/claude-settings.test.js | 557 +++++++++++++
tests/lib/hook-consent.test.js | 24 +-
tests/lib/install-executor.test.js | 168 ++++
tests/lib/install-lifecycle.test.js | 477 ++++++++++-
tests/lib/install-state.test.js | 109 +++
tests/scripts/install-apply.test.js | 323 +++++---
.../scripts/manual-hook-install-docs.test.js | 8 +
21 files changed, 3160 insertions(+), 338 deletions(-)
create mode 100644 scripts/lib/install/claude-settings.js
create mode 100644 tests/lib/claude-settings.test.js
diff --git a/README.md b/README.md
index 00d3a8ca7..2431bd418 100644
--- a/README.md
+++ b/README.md
@@ -555,7 +555,10 @@ 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.
diff --git a/hooks/README.md b/hooks/README.md
index 510dfa755..e540b2d24 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/schemas/hooks.schema.json b/schemas/hooks.schema.json
index 4d1192973..c325d9712 100644
--- a/schemas/hooks.schema.json
+++ b/schemas/hooks.schema.json
@@ -122,6 +122,11 @@
"hooks"
],
"properties": {
+ "id": {
+ "type": "string",
+ "pattern": "\\S",
+ "description": "Stable identifier for a matcher entry. Required and globally unique in wrapped object format."
+ },
"matcher": {
"oneOf": [
{
diff --git a/schemas/install-state.schema.json b/schemas/install-state.schema.json
index 976d5129b..9b827e144 100644
--- a/schemas/install-state.schema.json
+++ b/schemas/install-state.schema.json
@@ -213,9 +213,92 @@
"contentSha256": {
"type": "string",
"pattern": "^[a-fA-F0-9]{64}$"
+ },
+ "managedHooks": {
+ "type": "object",
+ "minProperties": 1,
+ "propertyNames": {
+ "enum": [
+ "SessionStart",
+ "UserPromptSubmit",
+ "PreToolUse",
+ "PermissionRequest",
+ "PostToolUse",
+ "PostToolUseFailure",
+ "Notification",
+ "SubagentStart",
+ "Stop",
+ "SubagentStop",
+ "PreCompact",
+ "InstructionsLoaded",
+ "TeammateIdle",
+ "TaskCompleted",
+ "ConfigChange",
+ "WorktreeCreate",
+ "WorktreeRemove",
+ "SessionEnd"
+ ]
+ },
+ "additionalProperties": {
+ "type": "array",
+ "minItems": 1,
+ "items": {
+ "type": "object",
+ "required": ["id", "hooks"],
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "\\S"
+ }
+ }
+ }
+ }
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "kind": { "const": "update-claude-settings" }
+ }
+ },
+ "then": {
+ "required": ["managedHooks"],
+ "properties": {
+ "moduleId": { "const": "hooks-runtime" },
+ "sourceRelativePath": { "const": "hooks/hooks.json" }
+ }
+ }
+ }
+ ]
+ }
+ }
+ },
+ "allOf": [
+ {
+ "if": {
+ "properties": {
+ "operations": {
+ "contains": {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "update-claude-settings" }
+ },
+ "required": ["kind"]
+ }
+ }
+ }
+ },
+ "then": {
+ "properties": {
+ "target": {
+ "properties": {
+ "target": { "enum": ["claude", "claude-project"] }
+ },
+ "required": ["target"]
}
}
}
}
- }
+ ]
}
diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js
index bc1da8020..779555a44 100644
--- a/scripts/ci/validate-hooks.js
+++ b/scripts/ci/validate-hooks.js
@@ -154,8 +154,17 @@ function validateHooks() {
// Support both object format { hooks: {...} } and array format
const hooks = data.hooks || data;
+ const requiresStableIds = Boolean(
+ data
+ && typeof data === 'object'
+ && !Array.isArray(data)
+ && data.hooks
+ && typeof data.hooks === 'object'
+ && !Array.isArray(data.hooks)
+ );
let hasErrors = false;
let totalMatchers = 0;
+ const matcherIdLocations = new Map();
if (typeof hooks === 'object' && !Array.isArray(hooks)) {
// Object format: { EventType: [matchers] }
@@ -179,20 +188,32 @@ function validateHooks() {
hasErrors = true;
continue;
}
+ const matcherLabel = `${eventType}[${i}]`;
+ if (requiresStableIds && !isNonEmptyString(matcher.id)) {
+ console.error(`ERROR: ${matcherLabel} missing or invalid 'id' field`);
+ hasErrors = true;
+ } else if (requiresStableIds && matcherIdLocations.has(matcher.id)) {
+ console.error(
+ `ERROR: ${matcherLabel} has duplicate id '${matcher.id}' (already used by ${matcherIdLocations.get(matcher.id)})`
+ );
+ hasErrors = true;
+ } else if (requiresStableIds) {
+ matcherIdLocations.set(matcher.id, matcherLabel);
+ }
if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) {
- console.error(`ERROR: ${eventType}[${i}] missing 'matcher' field`);
+ console.error(`ERROR: ${matcherLabel} missing 'matcher' field`);
hasErrors = true;
} else if ('matcher' in matcher && typeof matcher.matcher !== 'string' && (typeof matcher.matcher !== 'object' || matcher.matcher === null)) {
- console.error(`ERROR: ${eventType}[${i}] has invalid 'matcher' field`);
+ console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`);
hasErrors = true;
}
if (!matcher.hooks || !Array.isArray(matcher.hooks)) {
- console.error(`ERROR: ${eventType}[${i}] missing 'hooks' array`);
+ console.error(`ERROR: ${matcherLabel} missing 'hooks' array`);
hasErrors = true;
} else {
// Validate each hook entry
for (let j = 0; j < matcher.hooks.length; j++) {
- if (validateHookEntry(matcher.hooks[j], `${eventType}[${i}].hooks[${j}]`)) {
+ if (validateHookEntry(matcher.hooks[j], `${matcherLabel}.hooks[${j}]`)) {
hasErrors = true;
}
}
diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js
index c10b1cfe3..c5ece3504 100644
--- a/scripts/lib/install-lifecycle.js
+++ b/scripts/lib/install-lifecycle.js
@@ -20,6 +20,15 @@ const {
getLegacyOpencodeLocation,
inspectLegacyOpencodeState,
} = require('./install/opencode-legacy-migration');
+const {
+ acquireSettingsLock,
+ 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 +532,24 @@ function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
+function expectedClaudeSettingsPath(targetRoot) {
+ return path.join(targetRoot, 'settings.json');
+}
+
+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.');
+ }
+ if (path.resolve(operation.destinationPath) !== path.resolve(
+ expectedClaudeSettingsPath(trustedRoot)
+ )) {
+ throw new Error(
+ `Refusing to manage Claude hooks outside the canonical settings file: `
+ + `${operation.destinationPath}`
+ );
+ }
+}
+
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
const finalDestination = getManagedDestination(
@@ -689,6 +716,24 @@ function deepRemoveJsonSubset(currentValue, managedValue) {
function hydrateRecordedOperations(repoRoot, operations) {
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),
+ path.dirname(operation.destinationPath)
+ ),
+ };
+ }
+
if (operation.kind !== 'copy-file') {
return { ...operation };
}
@@ -717,7 +762,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 +822,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 +1019,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 +1086,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 +1267,48 @@ 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: 'drifted',
+ operation,
+ destinationPath
+ };
+ }
+ }
+
return {
status: 'unverified',
operation,
@@ -1154,11 +1316,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') {
@@ -1185,6 +1353,12 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations) {
);
}
+function hookRepairOperations(operationHealth) {
+ return operationHealth.drifted
+ .filter(entry => entry.operation.kind === 'update-claude-settings')
+ .map(entry => ({ ...entry.operation }));
+}
+
function getUnsafeManagedDestinationError(operationHealth) {
const hasFinalSymlink = operationHealth.unsafeDestination.some(
inspection => inspection.reason === 'final-symlink'
@@ -1467,7 +1641,8 @@ function analyzeRecord(record, context) {
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
- managedOperations
+ managedOperations,
+ record.adapter.target
);
const missingManagedOperations = operationHealth.missing;
@@ -1757,7 +1932,18 @@ function repairInstalledStates(options = {}) {
};
}
+ let releaseSettingsLock = null;
try {
+ if (
+ !options.dryRun
+ && getManagedOperations(record.state || {}).some(
+ operation => operation.kind === 'update-claude-settings'
+ )
+ ) {
+ releaseSettingsLock = acquireSettingsLock(
+ path.join(record.targetRoot, 'settings.json')
+ );
+ }
const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
@@ -1829,7 +2015,8 @@ function repairInstalledStates(options = {}) {
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
- desiredPlan.operations
+ desiredPlan.operations,
+ record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
record,
@@ -1876,7 +2063,8 @@ function repairInstalledStates(options = {}) {
const operationHealth = summarizeManagedOperationHealth(
context.repoRoot,
record.targetRoot,
- desiredPlan.operations
+ desiredPlan.operations,
+ record.adapter.target
);
const unsafeOperationResult = getUnsafeOperationResult(
@@ -1899,7 +2087,23 @@ 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 })),
+ ...hookRepairOperations({
+ drifted: desiredPlan.operations
+ .filter(operation => (
+ operation.kind === 'update-claude-settings'
+ && operation.previousManagedHooks
+ && JSON.stringify(operation.previousManagedHooks)
+ !== JSON.stringify(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 +2138,9 @@ function repairInstalledStates(options = {}) {
context.repoRoot,
operation,
record.targetRoot,
- repairLinkIndex
+ repairLinkIndex,
+ record.adapter.target,
+ Boolean(releaseSettingsLock)
);
if (repairedPath) {
repairedPaths.push(repairedPath);
@@ -1986,6 +2192,8 @@ function repairInstalledStates(options = {}) {
plannedRepairs: [],
error: error.message
};
+ } finally {
+ if (releaseSettingsLock) releaseSettingsLock();
}
});
@@ -2100,15 +2308,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(
+ path.join(record.targetRoot, 'settings.json')
+ );
+ }
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 +2369,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..805943f92 100644
--- a/scripts/lib/install-state.js
+++ b/scripts/lib/install-state.js
@@ -1,5 +1,6 @@
const fs = require('fs');
const path = require('path');
+const { validateManagedHooks } = 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 +210,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, '/') !== 'hooks/hooks.json') {
+ pushError(`${instancePath}/sourceRelativePath`, 'must equal hooks/hooks.json');
+ }
+ if (
+ isNonEmptyString(state.target && state.target.root)
+ && isNonEmptyString(operation.destinationPath)
+ ) {
+ const expectedDestination = path.resolve(state.target.root, 'settings.json');
+ 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 {
+ validateManagedHooks(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..0ff84a160 100644
--- a/scripts/lib/install-targets/claude-home.js
+++ b/scripts/lib/install-targets/claude-home.js
@@ -1,3 +1,4 @@
+const fs = require('fs');
const path = require('path');
const {
@@ -8,6 +9,39 @@ const {
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
+const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
+
+function planClaudeHooksOperations(adapter, module, input) {
+ const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
+ const operations = [
+ createRemappedOperation(
+ adapter,
+ module.id,
+ CLAUDE_HOOKS_CONFIG_PATH,
+ path.join(adapter.resolveRoot(input), 'settings.json'),
+ {
+ kind: 'update-claude-settings',
+ strategy: 'merge-hook-ids',
+ }
+ ),
+ ];
+
+ if (!input.repoRoot || !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 getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
@@ -66,7 +100,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 +115,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..4c5f23a32 100644
--- a/scripts/lib/install-targets/claude-project.js
+++ b/scripts/lib/install-targets/claude-project.js
@@ -1,3 +1,4 @@
+const fs = require('fs');
const path = require('path');
const {
@@ -8,6 +9,39 @@ const {
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
+const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
+
+function planClaudeHooksOperations(adapter, module, input) {
+ const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
+ const operations = [
+ createRemappedOperation(
+ adapter,
+ module.id,
+ CLAUDE_HOOKS_CONFIG_PATH,
+ path.join(adapter.resolveRoot(input), 'settings.json'),
+ {
+ kind: 'update-claude-settings',
+ strategy: 'merge-hook-ids',
+ }
+ ),
+ ];
+
+ if (!input.repoRoot || !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 getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
@@ -66,7 +100,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 +115,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/apply.js b/scripts/lib/install/apply.js
index e755586a8..1c5d4900d 100644
--- a/scripts/lib/install/apply.js
+++ b/scripts/lib/install/apply.js
@@ -8,8 +8,16 @@ 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 {
+ acquireSettingsLock,
+ mergeManagedHooks,
+ readSettings,
+ uninstallManagedHooks,
+ updateSettingsAtomic,
+ validateManagedHooks,
+} = require('./claude-settings');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -200,69 +208,21 @@ 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.
- let settings;
+function writeClaudeCommitAttributionPreference(settingsPath, options = {}) {
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)) {
+ let changed = false;
+ updateSettingsAtomic(settingsPath, settings => {
+ if (hasExplicitCommitAttributionPreference(settings)) {
+ return { settings };
+ }
+ changed = true;
+ return { settings: withCommitAttributionDisabled(settings) };
+ }, options);
+ return changed;
+ } catch (_error) {
+ // Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
-
- if (hasExplicitCommitAttributionPreference(settings)) {
- 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'
- ));
}
function isMcpConfigPath(filePath) {
@@ -302,40 +262,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
+ && candidate.destinationPath === 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 validateManagedHooks(
+ 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,
+ 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 +411,25 @@ 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
+ ? path.join(plan.targetRoot, 'settings.json')
+ : null;
+ if (settingsPathToLock) {
+ assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
+ }
+ const releaseSettingsLock = settingsPathToLock
+ ? acquireSettingsLock(settingsPathToLock)
+ : null;
+ try {
+ return applyInstallPlanLocked(plan, dependencies, Boolean(releaseSettingsLock));
+ } finally {
+ if (releaseSettingsLock) releaseSettingsLock();
+ }
+}
+
+function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
const persistInstallState = dependencies.writeInstallState || writeInstallState;
const beforeInstallStateRead = dependencies.beforeInstallStateRead;
const beforeOperationWrite = dependencies.beforeOperationWrite;
@@ -363,30 +437,36 @@ function applyInstallPlan(plan, dependencies = {}) {
if (typeof beforeInstallStateRead === 'function') {
beforeInstallStateRead({ plan });
}
- const migration = prepareClaudeSkillMigration(plan);
+ const migration = prepareHookConsentMigration(
+ 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;
+ 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 });
@@ -399,6 +479,42 @@ function applyInstallPlan(plan, dependencies = {}) {
beforeOperationWrite({ plan: appliedPlan, 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);
+ },
+ });
+ if (operation.kind === 'remove-claude-settings-hooks') {
+ completedHookRemovalCount += 1;
+ }
+ continue;
+ }
+
if (operation.kind === 'merge-json') {
const payload = cloneJsonValue(operation.mergePayload);
if (payload === undefined) {
@@ -450,55 +566,49 @@ function applyInstallPlan(plan, dependencies = {}) {
}
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 });
}
- 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(
+ path.join(plan.targetRoot, 'settings.json'),
+ { 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(
+ hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
+ ? migration.finalState
+ : migration.bridgeState,
+ appliedPlan
+ )
+ );
+ } 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 +638,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.js b/scripts/lib/install/claude-settings.js
new file mode 100644
index 000000000..90b0791ec
--- /dev/null
+++ b/scripts/lib/install/claude-settings.js
@@ -0,0 +1,750 @@
+'use strict';
+
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { isDeepStrictEqual } = require('util');
+const { writeFileAtomic } = require('../atomic-write');
+
+const PLUGIN_ROOT_PLACEHOLDER = '${CLAUDE_PLUGIN_ROOT}';
+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']);
+const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
+
+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 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 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')`;
+ return Object.fromEntries(
+ Object.entries(managedHooks).map(([event, entries]) => [
+ event,
+ entries.map(entry => ({
+ ...entry,
+ hooks: entry.hooks.map(hook => ({
+ ...hook,
+ ...(typeof hook.command === 'string'
+ ? {
+ command: hook.command
+ .split('var e=process.env.CLAUDE_PLUGIN_ROOT;')
+ .join(`var e=${rootExpression};`),
+ }
+ : {}),
+ })),
+ })),
+ ])
+ );
+}
+
+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 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;
+ released = true;
+ 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}`);
+ }
+ fs.rmSync(quarantinePath, { force: true });
+ };
+}
+
+function sameFileIdentity(left, right) {
+ return left.dev === right.dev && left.ino === right.ino;
+}
+
+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 updateSettingsAtomic(settingsPath, transform, options = {}) {
+ const update = () => {
+ const maxAttempts = options.maxAttempts || 3;
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ try {
+ 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 }
+ );
+ 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();
+ }
+ const releaseLock = acquireSettingsLock(settingsPath);
+ try {
+ return update();
+ } finally {
+ releaseLock();
+ }
+}
+
+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
+ : validateManagedHooks(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 = validateManagedHooks(recordedManagedHooks, 'recorded managed hooks');
+ 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 = {
+ acquireSettingsLock,
+ inspectManagedHooks,
+ materializeManagedHooks,
+ mergeManagedHooks,
+ parseSettings,
+ readSettings,
+ repairManagedHooks,
+ replacePluginRootPlaceholders,
+ updateSettingsAtomic,
+ uninstallManagedHooks,
+ validateManagedHooks,
+ validateSettings,
+};
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/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/tests/ci/validators.test.js b/tests/ci/validators.test.js
index afac4a469..a91bfe854 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,74 @@ 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 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 +2826,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/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js
new file mode 100644
index 000000000..4fadb1c11
--- /dev/null
+++ b/tests/lib/claude-settings.test.js
@@ -0,0 +1,557 @@
+/**
+ * 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 {
+ 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 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' }] }] },
+ {
+ SessionStart: [{ id: 'shared' }],
+ Stop: [{ id: 'shared' }],
+ },
+ ];
+
+ for (const invalid of invalidValues) {
+ assert.throws(() => validateManagedHooks(invalid), /managed hooks|hook entry|unique id/i);
+ }
+ })) 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++;
+
+ 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('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('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..d0acad15a 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,98 @@ 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 = {
+ ...rawPlan,
+ hookConsent: 'enabled',
+ statePreview: {
+ ...rawPlan.statePreview,
+ request: { ...rawPlan.statePreview.request, hookConsent: '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 +494,80 @@ 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 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('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..4e45c9597 100644
--- a/tests/lib/install-lifecycle.test.js
+++ b/tests/lib/install-lifecycle.test.js
@@ -23,6 +23,7 @@ const {
readInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
+const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings');
const REPO_ROOT = path.join(__dirname, '..', '..');
const CURRENT_PACKAGE_VERSION = JSON.parse(
@@ -52,6 +53,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 +105,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 +231,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 +3293,417 @@ 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('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') 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');
+ assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600);
+ assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks, managedHooks);
+ } 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'
+ || issue.code === 'invalid-install-state'
+ )));
+ 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 lifecycle refuses a non-canonical settings 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');
+ const managedHooks = currentManagedHooks(targetRoot);
+ fs.mkdirSync(targetRoot, { recursive: true });
+ assert.throws(
+ () => writeClaudeState(homeDir, {
+ operations: [
+ managedOperation('update-claude-settings', destinationPath, {
+ managedHooks,
+ }),
+ ],
+ }),
+ /canonical Claude settings path/
+ );
+ 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..ba8effaea 100644
--- a/tests/lib/install-state.test.js
+++ b/tests/lib/install-state.test.js
@@ -84,6 +84,115 @@ 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.*non-empty unique id/
+ );
+ assert.throws(
+ () => createInstallState({
+ ...baseOptions,
+ operations: [{
+ ...operation,
+ managedHooks: {
+ SessionStart: [{
+ id: 'duplicate',
+ matcher: '.*',
+ hooks: [{ type: 'command', command: 'node start.js' }],
+ }],
+ Stop: [{
+ id: 'duplicate',
+ hooks: [{ type: 'command', command: 'node stop.js' }],
+ }],
+ },
+ }],
+ }),
+ /managedHooks.*globally unique id/
+ );
+ })) 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/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..f86ea670f 100644
--- a/tests/scripts/manual-hook-install-docs.test.js
+++ b/tests/scripts/manual-hook-install-docs.test.js
@@ -47,6 +47,10 @@ function runTests() {
readme.includes('%USERPROFILE%\\\\.claude'),
'README should call out the correct Windows Claude config root'
);
+ assert.ok(
+ readme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
+ '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 +66,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(
+ hooksReadme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
+ 'hooks/README should explain that manual installs register hooks in Claude settings'
+ );
})) passed++; else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
From 26d3e0038b22e48f6eb769283d293e41270f6e85 Mon Sep 17 00:00:00 2001
From: wellkilo
Date: Mon, 7 Sep 2026 01:13:20 +0800
Subject: [PATCH 012/141] fix(install): surface Claude settings failures
---
scripts/lib/install-lifecycle.js | 25 +++++++++++---
scripts/lib/install/apply.js | 25 +++++++++-----
tests/lib/install-executor.test.js | 53 +++++++++++++++++++++++++++--
tests/lib/install-lifecycle.test.js | 48 ++++++++++++++++++++++++++
4 files changed, 136 insertions(+), 15 deletions(-)
diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js
index c5ece3504..5da0cd8b1 100644
--- a/scripts/lib/install-lifecycle.js
+++ b/scripts/lib/install-lifecycle.js
@@ -1300,11 +1300,12 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
destinationPath,
managedHookInspection: inspection
};
- } catch (_error) {
+ } catch (error) {
return {
- status: 'drifted',
+ status: 'invalid-settings',
operation,
- destinationPath
+ destinationPath,
+ error: `Failed to inspect Claude settings at ${destinationPath}: ${error.message}`
};
}
}
@@ -1337,6 +1338,8 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, targ
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);
}
@@ -1348,6 +1351,7 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, targ
missingSource: [],
unsafeSource: [],
unsafeDestination: [],
+ invalidSettings: [],
unverified: []
}
);
@@ -1374,7 +1378,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;
}
@@ -1666,6 +1672,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`, {
diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js
index 1c5d4900d..015b1e128 100644
--- a/scripts/lib/install/apply.js
+++ b/scripts/lib/install/apply.js
@@ -209,20 +209,27 @@ function shouldSetClaudeCommitAttributionPreference(plan) {
}
function writeClaudeCommitAttributionPreference(settingsPath, options = {}) {
+ let settings;
try {
- let changed = false;
- updateSettingsAtomic(settingsPath, settings => {
- if (hasExplicitCommitAttributionPreference(settings)) {
- return { settings };
- }
- changed = true;
- return { settings: withCommitAttributionDisabled(settings) };
- }, options);
- return changed;
+ settings = readSettings(settingsPath);
} catch (_error) {
// Unreadable or malformed settings belong to the user; leave them untouched.
return false;
}
+
+ if (hasExplicitCommitAttributionPreference(settings)) {
+ return false;
+ }
+
+ let changed = false;
+ updateSettingsAtomic(settingsPath, latestSettings => {
+ if (hasExplicitCommitAttributionPreference(latestSettings)) {
+ return { settings: latestSettings };
+ }
+ changed = true;
+ return { settings: withCommitAttributionDisabled(latestSettings) };
+ }, options);
+ return changed;
}
function isMcpConfigPath(filePath) {
diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js
index d0acad15a..0f9c656f6 100644
--- a/tests/lib/install-executor.test.js
+++ b/tests/lib/install-executor.test.js
@@ -498,8 +498,9 @@ function runTests() {
const tempDir = createTempDir('install-executor-claude-hooks-');
try {
for (const target of ['claude', 'claude-project']) {
- const homeDir = path.join(tempDir, `${target} home "quoted" $dollar %percent%`);
- const projectRoot = path.join(tempDir, `${target} project "quoted" $dollar %percent%`);
+ 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 });
@@ -568,6 +569,54 @@ function runTests() {
}
})) 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 = {
+ ...rawPlan,
+ hookConsent: 'enabled',
+ statePreview: {
+ ...rawPlan.statePreview,
+ request: { ...rawPlan.statePreview.request, hookConsent: '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 4e45c9597..b086ef2bf 100644
--- a/tests/lib/install-lifecycle.test.js
+++ b/tests/lib/install-lifecycle.test.js
@@ -3372,6 +3372,54 @@ function runTests() {
}
})) 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-');
From f59cfd57c26c65eeaba37ffdb95cd6abd055ee86 Mon Sep 17 00:00:00 2001
From: wellkilo
Date: Mon, 7 Sep 2026 01:57:04 +0800
Subject: [PATCH 013/141] fix(install): harden Claude settings lifecycle
---
README.md | 2 +-
hooks/README.md | 2 +-
schemas/hooks.schema.json | 28 ++-
schemas/install-state.schema.json | 22 +-
scripts/ci/validate-hooks.js | 2 +-
scripts/lib/install-lifecycle.js | 62 ++---
scripts/lib/install-state.js | 12 +-
scripts/lib/install-targets/claude-home.js | 35 +--
scripts/lib/install-targets/claude-project.js | 35 +--
scripts/lib/install-targets/helpers.js | 41 ++++
scripts/lib/install/apply.js | 26 +--
scripts/lib/install/claude-settings-lock.js | 170 ++++++++++++++
scripts/lib/install/claude-settings.js | 214 ++++++------------
tests/ci/validators.test.js | 31 +++
tests/lib/claude-settings.test.js | 122 +++++++++-
tests/lib/install-executor.test.js | 18 +-
tests/lib/install-lifecycle.test.js | 24 +-
tests/lib/install-state.test.js | 32 +--
.../scripts/manual-hook-install-docs.test.js | 12 +-
19 files changed, 540 insertions(+), 350 deletions(-)
create mode 100644 scripts/lib/install/claude-settings-lock.js
diff --git a/README.md b/README.md
index 2431bd418..091836e33 100644
--- a/README.md
+++ b/README.md
@@ -562,7 +562,7 @@ 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/hooks/README.md b/hooks/README.md
index e540b2d24..144bc89ad 100644
--- a/hooks/README.md
+++ b/hooks/README.md
@@ -37,7 +37,7 @@ 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`.
+`%USERPROFILE%\.claude`.
### PreToolUse Hooks
diff --git a/schemas/hooks.schema.json b/schemas/hooks.schema.json
index c325d9712..e3d339f77 100644
--- a/schemas/hooks.schema.json
+++ b/schemas/hooks.schema.json
@@ -147,6 +147,24 @@
"type": "string"
}
}
+ },
+ "managedMatcherEntry": {
+ "allOf": [
+ { "$ref": "#/$defs/matcherEntry" },
+ {
+ "type": "object",
+ "required": ["id"],
+ "properties": {
+ "hooks": { "type": "array", "minItems": 1 }
+ }
+ }
+ ]
+ },
+ "managedMatcherRequiredEntry": {
+ "allOf": [
+ { "$ref": "#/$defs/managedMatcherEntry" },
+ { "type": "object", "required": ["matcher"] }
+ ]
}
},
"oneOf": [
@@ -180,10 +198,18 @@
"SessionEnd"
]
},
+ "patternProperties": {
+ "^(SessionStart|PreToolUse|PermissionRequest|PostToolUse|PostToolUseFailure|SubagentStart|PreCompact|InstructionsLoaded|TeammateIdle|TaskCompleted|ConfigChange|WorktreeCreate|WorktreeRemove|SessionEnd)$": {
+ "type": "array",
+ "items": {
+ "$ref": "#/$defs/managedMatcherRequiredEntry"
+ }
+ }
+ },
"additionalProperties": {
"type": "array",
"items": {
- "$ref": "#/$defs/matcherEntry"
+ "$ref": "#/$defs/managedMatcherEntry"
}
}
}
diff --git a/schemas/install-state.schema.json b/schemas/install-state.schema.json
index 9b827e144..b7e48def5 100644
--- a/schemas/install-state.schema.json
+++ b/schemas/install-state.schema.json
@@ -218,26 +218,8 @@
"type": "object",
"minProperties": 1,
"propertyNames": {
- "enum": [
- "SessionStart",
- "UserPromptSubmit",
- "PreToolUse",
- "PermissionRequest",
- "PostToolUse",
- "PostToolUseFailure",
- "Notification",
- "SubagentStart",
- "Stop",
- "SubagentStop",
- "PreCompact",
- "InstructionsLoaded",
- "TeammateIdle",
- "TaskCompleted",
- "ConfigChange",
- "WorktreeCreate",
- "WorktreeRemove",
- "SessionEnd"
- ]
+ "type": "string",
+ "pattern": "\\S"
},
"additionalProperties": {
"type": "array",
diff --git a/scripts/ci/validate-hooks.js b/scripts/ci/validate-hooks.js
index 779555a44..d59807f1d 100644
--- a/scripts/ci/validate-hooks.js
+++ b/scripts/ci/validate-hooks.js
@@ -207,7 +207,7 @@ function validateHooks() {
console.error(`ERROR: ${matcherLabel} has invalid 'matcher' field`);
hasErrors = true;
}
- if (!matcher.hooks || !Array.isArray(matcher.hooks)) {
+ if (!matcher.hooks || !Array.isArray(matcher.hooks) || matcher.hooks.length === 0) {
console.error(`ERROR: ${matcherLabel} missing 'hooks' array`);
hasErrors = true;
} else {
diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js
index 5da0cd8b1..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');
@@ -22,6 +23,8 @@ const {
} = require('./install/opencode-legacy-migration');
const {
acquireSettingsLock,
+ assertClaudeSettingsPath,
+ getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
repairManagedHooks,
@@ -532,22 +535,11 @@ function readJsonNoFollow(filePath) {
return JSON.parse(readFileNoFollow(filePath, 'utf8'));
}
-function expectedClaudeSettingsPath(targetRoot) {
- return path.join(targetRoot, 'settings.json');
-}
-
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.');
}
- if (path.resolve(operation.destinationPath) !== path.resolve(
- expectedClaudeSettingsPath(trustedRoot)
- )) {
- throw new Error(
- `Refusing to manage Claude hooks outside the canonical settings file: `
- + `${operation.destinationPath}`
- );
- }
+ assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
}
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
@@ -714,7 +706,7 @@ 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);
@@ -729,7 +721,7 @@ function hydrateRecordedOperations(repoRoot, operations) {
previousManagedHooks: operation.managedHooks,
managedHooks: materializeManagedHooks(
readJsonNoFollow(sourcePath),
- path.dirname(operation.destinationPath)
+ trustedRoot
),
};
}
@@ -1357,12 +1349,6 @@ function summarizeManagedOperationHealth(repoRoot, trustedRoot, operations, targ
);
}
-function hookRepairOperations(operationHealth) {
- return operationHealth.drifted
- .filter(entry => entry.operation.kind === 'update-claude-settings')
- .map(entry => ({ ...entry.operation }));
-}
-
function getUnsafeManagedDestinationError(operationHealth) {
const hasFinalSymlink = operationHealth.unsafeDestination.some(
inspection => inspection.reason === 'final-symlink'
@@ -1806,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 {
@@ -1951,15 +1941,14 @@ function repairInstalledStates(options = {}) {
let releaseSettingsLock = null;
try {
- if (
- !options.dryRun
+ const settingsPathToLock = !options.dryRun
&& getManagedOperations(record.state || {}).some(
operation => operation.kind === 'update-claude-settings'
)
- ) {
- releaseSettingsLock = acquireSettingsLock(
- path.join(record.targetRoot, 'settings.json')
- );
+ ? getClaudeSettingsPath(record.targetRoot)
+ : null;
+ if (settingsPathToLock) {
+ releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
}
const needsOpencodeBuild = record.adapter.target === 'opencode'
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
@@ -2107,16 +2096,13 @@ function repairInstalledStates(options = {}) {
const repairOperations = [
...operationHealth.missing.map(entry => ({ ...entry.operation })),
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
- ...hookRepairOperations({
- drifted: desiredPlan.operations
- .filter(operation => (
- operation.kind === 'update-claude-settings'
- && operation.previousManagedHooks
- && JSON.stringify(operation.previousManagedHooks)
- !== JSON.stringify(operation.managedHooks)
- ))
- .map(operation => ({ 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
@@ -2333,7 +2319,7 @@ function uninstallInstalledStates(options = {}) {
const operations = getManagedOperations(state);
if (operations.some(operation => operation.kind === 'update-claude-settings')) {
releaseSettingsLock = acquireSettingsLock(
- path.join(record.targetRoot, 'settings.json')
+ getClaudeSettingsPath(record.targetRoot)
);
}
diff --git a/scripts/lib/install-state.js b/scripts/lib/install-state.js
index 805943f92..3b5b7fc23 100644
--- a/scripts/lib/install-state.js
+++ b/scripts/lib/install-state.js
@@ -1,6 +1,10 @@
const fs = require('fs');
const path = require('path');
-const { validateManagedHooks } = require('./install/claude-settings');
+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
@@ -217,14 +221,14 @@ function createFallbackValidator() {
if (operation.moduleId !== 'hooks-runtime') {
pushError(`${instancePath}/moduleId`, 'must equal hooks-runtime');
}
- if (String(operation.sourceRelativePath).replace(/\\/g, '/') !== 'hooks/hooks.json') {
+ 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(state.target.root, 'settings.json');
+ const expectedDestination = path.resolve(getClaudeSettingsPath(state.target.root));
const actualDestination = path.resolve(operation.destinationPath);
const pathsMatch = process.platform === 'win32'
? expectedDestination.toLowerCase() === actualDestination.toLowerCase()
@@ -237,7 +241,7 @@ function createFallbackValidator() {
}
}
try {
- validateManagedHooks(operation.managedHooks);
+ 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 0ff84a160..5cc426ac9 100644
--- a/scripts/lib/install-targets/claude-home.js
+++ b/scripts/lib/install-targets/claude-home.js
@@ -1,4 +1,3 @@
-const fs = require('fs');
const path = require('path');
const {
@@ -6,42 +5,10 @@ const {
createRemappedOperation,
isForeignPlatformPath,
normalizeRelativePath,
+ planClaudeHooksOperations,
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
-const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
-
-function planClaudeHooksOperations(adapter, module, input) {
- const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
- const operations = [
- createRemappedOperation(
- adapter,
- module.id,
- CLAUDE_HOOKS_CONFIG_PATH,
- path.join(adapter.resolveRoot(input), 'settings.json'),
- {
- kind: 'update-claude-settings',
- strategy: 'merge-hook-ids',
- }
- ),
- ];
-
- if (!input.repoRoot || !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 getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
diff --git a/scripts/lib/install-targets/claude-project.js b/scripts/lib/install-targets/claude-project.js
index 4c5f23a32..a4fda3970 100644
--- a/scripts/lib/install-targets/claude-project.js
+++ b/scripts/lib/install-targets/claude-project.js
@@ -1,4 +1,3 @@
-const fs = require('fs');
const path = require('path');
const {
@@ -6,42 +5,10 @@ const {
createRemappedOperation,
isForeignPlatformPath,
normalizeRelativePath,
+ planClaudeHooksOperations,
} = require('./helpers');
const CLAUDE_ECC_NAMESPACE = 'ecc';
-const CLAUDE_HOOKS_CONFIG_PATH = 'hooks/hooks.json';
-
-function planClaudeHooksOperations(adapter, module, input) {
- const sourceHooksRoot = path.join(input.repoRoot || '', 'hooks');
- const operations = [
- createRemappedOperation(
- adapter,
- module.id,
- CLAUDE_HOOKS_CONFIG_PATH,
- path.join(adapter.resolveRoot(input), 'settings.json'),
- {
- kind: 'update-claude-settings',
- strategy: 'merge-hook-ids',
- }
- ),
- ];
-
- if (!input.repoRoot || !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 getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
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 015b1e128..8a726883e 100644
--- a/scripts/lib/install/apply.js
+++ b/scripts/lib/install/apply.js
@@ -11,12 +11,14 @@ const {
const { readInstallState, writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const {
- acquireSettingsLock,
+ getClaudeSettingsPath,
mergeManagedHooks,
readSettings,
+ runWithSettingsLock,
uninstallManagedHooks,
updateSettingsAtomic,
validateManagedHooks,
+ validateRecordedManagedHooks,
} = require('./claude-settings');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
@@ -293,13 +295,13 @@ function findPreviousManagedHooks(previousState, plan, operation) {
const previousOperation = (previousState.operations || []).find(candidate => (
candidate.kind === operation.kind
- && candidate.destinationPath === operation.destinationPath
+ && comparablePath(candidate.destinationPath) === comparablePath(operation.destinationPath)
));
if (!previousOperation || !previousOperation.managedHooks) {
return null;
}
- return validateManagedHooks(
+ return validateRecordedManagedHooks(
previousOperation.managedHooks,
'previous managed hooks'
);
@@ -421,19 +423,17 @@ function applyInstallPlan(plan, dependencies = {}) {
const isClaudeManualTarget = plan.adapter
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
const settingsPathToLock = isClaudeManualTarget
- ? path.join(plan.targetRoot, 'settings.json')
+ ? getClaudeSettingsPath(plan.targetRoot)
: null;
if (settingsPathToLock) {
assertSafeInstallOperation(plan, { destinationPath: settingsPathToLock });
}
- const releaseSettingsLock = settingsPathToLock
- ? acquireSettingsLock(settingsPathToLock)
- : null;
- try {
- return applyInstallPlanLocked(plan, dependencies, Boolean(releaseSettingsLock));
- } finally {
- if (releaseSettingsLock) releaseSettingsLock();
- }
+ return settingsPathToLock
+ ? runWithSettingsLock(
+ settingsPathToLock,
+ () => applyInstallPlanLocked(plan, dependencies, true)
+ )
+ : applyInstallPlanLocked(plan, dependencies, false);
}
function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = false) {
@@ -581,7 +581,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (shouldSetClaudeCommitAttributionPreference(appliedPlan)) {
writeClaudeCommitAttributionPreference(
- path.join(plan.targetRoot, 'settings.json'),
+ getClaudeSettingsPath(plan.targetRoot),
{ lockHeld: settingsLockHeld }
);
}
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
index 90b0791ec..dd34dd6fb 100644
--- a/scripts/lib/install/claude-settings.js
+++ b/scripts/lib/install/claude-settings.js
@@ -1,12 +1,16 @@
'use strict';
-const crypto = require('crypto');
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',
@@ -18,7 +22,6 @@ const EVENTS_WITHOUT_MATCHER = new Set([
'UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop',
]);
const VALID_HOOK_TYPES = new Set(['command', 'http', 'prompt', 'agent']);
-const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
function isJsonObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -44,6 +47,23 @@ 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`);
@@ -167,6 +187,30 @@ function validateManagedHooks(managedHooks, label = 'managed hooks') {
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`);
@@ -210,6 +254,18 @@ function replacePluginRootPlaceholders(value, pluginRoot) {
function resolveManagedHookCommands(managedHooks, targetRoot) {
const encodedRoot = Buffer.from(targetRoot, 'utf8').toString('base64');
const rootExpression = `Buffer.from('${encodedRoot}','base64').toString('utf8')`;
+ const resolveCommand = 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,
@@ -219,9 +275,7 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
...hook,
...(typeof hook.command === 'string'
? {
- command: hook.command
- .split('var e=process.env.CLAUDE_PLUGIN_ROOT;')
- .join(`var e=${rootExpression};`),
+ command: resolveCommand(hook.command),
}
: {}),
})),
@@ -333,139 +387,6 @@ function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
}
}
-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;
- released = true;
- 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}`);
- }
- fs.rmSync(quarantinePath, { force: true });
- };
-}
-
-function sameFileIdentity(left, right) {
- return left.dev === right.dev && left.ino === right.ino;
-}
-
-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 updateSettingsAtomic(settingsPath, transform, options = {}) {
const update = () => {
const maxAttempts = options.maxAttempts || 3;
@@ -492,12 +413,7 @@ function updateSettingsAtomic(settingsPath, transform, options = {}) {
if (options.lockHeld) {
return update();
}
- const releaseLock = acquireSettingsLock(settingsPath);
- try {
- return update();
- } finally {
- releaseLock();
- }
+ return runWithSettingsLock(settingsPath, update);
}
function reference(event, id) {
@@ -534,7 +450,7 @@ function mergeManagedHooks(settings, managedHooks, options = {}) {
const previousHooks = options.previousManagedHooks === undefined
|| options.previousManagedHooks === null
? null
- : validateManagedHooks(options.previousManagedHooks, 'previous managed hooks');
+ : 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}`);
@@ -677,7 +593,7 @@ function withoutProperty(object, omittedKey) {
function uninstallManagedHooks(settings, recordedManagedHooks) {
const validatedSettings = validateSettings(settings);
- const recordedHooks = validateManagedHooks(recordedManagedHooks, 'recorded managed hooks');
+ const recordedHooks = validateRecordedManagedHooks(recordedManagedHooks);
const currentHooks = validatedSettings.hooks || {};
for (const [event, recordedEntries] of Object.entries(recordedHooks)) {
@@ -735,7 +651,11 @@ function uninstallManagedHooks(settings, recordedManagedHooks) {
}
module.exports = {
+ CLAUDE_HOOKS_CONFIG_PATH,
+ CLAUDE_SETTINGS_FILENAME,
acquireSettingsLock,
+ assertClaudeSettingsPath,
+ getClaudeSettingsPath,
inspectManagedHooks,
materializeManagedHooks,
mergeManagedHooks,
@@ -743,8 +663,10 @@ module.exports = {
readSettings,
repairManagedHooks,
replacePluginRootPlaceholders,
+ runWithSettingsLock,
updateSettingsAtomic,
uninstallManagedHooks,
validateManagedHooks,
+ validateRecordedManagedHooks,
validateSettings,
};
diff --git a/tests/ci/validators.test.js b/tests/ci/validators.test.js
index a91bfe854..8f0a92eab 100644
--- a/tests/ci/validators.test.js
+++ b/tests/ci/validators.test.js
@@ -2694,6 +2694,37 @@ function runTests() {
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');
diff --git a/tests/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js
index 4fadb1c11..c31f191f9 100644
--- a/tests/lib/claude-settings.test.js
+++ b/tests/lib/claude-settings.test.js
@@ -10,6 +10,8 @@ const os = require('os');
const path = require('path');
const {
+ runWithSettingsLock,
+ materializeManagedHooks,
inspectManagedHooks,
mergeManagedHooks,
parseSettings,
@@ -78,15 +80,49 @@ function runTests() {
{ BogusEvent: [entry('bad:event', 'bad')] },
{ SessionStart: [{ id: 'missing:hooks', matcher: '.*' }] },
{ SessionStart: [{ id: 'bad:command', matcher: '.*', hooks: [{ type: 'command' }] }] },
- {
- SessionStart: [{ id: 'shared' }],
- Stop: [{ id: 'shared' }],
- },
];
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', () => {
@@ -234,6 +270,71 @@ function runTests() {
}
})) 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 = {
@@ -540,6 +641,19 @@ function runTests() {
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')] };
diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js
index 0f9c656f6..6f64b540c 100644
--- a/tests/lib/install-executor.test.js
+++ b/tests/lib/install-executor.test.js
@@ -182,14 +182,7 @@ function runTests() {
target: 'claude',
moduleIds: ['hooks-runtime'],
});
- const plan = {
- ...rawPlan,
- hookConsent: 'enabled',
- statePreview: {
- ...rawPlan.statePreview,
- request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
- },
- };
+ const plan = withHookConsent(rawPlan, 'enabled');
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
applyInstallPlanDirect(plan, {
@@ -584,14 +577,7 @@ function runTests() {
target: 'claude',
moduleIds: ['hooks-runtime'],
});
- const plan = {
- ...rawPlan,
- hookConsent: 'enabled',
- statePreview: {
- ...rawPlan.statePreview,
- request: { ...rawPlan.statePreview.request, hookConsent: 'enabled' },
- },
- };
+ const plan = withHookConsent(rawPlan, 'enabled');
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
let settingsCommitCount = 0;
fs.renameSync = function failAttributionCommit(sourcePath, destinationPath) {
diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js
index b086ef2bf..fef01bd8a 100644
--- a/tests/lib/install-lifecycle.test.js
+++ b/tests/lib/install-lifecycle.test.js
@@ -23,7 +23,10 @@ const {
readInstallState,
writeInstallState,
} = require('../../scripts/lib/install-state');
-const { materializeManagedHooks } = require('../../scripts/lib/install/claude-settings');
+const {
+ assertClaudeSettingsPath,
+ materializeManagedHooks,
+} = require('../../scripts/lib/install/claude-settings');
const REPO_ROOT = path.join(__dirname, '..', '..');
const CURRENT_PACKAGE_VERSION = JSON.parse(
@@ -3479,7 +3482,10 @@ function runTests() {
})) passed++; else failed++;
if (test('repair creates missing Claude settings with private permissions', () => {
- if (process.platform === 'win32') return;
+ 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-');
@@ -3710,7 +3716,6 @@ function runTests() {
assert.strictEqual(doctor.results[0].status, 'error');
assert.ok(doctor.results[0].issues.some(issue => (
issue.code === 'unsafe-managed-destination'
- || issue.code === 'invalid-install-state'
)));
assert.strictEqual(repair.results[0].status, 'error');
assert.match(repair.results[0].error, /final symlink/);
@@ -3726,24 +3731,17 @@ function runTests() {
}
})) passed++; else failed++;
- if (test('Claude settings lifecycle refuses a non-canonical settings destination', () => {
+ 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');
- const managedHooks = currentManagedHooks(targetRoot);
fs.mkdirSync(targetRoot, { recursive: true });
assert.throws(
- () => writeClaudeState(homeDir, {
- operations: [
- managedOperation('update-claude-settings', destinationPath, {
- managedHooks,
- }),
- ],
- }),
- /canonical Claude settings path/
+ () => assertClaudeSettingsPath(destinationPath, targetRoot),
+ /outside the canonical settings file/
);
assert.ok(!fs.existsSync(destinationPath));
} finally {
diff --git a/tests/lib/install-state.test.js b/tests/lib/install-state.test.js
index ba8effaea..653a6bb10 100644
--- a/tests/lib/install-state.test.js
+++ b/tests/lib/install-state.test.js
@@ -169,28 +169,18 @@ function runTests() {
},
}],
}),
- /managedHooks.*non-empty unique id/
- );
- assert.throws(
- () => createInstallState({
- ...baseOptions,
- operations: [{
- ...operation,
- managedHooks: {
- SessionStart: [{
- id: 'duplicate',
- matcher: '.*',
- hooks: [{ type: 'command', command: 'node start.js' }],
- }],
- Stop: [{
- id: 'duplicate',
- hooks: [{ type: 'command', command: 'node stop.js' }],
- }],
- },
- }],
- }),
- /managedHooks.*globally unique id/
+ /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', () => {
diff --git a/tests/scripts/manual-hook-install-docs.test.js b/tests/scripts/manual-hook-install-docs.test.js
index f86ea670f..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,11 +50,11 @@ 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(
- readme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
+ normalizeWhitespace(readme).includes(HOOK_REGISTRATION_PHRASE),
'README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;
@@ -67,7 +73,7 @@ function runTests() {
'hooks/README should document the supported PowerShell hook install path'
);
assert.ok(
- hooksReadme.includes('registers the resolved\nhook entries in `~/.claude/settings.json`'),
+ normalizeWhitespace(hooksReadme).includes(HOOK_REGISTRATION_PHRASE),
'hooks/README should explain that manual installs register hooks in Claude settings'
);
})) passed++; else failed++;
From bf0ac4e4b382517a52969380eada34072257345c Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:23:30 -0400
Subject: [PATCH 014/141] fix: reject late PowerShell scalar resolution
---
scripts/lib/powershell-destructive-command.js | 16 +++++++++++++++-
tests/hooks/gateguard-fact-force.test.js | 3 ++-
tests/hooks/governance-capture.test.js | 4 ++++
tests/lib/powershell-destructive-command.test.js | 16 ++++++++++++++++
4 files changed, 37 insertions(+), 2 deletions(-)
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index f99216016..77f3ac095 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -1164,6 +1164,13 @@ function createScanState() {
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;
@@ -1177,11 +1184,18 @@ function collectStaticScalarAssignments(input, state) {
);
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(match[1].toLowerCase(), value);
+ state.staticScalars.set(name, value);
}
for (const [name, count] of assignmentCounts) {
if (count !== 1) state.staticScalars.delete(name);
diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index 3735db1d9..cb173c2b4 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2991,7 +2991,8 @@ function runTests() {
'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'
+ 'Invoke-Expression $runtimeValue',
+ 'pwsh -Command "$payload"; $payload = "Write-Output ok"'
];
for (const command of commands) {
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index 9ee30fed3..c7cc46c52 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -251,6 +251,10 @@ async function runTests() {
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'],
diff --git a/tests/lib/powershell-destructive-command.test.js b/tests/lib/powershell-destructive-command.test.js
index 569cbb18d..7a4ae31cf 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -190,6 +190,22 @@ test('classifies pipeline recursion evidence upstream of Remove-Item', () => {
console.log('\nNested shell payloads:');
+test('does not resolve earlier invocations from later scalar assignments', () => {
+ for (const invocation of [
+ 'pwsh -Command "$payload"',
+ 'pwsh -Command:$payload',
+ 'pwsh -EncodedCommand:$payload',
+ 'Invoke-Expression $payload',
+ '& $payload',
+ ]) {
+ expectRules(`${invocation}; $payload = 'Write-Output ok'`, [RULES.DYNAMIC_EXECUTION]);
+ }
+ expectRules('pwsh -Command "$payload"; $payload = "Remove-Item -Force C:/tmp/demo"', [
+ RULES.DYNAMIC_EXECUTION,
+ ]);
+ expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"');
+});
+
test('classifies powershell and pwsh command payloads recursively', () => {
expectRules(
'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',
From 20b1ba423e89c5f6adcf065e3aa543d72286f098 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:24:54 -0400
Subject: [PATCH 015/141] fix(hooks): preserve complete bounded passthrough
payloads
Forward-port #2925 for #2924 and verify ASCII and multibyte over-limit input suppression. Supersedes the overlapping direct-entrypoint fix in #2978.
Co-authored-by: jackie-cqz <2557911191@qq.com>
---
scripts/hooks/check-console-log.js | 28 +--
scripts/hooks/doc-file-warning.js | 22 ++-
scripts/hooks/post-edit-console-warn.js | 18 +-
scripts/hooks/post-edit-format.js | 21 ++-
scripts/hooks/post-edit-typecheck.js | 25 ++-
tests/hooks/hooks.test.js | 69 ++++----
tests/hooks/passthrough-large-stdin.test.js | 178 ++++++++++++++++++++
tests/hooks/stop-hooks-stdout.test.js | 12 +-
tests/integration/hooks.test.js | 4 +-
9 files changed, 299 insertions(+), 78 deletions(-)
create mode 100644 tests/hooks/passthrough-large-stdin.test.js
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/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/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js
index ce3411b15..32c99a5c9 100644
--- a/tests/hooks/hooks.test.js
+++ b/tests/hooks/hooks.test.js
@@ -4310,9 +4310,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 +4325,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 +4338,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 +4353,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 +5454,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 +5555,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 +6077,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/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/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
From f2bcc00d69106b39bfb06ba84dc92ebdb734fc9c Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:24:54 -0400
Subject: [PATCH 016/141] fix(pi): prevent recursive compiled OMP hook
execution
Forward-port #2911 for #2909 and exercise the real adapter lifecycle with a recorded process boundary, including unavailable Node and invalid overrides.
Co-authored-by: DavidHLP
---
.pi/README.md | 12 ++-
.pi/extensions/hook-runtime.js | 35 ++++++++
.pi/extensions/index.ts | 29 +++++--
tests/pi/pi-extension-adapter.test.js | 104 ++++++++++++++++++++--
tests/pi/pi-hook-runtime.test.js | 120 ++++++++++++++++++++++++++
5 files changed, 286 insertions(+), 14 deletions(-)
create mode 100644 .pi/extensions/hook-runtime.js
create mode 100644 tests/pi/pi-hook-runtime.test.js
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/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; });
From ce11e8f690d3a1a4ef9e8977436cdd52003483e1 Mon Sep 17 00:00:00 2001
From: wakqasahmed
Date: Sun, 6 Sep 2026 20:09:27 +0200
Subject: [PATCH 017/141] fix(install): ship ajv/sql.js with the plugin install
bundle (#2822)
install-plan.js and install-apply.js both require ./lib/install/config at
load time, and that module required ajv unconditionally at the top of the
file even though ajv is only actually used when validating an
ecc-install.json. When ECC is installed via the Claude Code plugin
marketplace, the marketplace directory is a bare git clone with no
node_modules, so requiring ajv crashes commands like --list-profiles that
never touch install-config validation at all.
Same root cause in scripts/lib/control-pane/state.js: sql.js and
@iarna/toml were required at module scope even though they are only used
inside openSqlDatabase() and readTomlConfig(), so control-pane.js --help
crashed too.
Make both requires lazy so they only load when the feature that actually
needs them runs. For the case where ajv/sql.js/js-yaml/@iarna-toml is
genuinely needed and still missing, add a small helper that turns the raw
MODULE_NOT_FOUND into an actionable message naming the package and the
install command, instead of a stack trace (install-apply.js) or, worse, an
unhandled crash with a usage banner tacked on that reads like a bad
argument (install-plan.js, control-pane.js). Applied the same helper to
memory-mcp.mjs, where ajv is genuinely load-bearing (it compiles every MCP
tool's JSON schema up front) so it can't be made lazy the same way.
Added a regression test that copies just scripts/, schemas/, and
manifests/ into a directory with no node_modules anywhere above it in the
filesystem, which reproduces the plugin-marketplace install exactly, and
asserts install-plan.js and control-pane.js still work.
---
scripts/control-pane.js | 3 +-
scripts/install-apply.js | 8 +-
scripts/install-plan.js | 3 +-
scripts/lib/control-pane/state.js | 11 +-
scripts/lib/install/config.js | 4 +-
scripts/lib/missing-dependency.js | 38 +++++++
scripts/memory-mcp.mjs | 11 +-
tests/lib/missing-dependency.test.js | 70 ++++++++++++
...lugin-install-without-node-modules.test.js | 106 ++++++++++++++++++
9 files changed, 246 insertions(+), 8 deletions(-)
create mode 100644 scripts/lib/missing-dependency.js
create mode 100644 tests/lib/missing-dependency.test.js
create mode 100644 tests/scripts/plugin-install-without-node-modules.test.js
diff --git a/scripts/control-pane.js b/scripts/control-pane.js
index 790f2a681..c5b7215f4 100755
--- a/scripts/control-pane.js
+++ b/scripts/control-pane.js
@@ -8,6 +8,7 @@ const {
parseArgs,
usage,
} = require('./lib/control-pane/server');
+const { describeMissingDependencyError } = require('./lib/missing-dependency');
function openBrowser(url) {
if (process.platform !== 'darwin') return;
@@ -55,7 +56,7 @@ async function main(argv = process.argv) {
if (require.main === module) {
main().catch(error => {
- console.error(`[control-pane] ${error.message}`);
+ console.error(`[control-pane] ${describeMissingDependencyError(error) || error.message}`);
process.exit(1);
});
}
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/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/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/missing-dependency.js b/scripts/lib/missing-dependency.js
new file mode 100644
index 000000000..7292cd9d5
--- /dev/null
+++ b/scripts/lib/missing-dependency.js
@@ -0,0 +1,38 @@
+'use strict';
+
+// Production dependencies declared in package.json's "dependencies" field.
+// `npm install` never runs when ECC is installed via the Claude Code plugin
+// marketplace (a plain git clone), so these can be missing at runtime even
+// though the code that needs them is fine.
+const RUNTIME_DEPENDENCY_VERSIONS = {
+ ajv: '8.20.0',
+ 'sql.js': '1.14.2',
+ 'js-yaml': '4.3.1',
+ '@iarna/toml': '2.2.5',
+};
+
+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];
+ const pinnedVersion = moduleName && RUNTIME_DEPENDENCY_VERSIONS[moduleName];
+
+ if (!pinnedVersion) {
+ return null;
+ }
+
+ 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 "npm install --no-save ${moduleName}@${pinnedVersion}".`
+ );
+}
+
+module.exports = {
+ RUNTIME_DEPENDENCY_VERSIONS,
+ describeMissingDependencyError,
+};
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/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/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js
new file mode 100644
index 000000000..b89ab74e2
--- /dev/null
+++ b/tests/scripts/plugin-install-without-node-modules.test.js
@@ -0,0 +1,106 @@
+/**
+ * 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']) {
+ fs.cpSync(path.join(REPO_ROOT, entry), path.join(destDir, entry), { recursive: true });
+ }
+}
+
+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 {
+ 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();
From fe3d82e280ee6faa5234417b5eab0a28b4027ec0 Mon Sep 17 00:00:00 2001
From: wakqasahmed
Date: Mon, 7 Sep 2026 10:30:24 +0200
Subject: [PATCH 018/141] fix(install): derive dependency versions from
package.json, harden fixture isolation (#2822)
Addresses Greptile's review on #2994:
- missing-dependency.js no longer hardcodes a second copy of the four
runtime dependency versions; it reads them from package.json's
dependencies field instead, so the two can't silently drift apart.
describeMissingDependencyError() still recognizes a tracked
dependency even if package.json can't be read for some reason,
just without a version-pinned install command in that case.
- The regression test now asserts no ancestor directory of its
temp fixture has a node_modules, so a stray one wouldn't let
Node resolve ajv/sql.js from there and mask what the test is
actually meant to exercise. Also copies package.json into the
fixture, matching a real plugin-marketplace git clone and what
the version-lookup above now needs.
---
scripts/lib/missing-dependency.js | 53 ++++++++++++++-----
...lugin-install-without-node-modules.test.js | 26 ++++++++-
2 files changed, 65 insertions(+), 14 deletions(-)
diff --git a/scripts/lib/missing-dependency.js b/scripts/lib/missing-dependency.js
index 7292cd9d5..d75334714 100644
--- a/scripts/lib/missing-dependency.js
+++ b/scripts/lib/missing-dependency.js
@@ -1,15 +1,38 @@
'use strict';
-// Production dependencies declared in package.json's "dependencies" field.
-// `npm install` never runs when ECC is installed via the Claude Code plugin
-// marketplace (a plain git clone), so these can be missing at runtime even
-// though the code that needs them is fine.
-const RUNTIME_DEPENDENCY_VERSIONS = {
- ajv: '8.20.0',
- 'sql.js': '1.14.2',
- 'js-yaml': '4.3.1',
- '@iarna/toml': '2.2.5',
-};
+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') {
@@ -18,17 +41,21 @@ function describeMissingDependencyError(error) {
const match = /Cannot find module '([^']+)'/.exec(error.message || '');
const moduleName = match && match[1];
- const pinnedVersion = moduleName && RUNTIME_DEPENDENCY_VERSIONS[moduleName];
- if (!pinnedVersion) {
+ 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 "npm install --no-save ${moduleName}@${pinnedVersion}".`
+ `root, or install just this package with "${installCommand}".`
);
}
diff --git a/tests/scripts/plugin-install-without-node-modules.test.js b/tests/scripts/plugin-install-without-node-modules.test.js
index b89ab74e2..6f7d5ce26 100644
--- a/tests/scripts/plugin-install-without-node-modules.test.js
+++ b/tests/scripts/plugin-install-without-node-modules.test.js
@@ -30,11 +30,31 @@ function test(name, fn) {
}
function copyRuntimeFiles(destDir) {
- for (const entry of ['scripts', 'schemas', 'manifests']) {
+ 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], {
@@ -63,6 +83,10 @@ function runTests() {
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', () => {
From e0252df02f2fb7d431328df25e5f70a6b20d771e Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:26:24 -0400
Subject: [PATCH 019/141] fix: scope GateGuard exemptions to the project
Address #2921 and complete the segment-anchoring direction in #2979. Preserve explicit absolute exemptions while denying accidental matches in unrelated projects.
---
scripts/hooks/gateguard-fact-force.js | 47 ++++++++++++++++--------
skills/gateguard/SKILL.md | 22 ++++++-----
tests/hooks/gateguard-fact-force.test.js | 47 +++++++++++++++++++++++-
3 files changed, 90 insertions(+), 26 deletions(-)
diff --git a/scripts/hooks/gateguard-fact-force.js b/scripts/hooks/gateguard-fact-force.js
index bf91eb78a..efd6fbf1b 100644
--- a/scripts/hooks/gateguard-fact-force.js
+++ b/scripts/hooks/gateguard-fact-force.js
@@ -100,11 +100,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 +117,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 +143,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() {
@@ -1206,7 +1223,7 @@ function run(rawInput) {
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 +1256,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();
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/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index 54a19c0e0..3b6851481 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -2788,7 +2788,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 +2824,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 +2860,49 @@ 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++;
+
// Cleanup only the temp directory created by this test file.
try {
if (fs.existsSync(stateDir)) {
From c85c39e01ac0584812e3079f5bf6f86045b6434b Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:27:01 -0400
Subject: [PATCH 020/141] fix(release): patch Yarn toml advisory and track
2.2.1 gates
---
docs/releases/2.2.1/patch-execution.md | 59 ++++++++++++++++++++++++++
yarn.lock | 6 +--
2 files changed, 62 insertions(+), 3 deletions(-)
create mode 100644 docs/releases/2.2.1/patch-execution.md
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..1fb771336
--- /dev/null
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -0,0 +1,59 @@
+# 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.
+- #2961 head db88758cbdadf214728d5ea028fa5705453d6ffc is mergeable with hosted
+ checks passing; independent review is in progress.
+
+## Completion evidence
+
+Pending integration, hosted validation, signed tag, publication, registry
+integrity readback, and clean lifecycle canaries. This document does not claim
+that 2.2.1 has shipped.
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
From 82bfd225780aa07e2d1bf83c25493ecf5df61d66 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:29:28 -0400
Subject: [PATCH 021/141] docs(release): record reviewed patch candidates and
limits
---
docs/releases/2.2.1/patch-execution.md | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md
index 1fb771336..6f0b7d8a9 100644
--- a/docs/releases/2.2.1/patch-execution.md
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -49,8 +49,24 @@ outside this patch.
- 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.
-- #2961 head db88758cbdadf214728d5ea028fa5705453d6ffc is mergeable with hosted
- checks passing; independent review is in progress.
+- 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 |
+
+Plugin dependency handling does not bundle or automatically install modules.
+Database and schema-validation features still require declared runtime packages.
+The high-priority installer and manual Claude registration candidates remain
+under independent review and have not yet been included in this integration.
## Completion evidence
From dbe8bfbba9449e4baeefc27366b9b0eb31e3ad48 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:31:33 -0400
Subject: [PATCH 022/141] fix(install): pin Claude settings parent during
atomic replacement
Reject directory replacement after temporary file creation or staging, preserve unrelated files during cleanup, and retry settings edits observed before the final rename. Add three regression tests for the review findings.
---
scripts/lib/atomic-write.js | 15 +++-
scripts/lib/install/claude-settings.js | 23 ++++++-
tests/lib/claude-settings.test.js | 95 ++++++++++++++++++++++++++
3 files changed, 131 insertions(+), 2 deletions(-)
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/install/claude-settings.js b/scripts/lib/install/claude-settings.js
index dd34dd6fb..3c18668a6 100644
--- a/scripts/lib/install/claude-settings.js
+++ b/scripts/lib/install/claude-settings.js
@@ -389,9 +389,23 @@ function assertSettingsSnapshotUnchanged(settingsPath, snapshot) {
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();
@@ -399,7 +413,14 @@ function updateSettingsAtomic(settingsPath, transform, options = {}) {
writeFileAtomic(
settingsPath,
`${JSON.stringify(result.settings, null, 2)}\n`,
- { encoding: 'utf8', mode: snapshot.mode }
+ {
+ encoding: 'utf8',
+ mode: snapshot.mode,
+ validateParent,
+ beforeRename() {
+ assertSettingsSnapshotUnchanged(settingsPath, snapshot);
+ },
+ }
);
return result;
} catch (error) {
diff --git a/tests/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js
index c31f191f9..26d094fc4 100644
--- a/tests/lib/claude-settings.test.js
+++ b/tests/lib/claude-settings.test.js
@@ -48,6 +48,67 @@ 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 originalOpen = fs.openSync;
+ const originalFsync = fs.fsyncSync;
+ const targetContent = '{"target":true}\n';
+ const victimContent = '{"victim":"preserve"}\n';
+ let tempDescriptor;
+ let tempBasename;
+ let replaced = false;
+ const replaceParent = () => {
+ replaced = true;
+ fs.renameSync(targetRoot, parkedRoot);
+ fs.symlinkSync(victimRoot, targetRoot, process.platform === 'win32' ? 'junction' : 'dir');
+ // 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.openSync = function(file, flags, ...args) {
+ const isTemp = typeof file === 'string'
+ && path.basename(file).startsWith('.settings.json.') && file.endsWith('.tmp');
+ if (isTemp) tempBasename = path.basename(file);
+ if (isTemp && !replaced && stage === 'open') {
+ // Replace immediately after the temporary descriptor has been created.
+ const descriptor = originalOpen.call(fs, file, flags, ...args);
+ tempDescriptor = descriptor;
+ replaceParent();
+ return descriptor;
+ }
+ const descriptor = originalOpen.call(fs, file, flags, ...args);
+ if (isTemp) tempDescriptor = descriptor;
+ return descriptor;
+ };
+ fs.fsyncSync = function(descriptor) {
+ const result = originalFsync.call(fs, descriptor);
+ if (!replaced && stage === 'rename' && descriptor === tempDescriptor) replaceParent();
+ return result;
+ };
+ assert.throws(
+ () => updateSettingsAtomic(settingsPath, settings => ({ settings: { ...settings, managed: true } })),
+ /parent.*changed|changed.*parent/i
+ );
+ assert.ok(replaced, 'must exercise a replacement inside the atomic writer');
+ assert.strictEqual(fs.readFileSync(victimPath, 'utf8'), victimContent);
+ 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.openSync = originalOpen;
+ fs.fsyncSync = originalFsync;
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+}
+
function runTests() {
console.log('\n=== Testing install/claude-settings.js ===\n');
@@ -224,6 +285,40 @@ function runTests() {
}
})) 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');
From 59b74901c451b899fa873b8a4a2ced1ab945db4d Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:32:58 -0400
Subject: [PATCH 023/141] fix(install): preserve user files and honor uninstall
previews
Generalize PR #2981 ownership protection to every managed target. Reject mismatched target state and preserve files that appear during writes or failed-install checkpoints. Keep prior hashes for managed files a failed attempt never writes.
Integrate PR #2980 preview wording and global dry-run propagation, with PR #2956 fail-closed environment validation and CLI/legacy regression coverage.
Fixes #2964. Fixes #2952.
Co-authored-by: ilkmajans-cpu
Co-authored-by: wellkilo
---
scripts/lib/install/apply.js | 33 +++-
scripts/lib/install/ownership-guard.js | 159 +++++++++++++++++++
scripts/uninstall.js | 39 +++--
tests/scripts/ecc.test.js | 97 ++++++++++--
tests/scripts/ownership-guard.test.js | 180 +++++++++++++++++++++
tests/scripts/uninstall.test.js | 210 ++++++++++++++++++++++++-
6 files changed, 688 insertions(+), 30 deletions(-)
create mode 100644 scripts/lib/install/ownership-guard.js
create mode 100644 tests/scripts/ownership-guard.test.js
diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js
index 8a726883e..fbab1293b 100644
--- a/scripts/lib/install/apply.js
+++ b/scripts/lib/install/apply.js
@@ -28,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');
@@ -393,7 +398,7 @@ function prepareHookConsentMigration(plan, migration) {
function previewInstallPlan(plan) {
const migration = prepareHookConsentMigration(
plan,
- prepareClaudeSkillMigration(plan)
+ prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
);
const appliedPlan = {
...plan,
@@ -446,7 +451,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
}
const migration = prepareHookConsentMigration(
plan,
- prepareClaudeSkillMigration(plan)
+ prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
);
const appliedPlan = {
...plan,
@@ -460,6 +465,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
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
@@ -485,6 +491,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
if (typeof beforeOperationWrite === 'function') {
beforeOperationWrite({ plan: appliedPlan, operation });
}
+ assertNoNewUserOwnedFile(migration, operation);
if (
operation.kind === 'update-claude-settings'
@@ -516,6 +523,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
assertSafeInstallOperation(appliedPlan, operation);
},
});
+ writtenDestinations.add(operation.destinationPath);
if (operation.kind === 'remove-claude-settings-hooks') {
completedHookRemovalCount += 1;
}
@@ -540,6 +548,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
);
const mergedValue = deepMergeJson(currentValue, filteredPayload);
fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8');
+ writtenDestinations.add(operation.destinationPath);
continue;
}
@@ -547,6 +556,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
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;
}
@@ -569,10 +579,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
})
: transformed;
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
+ writtenDestinations.add(operation.destinationPath);
continue;
}
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
+ writtenDestinations.add(operation.destinationPath);
}
if (hasLegacyMigration) {
@@ -600,10 +612,19 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
persistInstallState(
plan.installStatePath,
stateWithContentDigests(
- hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
- ? migration.finalState
- : migration.bridgeState,
- appliedPlan
+ preserveUnwrittenFiles(
+ hookRemovalCount > 0 && completedHookRemovalCount === hookRemovalCount
+ ? migration.finalState
+ : migration.bridgeState,
+ migration,
+ writtenDestinations
+ ),
+ {
+ ...appliedPlan,
+ operations: appliedPlan.operations.filter(operation => (
+ writtenDestinations.has(operation.destinationPath)
+ )),
+ }
)
);
} catch (checkpointError) {
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/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/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/ownership-guard.test.js b/tests/scripts/ownership-guard.test.js
new file mode 100644
index 000000000..1856fc0fc
--- /dev/null
+++ b/tests/scripts/ownership-guard.test.js
@@ -0,0 +1,180 @@
+'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 === 'agents/architect.md');
+ 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);
+ 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/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-');
From c11753d0b94d3a8cc379564609a740c5e68599e3 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:36:10 -0400
Subject: [PATCH 024/141] fix(skills): replace invented autonomous harness
setup instructions
Resolve #2957 using the verified MCP reference memory package, native session scheduling, supported CLI invocation and documented computer-use integration. Incorporates the corrective direction from #2977 and #2958, including package-version pinning and regression checks for executable examples.
Co-authored-by: ilkmajans-cpu
Co-authored-by: kavish-19 <63698788+kavish-19@users.noreply.github.com>
---
skills/autonomous-agent-harness/SKILL.md | 75 ++++++++++-----------
tests/docs/autonomous-harness-setup.test.js | 46 +++++++++++++
2 files changed, 82 insertions(+), 39 deletions(-)
create mode 100644 tests/docs/autonomous-harness-setup.test.js
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/tests/docs/autonomous-harness-setup.test.js b/tests/docs/autonomous-harness-setup.test.js
new file mode 100644
index 000000000..75e8fe855
--- /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.doesNotMatch(blocks, /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;
From 8cc31f1e5f18801af6ccedf44100c9343cb9fb86 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:41:45 -0400
Subject: [PATCH 025/141] docs(release): record reviewed 2.2.1 bug fixes and
verification boundaries
---
docs/releases/2.2.1/patch-execution.md | 21 +++++++--
docs/releases/2.2.1/release-notes.md | 62 ++++++++++++++++++++++++--
2 files changed, 77 insertions(+), 6 deletions(-)
diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md
index 6f0b7d8a9..bc8fcd2d3 100644
--- a/docs/releases/2.2.1/patch-execution.md
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -62,14 +62,29 @@ outside this patch.
| 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 high-priority installer and manual Claude registration candidates remain
-under independent review and have not yet been included in this integration.
+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
-Pending integration, hosted validation, signed tag, publication, registry
+First batch 82bfd225 passed 4,215/4,215 tests and lint. Integrated full validation
+and hosted checks are pending. 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.
diff --git a/docs/releases/2.2.1/release-notes.md b/docs/releases/2.2.1/release-notes.md
index b8d7d5795..d56c4f4ae 100644
--- a/docs/releases/2.2.1/release-notes.md
+++ b/docs/releases/2.2.1/release-notes.md
@@ -1,8 +1,53 @@
# 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 checkpoint only files they actually wrote, preserving the previous
+ ownership hashes of untouched managed files
+ ([#2964](https://github.com/affaan-m/ECC/issues/2964)).
+- 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,6 +77,17 @@ 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
From a220947fb51524174b74856a7697dddcaf3bf4d7 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:49:20 -0400
Subject: [PATCH 026/141] test(install): exercise settings races safely on
Windows
Close fixture-owned descriptors when Windows blocks a directory rename before open returns. Assert OS rejection preserves both settings files and releases staging handles. Move the rename-boundary injection after descriptor close so Windows exercises parent-identity validation without skipping race coverage.
---
tests/lib/claude-settings.test.js | 55 ++++++++++++++++++++++++-------
1 file changed, 43 insertions(+), 12 deletions(-)
diff --git a/tests/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js
index 26d094fc4..dfd5d6b37 100644
--- a/tests/lib/claude-settings.test.js
+++ b/tests/lib/claude-settings.test.js
@@ -56,16 +56,25 @@ function assertAtomicParentReplacementRejected(stage) {
const settingsPath = path.join(targetRoot, 'settings.json');
const victimPath = path.join(victimRoot, 'settings.json');
const originalOpen = fs.openSync;
- 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 = () => {
- replaced = true;
- fs.renameSync(targetRoot, parkedRoot);
+ 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');
};
@@ -76,35 +85,57 @@ function assertAtomicParentReplacementRejected(stage) {
fs.writeFileSync(victimPath, victimContent);
fs.openSync = function(file, flags, ...args) {
const isTemp = typeof file === 'string'
+ && path.dirname(path.resolve(file)) === targetRoot
&& path.basename(file).startsWith('.settings.json.') && file.endsWith('.tmp');
if (isTemp) tempBasename = path.basename(file);
if (isTemp && !replaced && stage === 'open') {
// Replace immediately after the temporary descriptor has been created.
const descriptor = originalOpen.call(fs, file, flags, ...args);
tempDescriptor = descriptor;
- replaceParent();
+ try {
+ replaceParent();
+ } catch (error) {
+ // The writer has not received this handle yet. If Windows refuses
+ // the directory rename, the fixture must close its own descriptor.
+ originalClose.call(fs, descriptor);
+ throw error;
+ }
return descriptor;
}
const descriptor = originalOpen.call(fs, file, flags, ...args);
if (isTemp) tempDescriptor = descriptor;
return descriptor;
};
- fs.fsyncSync = function(descriptor) {
- const result = originalFsync.call(fs, descriptor);
- if (!replaced && stage === 'rename' && descriptor === tempDescriptor) replaceParent();
+ 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 } })),
- /parent.*changed|changed.*parent/i
+ error => /parent.*changed|changed.*parent/i.test(error.message)
+ || (replacementBlocked && ['EPERM', 'EACCES'].includes(error.code))
);
- assert.ok(replaced, 'must exercise a replacement inside the atomic writer');
+ 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);
- assert.strictEqual(fs.readFileSync(path.join(parkedRoot, 'settings.json'), 'utf8'), targetContent);
- assert.strictEqual(fs.readFileSync(path.join(victimRoot, tempBasename), 'utf8'), 'unrelated replacement file');
+ 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.openSync = originalOpen;
- fs.fsyncSync = originalFsync;
+ fs.closeSync = originalClose;
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
From adb39a13c9ed34f2783adf2a72f133ee191019f1 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:51:18 -0400
Subject: [PATCH 027/141] test: make release safety fixtures CodeQL-clean
Use literal matching for the forbidden documentation endpoint, pin lifecycle mode/content assertions to one descriptor, and inject parent races through the staged descriptor without forwarding arbitrary file-creation flags.
---
tests/docs/autonomous-harness-setup.test.js | 2 +-
tests/lib/claude-settings.test.js | 36 ++++++++-------------
tests/lib/install-lifecycle.test.js | 9 ++++--
3 files changed, 22 insertions(+), 25 deletions(-)
diff --git a/tests/docs/autonomous-harness-setup.test.js b/tests/docs/autonomous-harness-setup.test.js
index 75e8fe855..59d5c53e0 100644
--- a/tests/docs/autonomous-harness-setup.test.js
+++ b/tests/docs/autonomous-harness-setup.test.js
@@ -10,7 +10,7 @@ const blocks = [...source.matchAll(/```[^\n]*\n([\s\S]*?)```/g)].map(match => ma
const checks = [
['executable examples omit unpublished packages and the invented dispatch endpoint', () => {
assert.doesNotMatch(blocks, /@anthropic\/(?:memory|scheduled-tasks|computer-use)-mcp-server/);
- assert.doesNotMatch(blocks, /api\.anthropic\.com\/dispatch/);
+ 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__/);
diff --git a/tests/lib/claude-settings.test.js b/tests/lib/claude-settings.test.js
index dfd5d6b37..53caaf4bb 100644
--- a/tests/lib/claude-settings.test.js
+++ b/tests/lib/claude-settings.test.js
@@ -55,7 +55,7 @@ function assertAtomicParentReplacementRejected(stage) {
const victimRoot = path.join(tempDir, 'victim');
const settingsPath = path.join(targetRoot, 'settings.json');
const victimPath = path.join(victimRoot, 'settings.json');
- const originalOpen = fs.openSync;
+ const originalFsync = fs.fsyncSync;
const originalClose = fs.closeSync;
const targetContent = '{"target":true}\n';
const victimContent = '{"victim":"preserve"}\n';
@@ -83,28 +83,20 @@ function assertAtomicParentReplacementRejected(stage) {
fs.mkdirSync(victimRoot);
fs.writeFileSync(settingsPath, targetContent);
fs.writeFileSync(victimPath, victimContent);
- fs.openSync = function(file, flags, ...args) {
- const isTemp = typeof file === 'string'
- && path.dirname(path.resolve(file)) === targetRoot
- && path.basename(file).startsWith('.settings.json.') && file.endsWith('.tmp');
- if (isTemp) tempBasename = path.basename(file);
- if (isTemp && !replaced && stage === 'open') {
- // Replace immediately after the temporary descriptor has been created.
- const descriptor = originalOpen.call(fs, file, flags, ...args);
+ 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;
- try {
- replaceParent();
- } catch (error) {
- // The writer has not received this handle yet. If Windows refuses
- // the directory rename, the fixture must close its own descriptor.
- originalClose.call(fs, descriptor);
- throw error;
- }
- return 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();
}
- const descriptor = originalOpen.call(fs, file, flags, ...args);
- if (isTemp) tempDescriptor = descriptor;
- return descriptor;
+ return result;
};
fs.closeSync = function(descriptor) {
const result = originalClose.call(fs, descriptor);
@@ -134,7 +126,7 @@ function assertAtomicParentReplacementRejected(stage) {
assert.strictEqual(fs.readFileSync(path.join(victimRoot, tempBasename), 'utf8'), 'unrelated replacement file');
}
} finally {
- fs.openSync = originalOpen;
+ fs.fsyncSync = originalFsync;
fs.closeSync = originalClose;
fs.rmSync(tempDir, { recursive: true, force: true });
}
diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js
index fef01bd8a..ab20e54ca 100644
--- a/tests/lib/install-lifecycle.test.js
+++ b/tests/lib/install-lifecycle.test.js
@@ -3512,8 +3512,13 @@ function runTests() {
});
assert.strictEqual(result.results[0].status, 'repaired');
- assert.strictEqual(fs.statSync(settingsPath).mode & 0o777, 0o600);
- assert.deepStrictEqual(JSON.parse(fs.readFileSync(settingsPath, 'utf8')).hooks, managedHooks);
+ 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);
From ba3a64a2c5713d0369d7482fefc5d39f12db3902 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 16:53:53 -0400
Subject: [PATCH 028/141] fix(setup): preserve preflight guarantees across
ownership filtering
---
docs/releases/2.2.1/patch-execution.md | 22 +++++++++++--
docs/releases/2.2.1/release-notes.md | 5 ++-
scripts/lib/multi-harness-setup.js | 43 ++++++++++++++++----------
tests/lib/multi-harness-setup.test.js | 32 +++++++++++++++++++
4 files changed, 83 insertions(+), 19 deletions(-)
diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md
index bc8fcd2d3..f0faf4332 100644
--- a/docs/releases/2.2.1/patch-execution.md
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -82,8 +82,26 @@ Successful managed-file upgrades retain their existing replacement semantics.
## Completion evidence
-First batch 82bfd225 passed 4,215/4,215 tests and lint. Integrated full validation
-and hosted checks are pending. Signing remains unavailable locally.
+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
diff --git a/docs/releases/2.2.1/release-notes.md b/docs/releases/2.2.1/release-notes.md
index d56c4f4ae..5bd695d1c 100644
--- a/docs/releases/2.2.1/release-notes.md
+++ b/docs/releases/2.2.1/release-notes.md
@@ -14,9 +14,12 @@ and signing evidence are tracked separately in the release checklist.
absolute exemptions remain supported
([#2921](https://github.com/affaan-m/ECC/issues/2921)).
- Installer writes reject collisions with untracked user-owned files. Failed
- installs checkpoint only files they actually wrote, preserving the previous
+ 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)).
diff --git a/scripts/lib/multi-harness-setup.js b/scripts/lib/multi-harness-setup.js
index 30b9d469d..3b9b9eee0 100644
--- a/scripts/lib/multi-harness-setup.js
+++ b/scripts/lib/multi-harness-setup.js
@@ -373,7 +373,9 @@ async function applyPreflightedManagedPlan(entry) {
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
- let operationIndex = 0;
+ const expectedOperations = new Map(preview.operations.map(operation => [
+ canonicalPath(operation.destinationPath), operation,
+ ]));
const assertStateUnchanged = () => (
assertInstallStateUnchanged(preview.plan, expectedStateFingerprint)
);
@@ -381,26 +383,35 @@ async function applyPreflightedManagedPlan(entry) {
assertStateUnchanged();
expectedStateFingerprint = fingerprintInstallStateValue(state);
};
+ const assertOperationUnchanged = operation => {
+ const destination = canonicalPath(operation.destinationPath);
+ const expected = expectedOperations.get(destination);
+ const currentClassification = classifyManagedOperation(operation, ownedDestinations);
+ if (
+ !expected
+ || expected.kind !== operation.kind
+ || expected.classification !== 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;
},
beforeInstallStateWrite: prepareInstallStateWrite,
});
diff --git a/tests/lib/multi-harness-setup.test.js b/tests/lib/multi-harness-setup.test.js
index f6098e4b5..2858a7ab4 100644
--- a/tests/lib/multi-harness-setup.test.js
+++ b/tests/lib/multi-harness-setup.test.js
@@ -569,6 +569,38 @@ 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 });
+ }
+ });
+
await test('refuses conflicting JSON created after preview but before apply', async () => {
const root = tempDir('ecc-guided-late-json-collision-');
try {
From 14e731c6d5f341f35a9e24609de1d91f57cf8d70 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 17:57:26 -0400
Subject: [PATCH 029/141] fix: close release review gaps and expose failing CI
suites
---
docs/releases/2.2.1/patch-execution.md | 26 ++++
docs/releases/2.2.1/release-notes.md | 5 +-
scripts/lib/install/claude-settings.js | 10 +-
scripts/lib/multi-harness-setup.js | 17 ++-
scripts/lib/powershell-destructive-command.js | 120 ++++++++++++++----
tests/ci/run-all.test.js | 112 ++++++++++++++++
tests/hooks/gateguard-fact-force.test.js | 14 ++
tests/hooks/governance-capture.test.js | 44 +++++++
tests/lib/claude-settings-array.test.js | 88 +++++++++++++
tests/lib/multi-harness-setup.test.js | 38 ++++++
.../powershell-destructive-command.test.js | 103 +++++++++++++++
tests/run-all.js | 38 ++++--
12 files changed, 572 insertions(+), 43 deletions(-)
create mode 100644 tests/ci/run-all.test.js
create mode 100644 tests/lib/claude-settings-array.test.js
diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md
index f0faf4332..b8fdfbe5e 100644
--- a/docs/releases/2.2.1/patch-execution.md
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -106,3 +106,29 @@ 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`.
+Seven 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.
diff --git a/docs/releases/2.2.1/release-notes.md b/docs/releases/2.2.1/release-notes.md
index 5bd695d1c..4d05b3c3d 100644
--- a/docs/releases/2.2.1/release-notes.md
+++ b/docs/releases/2.2.1/release-notes.md
@@ -93,8 +93,9 @@ and signing evidence are tracked separately in the release checklist.
## 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/scripts/lib/install/claude-settings.js b/scripts/lib/install/claude-settings.js
index 3c18668a6..7075c5bd9 100644
--- a/scripts/lib/install/claude-settings.js
+++ b/scripts/lib/install/claude-settings.js
@@ -255,6 +255,10 @@ 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};`);
@@ -273,9 +277,11 @@ function resolveManagedHookCommands(managedHooks, targetRoot) {
...entry,
hooks: entry.hooks.map(hook => ({
...hook,
- ...(typeof hook.command === 'string'
+ ...(typeof hook.command === 'string' || Array.isArray(hook.command)
? {
- command: resolveCommand(hook.command),
+ command: Array.isArray(hook.command)
+ ? hook.command.map(resolveCommand)
+ : resolveCommand(hook.command),
}
: {}),
})),
diff --git a/scripts/lib/multi-harness-setup.js b/scripts/lib/multi-harness-setup.js
index 3b9b9eee0..141eb4206 100644
--- a/scripts/lib/multi-harness-setup.js
+++ b/scripts/lib/multi-harness-setup.js
@@ -373,9 +373,12 @@ async function applyPreflightedManagedPlan(entry) {
: preflightManagedPlan(entry.preview.plan);
const ownedDestinations = new Set(preview.ownershipSnapshot.destinations);
let expectedStateFingerprint = preview.ownershipSnapshot.stateFingerprint;
- const expectedOperations = new Map(preview.operations.map(operation => [
- canonicalPath(operation.destinationPath), operation,
+ // 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)
);
@@ -385,12 +388,17 @@ async function applyPreflightedManagedPlan(entry) {
};
const assertOperationUnchanged = operation => {
const destination = canonicalPath(operation.destinationPath);
- const expected = expectedOperations.get(destination);
+ 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
- || expected.classification !== currentClassification
+ || canonicalPath(expected.destinationPath) !== destination
+ || expectedClassification !== currentClassification
) {
throw new Error(
`Refusing to write ${operation.destinationPath}: destination changed after Kimi preflight.`
@@ -412,6 +420,7 @@ async function applyPreflightedManagedPlan(entry) {
assertStateUnchanged();
const destination = assertOperationUnchanged(operation);
ownedDestinations.add(destination);
+ writtenDestinations.add(destination);
},
beforeInstallStateWrite: prepareInstallStateWrite,
});
diff --git a/scripts/lib/powershell-destructive-command.js b/scripts/lib/powershell-destructive-command.js
index 77f3ac095..6ec294453 100644
--- a/scripts/lib/powershell-destructive-command.js
+++ b/scripts/lib/powershell-destructive-command.js
@@ -58,6 +58,21 @@ const START_PROCESS_SWITCH_PARAMETERS = new Set([
'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__';
@@ -1225,16 +1240,22 @@ function addNestedScan(payload, depth, findings, analysis, options = {}, scanSta
scanPowerShell(payload, depth + 1, findings, analysis, options, scanState);
}
-function staticPipelineInput(tokens) {
+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) {
- const value = String(tokens[0] || '');
- return value || 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) {
- const value = String(tokens[1] || '');
- return tokens.quotedTokens?.[1] === true || /\s/.test(value) ? value : null;
+ return staticTokenValue(tokens, 1, state, findings);
}
return null;
}
@@ -1272,7 +1293,7 @@ function scanNestedPowerShell(tokens, depth, findings, analysis, scanState, upst
let payload = inlinePayload
? [inlinePayload, ...tokens.slice(index + 1)].join(' ')
: tokens.slice(index + 1).join(' ');
- const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens) : null;
+ const pipelinePayload = payload === '-' ? staticPipelineInput(upstreamTokens, scanState, findings) : null;
const payloadIndex = index + 1;
const inlineQuoteKind = tokens.inlineValueQuoteKinds?.[index];
if (inlinePayload && inlineQuoteKind !== "'") {
@@ -1754,26 +1775,70 @@ function scanScriptBlockConsumer(tokens, quotedTokens, findings, state) {
}
}
-function staticAliasDefinition(tokens, quotedTokens = []) {
- let name = null;
- let value = null;
+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 = tokens[index];
- if (!quotedTokens[index] && isParameterPrefix(token, 'name')) {
- name = parameterValue(token) || tokens[++index] || null;
- } else if (!quotedTokens[index] && isParameterPrefix(token, 'value')) {
- value = parameterValue(token) || tokens[++index] || null;
- } else if (!String(token).startsWith('-')) {
- positional.push(token);
+ 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;
}
}
- name ||= positional[0] || null;
- value ||= positional[1] || null;
- if (!/^[A-Za-z_][\w-]*$/.test(name || '') || !/^[A-Za-z_][\w./\\-]*$/.test(value || '')) {
- return null;
- }
- return { name: name.toLowerCase(), value };
+ 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) {
@@ -1862,9 +1927,10 @@ function scanPowerShell(command, depth, findings, analysis = null, options = {},
state
);
}
- if (commandName === 'set-alias' || commandName === 'new-alias') {
- const definition = staticAliasDefinition(tokens, executable.quotedTokens);
- if (definition) state.aliases.set(definition.name, definition.value);
+ 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()}`);
diff --git a/tests/ci/run-all.test.js b/tests/ci/run-all.test.js
new file mode 100644
index 000000000..21c760c86
--- /dev/null
+++ b/tests/ci/run-all.test.js
@@ -0,0 +1,112 @@
+'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/);
+ }],
+ ['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/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js
index abf328201..495928691 100644
--- a/tests/hooks/gateguard-fact-force.test.js
+++ b/tests/hooks/gateguard-fact-force.test.js
@@ -3020,6 +3020,20 @@ function runTests() {
"$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)"',
diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js
index c7cc46c52..7d40ebe78 100644
--- a/tests/hooks/governance-capture.test.js
+++ b/tests/hooks/governance-capture.test.js
@@ -247,6 +247,50 @@ async function runTests() {
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'],
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/multi-harness-setup.test.js b/tests/lib/multi-harness-setup.test.js
index 2858a7ab4..24b5a5f45 100644
--- a/tests/lib/multi-harness-setup.test.js
+++ b/tests/lib/multi-harness-setup.test.js
@@ -601,6 +601,44 @@ function writeManagedState(plan, overrides = {}) {
}
});
+ 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
index 7a4ae31cf..43f01994a 100644
--- a/tests/lib/powershell-destructive-command.test.js
+++ b/tests/lib/powershell-destructive-command.test.js
@@ -206,6 +206,109 @@ test('does not resolve earlier invocations from later scalar assignments', () =>
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"',
diff --git a/tests/run-all.js b/tests/run-all.js
index fd79cb4af..09bc0ccef 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 => /\b(?:FAIL|[A-Za-z]*Error)\b|[✗❌]/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);
}
}
From 165074ecf40aaa9f95173f5182509c8193c48b86 Mon Sep 17 00:00:00 2001
From: haelyra <49814733+haelyra@users.noreply.github.com>
Date: Mon, 7 Sep 2026 18:09:46 -0400
Subject: [PATCH 030/141] test: fix Windows ownership paths and failure
annotations
---
docs/releases/2.2.1/patch-execution.md | 11 ++++++++++-
tests/ci/run-all.test.js | 7 +++++++
tests/run-all.js | 2 +-
tests/scripts/ownership-guard.test.js | 6 +++++-
4 files changed, 23 insertions(+), 3 deletions(-)
diff --git a/docs/releases/2.2.1/patch-execution.md b/docs/releases/2.2.1/patch-execution.md
index b8fdfbe5e..0dd5488bd 100644
--- a/docs/releases/2.2.1/patch-execution.md
+++ b/docs/releases/2.2.1/patch-execution.md
@@ -121,7 +121,7 @@ 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`.
-Seven isolated runner regressions pass.
+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;
@@ -132,3 +132,12 @@ 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/tests/ci/run-all.test.js b/tests/ci/run-all.test.js
index 21c760c86..94a7274ef 100644
--- a/tests/ci/run-all.test.js
+++ b/tests/ci/run-all.test.js
@@ -84,6 +84,13 @@ const tests = [
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);
diff --git a/tests/run-all.js b/tests/run-all.js
index 09bc0ccef..22d0ff5a4 100644
--- a/tests/run-all.js
+++ b/tests/run-all.js
@@ -51,7 +51,7 @@ function escapeAnnotation(value, property = false) {
function annotateFailure(displayPath, reason, output) {
if (process.env.GITHUB_ACTIONS !== 'true') return;
const context = output.split(/\r?\n/)
- .filter(line => /\b(?:FAIL|[A-Za-z]*Error)\b|[✗❌]/i.test(line))
+ .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);
diff --git a/tests/scripts/ownership-guard.test.js b/tests/scripts/ownership-guard.test.js
index 1856fc0fc..4c16afd2e 100644
--- a/tests/scripts/ownership-guard.test.js
+++ b/tests/scripts/ownership-guard.test.js
@@ -71,11 +71,15 @@ for (const adapter of listInstallTargetAdapters()) {
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 === 'agents/architect.md');
+ 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);
From 2cb58a0b3c76dff9deac27851677e115a5b0f290 Mon Sep 17 00:00:00 2001
From: Tony Yu <49832190+ysntony@users.noreply.github.com>
Date: Wed, 9 Sep 2026 22:20:47 +0800
Subject: [PATCH 031/141] docs: add Kimi tracking links to sponsor logo and
Kimi sections (#3047)
Point the Moonshot sponsor logo href at https://platform.kimi.ai?aff=ecc (image unchanged, link target only).
Add a Get Kimi Code link (https://www.kimi.com/code?aff=ecc) to the Kimi Code CLI row of the install-target table.
Link the API endpoint mention in the Self-host Kimi intro to the API platform link.
---
README.md | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 091836e33..73b4aa7f2 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,7 @@ The native path installs ECC's skills, agents, commands, and plugin-managed hook
-
+
@@ -337,7 +337,7 @@ cd ECC
| Qwen CLI | `./install.sh --profile minimal --target qwen` | See the [Qwen guide](docs/QWEN-GUIDE.md) |
| Hermes | `./install.sh --profile minimal --target hermes` | See the [Hermes setup guide](docs/HERMES-SETUP.md) |
| OpenClaw | `./install.sh --profile minimal --target openclaw` | Managed home-directory install |
-| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | Project-local `.kimi-code/` install |
+| Kimi Code CLI | `./install.sh --profile minimal --target kimi` | Project-local `.kimi-code/` install · [Get Kimi Code](https://www.kimi.com/code?aff=ecc) |
| CodeBuddy | `./install.sh --profile minimal --target codebuddy` | Project-local `.codebuddy/` install |
| JoyCode | `./install.sh --profile minimal --target joycode` | Project-local `.joycode/` install |
@@ -368,7 +368,7 @@ Run or self-host any open-source model behind that gateway using separate comput
### Self-host Kimi with ECC + Itô compute
-The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint or self-host an open-weight Kimi model on your own GPU capacity. This adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`):
+The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint ([get a Kimi API key](https://platform.kimi.ai?aff=ecc)) or self-host an open-weight Kimi model on your own GPU capacity. This adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`):
From 549c14692ccf610f127a4b95eb6f496bb45ab6c9 Mon Sep 17 00:00:00 2001
From: Myles Agnew
Date: Thu, 10 Sep 2026 03:56:47 +1000
Subject: [PATCH 032/141] fix(security): bump js-yaml 4.3.1 -> 4.3.2
(GHSA-2883-xcg3-v3hh) (#3032)
js-yaml < 4.3.2 is affected by a high-severity uncontrolled-resource-
consumption issue (CWE-400 / CWE-407, CVSS 7.5): maxTotalMergeKeys does
not limit CPU use for empty merge sources, allowing a crafted YAML
document with merge keys to cause a denial of service while parsing.
js-yaml is a direct runtime dependency (it is also pinned via `overrides`
and `resolutions`), so the bump is applied in all three package.json
locations and both lockfiles are regenerated. 4.3.2 is a non-breaking
patch release; `npm audit --audit-level=high` and an immutable
`yarn install` both pass afterward.
Advisory: https://github.com/advisories/GHSA-2883-xcg3-v3hh
Co-authored-by: Claude Opus 4.8
---
package-lock.json | 8 ++++----
package.json | 6 +++---
yarn.lock | 10 +++++-----
3 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index a692663fa..d5b64895a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,7 +11,7 @@
"dependencies": {
"@iarna/toml": "2.2.5",
"ajv": "8.20.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"sql.js": "1.14.2"
},
"bin": {
@@ -1493,9 +1493,9 @@
}
},
"node_modules/js-yaml": {
- "version": "4.3.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
- "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
diff --git a/package.json b/package.json
index 70f136fc9..9cdc81dce 100644
--- a/package.json
+++ b/package.json
@@ -479,7 +479,7 @@
"dependencies": {
"@iarna/toml": "2.2.5",
"ajv": "8.20.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"sql.js": "1.14.2"
},
"pi": {
@@ -509,13 +509,13 @@
"overrides": {
"fast-uri": "3.1.7",
"markdown-it": "14.3.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"@humanfs/node": "0.16.8"
},
"resolutions": {
"fast-uri": "3.1.7",
"markdown-it": "14.3.0",
- "js-yaml": "4.3.1",
+ "js-yaml": "4.3.2",
"@humanfs/node": "0.16.8"
},
"packageManager": "yarn@4.9.2+sha512.1fc009bc09d13cfd0e19efa44cbfc2b9cf6ca61482725eb35bbc5e257e093ebf4130db6dfe15d604ff4b79efd8e1e8e99b25fa7d0a6197c9f9826358d4d65c3c"
diff --git a/yarn.lock b/yarn.lock
index 8867e1184..7b566ee07 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -606,7 +606,7 @@ __metadata:
c8: "npm:11.0.0"
eslint: "npm:10.9.1"
globals: "npm:17.11.0"
- js-yaml: "npm:4.3.1"
+ js-yaml: "npm:4.3.2"
markdownlint-cli: "npm:0.49.1"
sql.js: "npm:1.14.2"
typescript: "npm:6.0.3"
@@ -1082,14 +1082,14 @@ __metadata:
languageName: node
linkType: hard
-"js-yaml@npm:4.3.1":
- version: 4.3.1
- resolution: "js-yaml@npm:4.3.1"
+"js-yaml@npm:4.3.2":
+ version: 4.3.2
+ resolution: "js-yaml@npm:4.3.2"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.js
- checksum: 10c0/13c500ca322e0c3f8c81686e6ecda96d2ea37b45247a420c17c7db36932d6965cc27391abc2d1a104501600e7f0d947a5f8b7be6db619c4fefa87901b3512807
+ checksum: 10c0/dedd34c2e8fef1d504687f1bc94ed0ee1311a1f78770fa2800352c6d59c635ccf555009d74e9afe529ae62199da2b1ccef30e53dc84dcd8d2d8187c22574bc97
languageName: node
linkType: hard
From cc91c24f9a35dc79811f2326b6cbf4a74739009a Mon Sep 17 00:00:00 2001
From: Wu Shuwen
Date: Thu, 10 Sep 2026 03:00:49 +0800
Subject: [PATCH 033/141] fix(commands): distinguish prp-pr alias (#2907)
---
commands/prp-pr.md | 2 +-
docs/COMMAND-REGISTRY.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/commands/prp-pr.md b/commands/prp-pr.md
index 9469cb884..2016ec90b 100644
--- a/commands/prp-pr.md
+++ b/commands/prp-pr.md
@@ -1,5 +1,5 @@
---
-description: "Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes"
+description: "Alias of /pr for the PRP workflow series. Use when creating a pull request mid-PRP workflow; otherwise use /pr."
argument-hint: "[base-branch] (default: main)"
---
diff --git a/docs/COMMAND-REGISTRY.json b/docs/COMMAND-REGISTRY.json
index 29b1cd647..4f7918cfc 100644
--- a/docs/COMMAND-REGISTRY.json
+++ b/docs/COMMAND-REGISTRY.json
@@ -741,7 +741,7 @@
},
{
"command": "prp-pr",
- "description": "Create a GitHub PR from current branch with unpushed commits — discovers templates, analyzes changes, pushes",
+ "description": "Alias of /pr for the PRP workflow series. Use when creating a pull request mid-PRP workflow; otherwise use /pr.",
"type": "testing",
"primaryAgents": [],
"allAgents": [],
From 052ddcb988127b984f13896bc502d60df130072c Mon Sep 17 00:00:00 2001
From: Affaan Mustafa
Date: Thu, 10 Sep 2026 14:11:04 +0300
Subject: [PATCH 034/141] Validate unified memory evidence across local CLI and
MCP handoffs (#3027)
* test(memory): add cross-harness conformance example
* fix: update js-yaml to patched 4.3.2
* feat(memory): verify recalled evidence against scoped source catalog
* fix: preserve control-character validation under lint
---
examples/unified-memory/README.md | 115 ++++++++++
examples/unified-memory/conformance.cjs | 246 ++++++++++++++++++++++
examples/unified-memory/evidence.cjs | 79 +++++++
examples/unified-memory/evidence.test.cjs | 109 ++++++++++
4 files changed, 549 insertions(+)
create mode 100644 examples/unified-memory/README.md
create mode 100644 examples/unified-memory/conformance.cjs
create mode 100644 examples/unified-memory/evidence.cjs
create mode 100644 examples/unified-memory/evidence.test.cjs
diff --git a/examples/unified-memory/README.md b/examples/unified-memory/README.md
new file mode 100644
index 000000000..bc4cbb1b8
--- /dev/null
+++ b/examples/unified-memory/README.md
@@ -0,0 +1,115 @@
+# Cross-harness memory conformance example
+
+Run the existing ECC CLI and local stdio MCP server against one disposable
+synthetic vault. The example checks that the same scoped query returns the same
+ordered records, scores, excerpts, and provenance for each configured identity.
+
+From an ECC checkout with its runtime dependencies already available:
+
+```sh
+node examples/unified-memory/conformance.cjs
+```
+
+No model, network, Graphiti service, package installation, or native harness
+application is required. The example uses the existing Ajv dependency. It
+creates temporary synthetic project, team, and user records, starts bounded
+Node subprocesses, and removes the temporary vaults when finished. Existing
+vault locations and ambient credential variables are not passed to children.
+
+## What runs
+
+The CLI creates a shared project record, team context, a Codex-targeted record,
+a user record, and another project's record. Separate MCP processes configured
+as `codex`, `claude`, and `hermes` each perform the same requests. These names
+are host configuration in the example, not authenticated sessions in those
+applications.
+
+The 24 checks cover:
+
+- Ordered CLI/MCP search parity and reproducibility after process restart.
+- Stable IDs, scope, source attribution, timestamps, body, and unreviewed trust.
+- Targeted read visibility and separate project roots.
+- Rejection of client identity overrides, target-filter overrides, trust
+ promotion, and user access without host opt-in.
+- Server-stamped Hermes handoff attribution, preserved memory links, and evidence
+ verification in both CLI-to-MCP and MCP-to-CLI directions.
+- Source-content matching against a separate synthetic source catalog, with
+ tampered content/digest, missing-source and foreign-context rejection.
+- Synthetic private-key marker rejection through CLI and MCP without changing
+ the recalled dataset.
+- Explicit user-scope recall after operator opt-in.
+- Failed startup when the host provides no identity.
+- Source files and Git HEAD unchanged after execution.
+
+Success prints a JSON receipt with individual checks, timestamps, Node version,
+source hashes, and the example's digest. Failure returns a nonzero exit status
+without printing raw subprocess output or memory content. The source hashes
+identify the executed files; Git HEAD alone does not prove that a checkout is
+clean. Installed dependencies are reused and are not digest-pinned by this
+example. This is focused conformance verification, not a full-suite result or
+a deployment receipt. The source receipt includes the example verifier digest;
+ dependency identity and native-harness integration remain separate checks.
+
+## Contract and auth boundary
+
+The example reuses `ecc.memory.v1` without adding fields. Project and team are
+the default scopes; user recall requires an explicit request and MCP host
+opt-in. The host pins `ECC_MEMORY_HARNESS`; clients cannot supply their own
+source identity or target filter through tool arguments. All writes remain
+`unreviewed` context subordinate to current instructions.
+
+The fixture body uses `ecc.memory.example-evidence.v1`, an **example-local**
+JSON envelope inside the existing Markdown body. No fields are added to
+`ecc.memory.v1`. `evidence.cjs` checks a source reference, content digest,
+observation time, session ID and checkpoint ID against an independent,
+host-owned in-memory catalog. The envelope text must equal the catalog's exact
+source bytes. There is no summary/derivation validation in this example.
+
+The verifier requires an exact workspace and scope match. Context is supplied
+by the example host using the selected vault and returned memory scope; it is
+not accepted from claims in the envelope. Only bounded `fixture:` identifiers
+are supported, with no path/URL lookup, filesystem read, network fallback or
+ambient source discovery. Missing evidence fails explicitly. Success returns
+`source-content-match`, never a trust promotion. The original observation time
+is compared to the catalog, not treated as proof of current factual validity.
+
+This verifies integrity relative to the host's catalog, not signed authorship,
+identity authentication, an immutable journal or statement truth. An operator
+who rewrites both catalog and memory can create another matching pair. The
+catalog is synthetic, process-local and not a durable archive; references do
+not promise continued source availability. The verifier does not execute
+memory text or make it authoritative. All vault records remain `unreviewed`.
+
+Run the pure in-memory negative and boundary checks separately:
+
+```sh
+node examples/unified-memory/evidence.test.cjs
+```
+
+These checks cover changed text, recomputed/altered digests, altered timestamps,
+session/checkpoint substitutions, missing sources, workspace/scope mismatches,
+unknown fields/schema, malformed/oversized envelopes and invalid host inputs.
+They start no server and require only Node built-ins. The conformance runner
+also saves two deliberately altered synthetic envelopes: core storage accepts
+unreviewed context, while this example's verifier rejects those recalled bodies.
+The verifier is not automatically enabled in core CLI/MCP save or recall paths.
+
+The private-key rejection fixture is a deliberately incomplete marker containing
+no key material. It exercises the existing best-effort secret scanner, not a
+complete privacy classifier or permission system. Never substitute private
+transcripts, credentials or production records into the public example.
+
+`targetHarnesses` constrains MCP routing, not same-user filesystem access. The
+CLI is an operator interface: direct CLI reads can access a targeted record
+without a harness target filter, and the CLI can choose source attribution.
+Separate OS accounts or equivalent filesystem isolation are necessary when
+local processes are mutually untrusted.
+
+The example provides no unified OAuth, delegated credential lifecycle, plan
+token routing, cross-machine synchronization, Graphiti partition policy, or
+Hermes MemoryProvider integration. A future backend adapter must preserve the
+existing record contract and enforce its authenticated partition policy
+separately from routing metadata.
+
+See [the memory vault design](../../docs/design/ecc-memory-vault.md) for the
+canonical storage and threat contract.
diff --git a/examples/unified-memory/conformance.cjs b/examples/unified-memory/conformance.cjs
new file mode 100644
index 000000000..a8fb424fe
--- /dev/null
+++ b/examples/unified-memory/conformance.cjs
@@ -0,0 +1,246 @@
+'use strict';
+
+// Runs existing ECC code against disposable synthetic vaults. No service or SDK installs.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { spawnSync } = require('node:child_process');
+const { encodeEvidence, verifyEvidence } = require('./evidence.cjs');
+
+const repo = path.resolve(__dirname, '../..');
+const sha256 = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
+const cleanEnv = { PATH: process.env.PATH || '/usr/bin:/bin' };
+// Use the already installed Ajv; no package manager or network operation occurs.
+let dependencyRoot;
+try {
+ dependencyRoot = path.dirname(path.dirname(require.resolve('ajv/package.json')));
+} catch {
+ process.stderr.write('ECC memory example requires the existing Ajv runtime dependency.\n');
+ process.exit(1);
+}
+const sourcePaths = [
+ 'scripts/memory.js', 'scripts/memory-mcp.mjs', 'scripts/lib/memory-vault.js',
+ 'scripts/lib/memory-vault-format.js', 'scripts/lib/path-safety.js',
+ 'scripts/lib/missing-dependency.js', 'schemas/memory.schema.json', 'package.json',
+ 'examples/unified-memory/evidence.cjs',
+];
+function snapshot() {
+ return Object.fromEntries(sourcePaths.map(file => [file, sha256(fs.readFileSync(path.join(repo, file)))]));
+}
+function sourceHead() {
+ const result = spawnSync('git', ['-C', repo, 'rev-parse', 'HEAD'], {
+ encoding: 'utf8', env: cleanEnv, timeout: 5000, maxBuffer: 1024,
+ });
+ return result.status === 0 && /^[a-f0-9]{40}\s*$/.test(result.stdout) ? result.stdout.trim() : null;
+}
+const before = snapshot();
+const headBefore = sourceHead();
+const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-conformance-'));
+const checks = [];
+const startedAt = new Date().toISOString();
+function envFor(partition = 'alpha', harness = 'codex', allowUser = false) {
+ const cwd = path.join(root, partition);
+ fs.mkdirSync(cwd, { recursive: true });
+ return { cwd, env: { ...cleanEnv,
+ NODE_PATH: dependencyRoot,
+ ECC_MEMORY_PROJECT_ROOT: path.join(cwd, 'vault'),
+ ECC_MEMORY_USER_ROOT: path.join(root, 'synthetic-user'),
+ ...(harness ? { ECC_MEMORY_HARNESS: harness } : {}),
+ ECC_MEMORY_ALLOW_USER_SCOPE: allowUser ? '1' : '0',
+ } };
+}
+function run(script, args, input, options) {
+ return spawnSync(process.execPath, [path.join(repo, script), ...args], {
+ ...options, input, encoding: 'utf8', timeout: 10000, maxBuffer: 2 * 1024 * 1024,
+ });
+}
+function cli(args, input = '', partition = 'alpha') {
+ const result = run('scripts/memory.js', [...args, '--json'], input, envFor(partition));
+ assert.equal(result.status, 0, 'Synthetic CLI operation failed; raw output withheld');
+ return JSON.parse(result.stdout);
+}
+function mcp(harness, calls, partition = 'alpha', allowUser = false) {
+ const frames = [
+ { jsonrpc: '2.0', id: 1, method: 'initialize', params: {
+ protocolVersion: '2025-11-25', capabilities: {},
+ clientInfo: { name: 'ecc-lane-conformance', version: '1.0.0' },
+ } },
+ { jsonrpc: '2.0', method: 'notifications/initialized', params: {} },
+ ...calls.map(([name, args], index) => ({ jsonrpc: '2.0', id: index + 2,
+ method: 'tools/call', params: { name, arguments: args } })),
+ ];
+ const result = run('scripts/memory-mcp.mjs', [],
+ frames.map(frame => JSON.stringify(frame)).join('\n') + '\n', envFor(partition, harness, allowUser));
+ assert.equal(result.status, 0, 'Synthetic MCP process failed; raw output withheld');
+ const responses = result.stdout.trim().split('\n').map(line => JSON.parse(line));
+ assert.equal(responses.length, calls.length + 1, 'Missing or extra MCP response');
+ assert.equal(responses[0].result.protocolVersion, '2025-11-25');
+ return calls.map((_, index) => {
+ const response = responses.find(item => item.id === index + 2);
+ assert.ok(response, 'Missing correlated MCP response');
+ return response;
+ });
+}
+function payload(response) {
+ assert.equal(response.error, undefined, 'Unexpected JSON-RPC error');
+ assert.notEqual(response.result.isError, true, 'Unexpected tool rejection');
+ return JSON.parse(response.result.content.find(item => item.type === 'text').text);
+}
+function check(name, fn) { fn(); checks.push({ name, passed: true }); }
+function save(title, scope = 'project', target = 'all', partition = 'alpha', body = 'Synthetic orbit evidence.') {
+ return cli(['save', '--title', title, '--scope', scope, '--source-harness', 'codex',
+ '--target', target, '--stdin'], body, partition).memory;
+}
+
+try {
+ const sourceText = 'Synthetic fixture only: orbit project uses scoped memory.';
+ // Kept separately from recalled content; memory cannot supply its own source catalog.
+ const sources = new Map([['fixture:orbit', Object.freeze({ workspace: 'alpha', scope: 'project', text: sourceText,
+ observedAt: startedAt, sessionId: 'fixture-session', checkpointId: 'fixture-checkpoint' })]]);
+ const evidenceContext = { workspace: 'alpha', scope: 'project' };
+ const body = encodeEvidence('fixture:orbit', sources, evidenceContext);
+ const shared = save('orbit shared evidence', 'project', 'all', 'alpha', body);
+ const team = save('orbit team context', 'team');
+ const targeted = save('orbit codex context', 'project', 'codex');
+ const user = save('orbit user context', 'user');
+ const other = save('orbit other project', 'project', 'all', 'beta');
+
+ for (const harness of ['codex', 'claude', 'hermes']) {
+ const result = mcp(harness, [
+ ['memory_search', { query: 'orbit' }],
+ ['memory_read', { id: shared.id }],
+ ['memory_read', { id: targeted.id }],
+ ['memory_search', { query: 'orbit', scopes: ['user'] }],
+ ['memory_save', { title: 'spoof', body: 'Synthetic', sourceHarness: 'other' }],
+ ['memory_search', { query: 'orbit', targetHarness: 'codex' }],
+ ['memory_save', { title: 'trusted', body: 'Synthetic', trust: 'verified' }],
+ ['memory_read', { id: user.id, scope: 'user' }],
+ ['memory_save', { title: 'user write', body: 'Synthetic', scope: 'user' }],
+ ]);
+ check(`${harness}: CLI/MCP ordered search parity`, () => {
+ const expected = cli(['search', 'orbit', '--target-harness', harness]);
+ assert.deepEqual(payload(result[0]).results, expected.results.map(({ memory, score, excerpt }) => ({ memory, score, excerpt })));
+ const ids = payload(result[0]).results.map(item => item.memory.id);
+ assert.ok(ids.includes(shared.id) && ids.includes(team.id));
+ assert.equal(ids.includes(targeted.id), harness === 'codex');
+ assert.ok(!ids.includes(user.id) && !ids.includes(other.id));
+ });
+ check(`${harness}: read preserves provenance and unreviewed trust`, () => {
+ const read = payload(result[1]).memory;
+ assert.equal(read.body, body);
+ for (const field of ['id', 'scope', 'sourceHarness', 'targetHarnesses', 'createdAt', 'updatedAt', 'trust']) {
+ assert.deepEqual(read[field], shared[field]);
+ }
+ assert.equal(read.trust, 'unreviewed');
+ const cliRead = cli(['read', shared.id]).memory;
+ assert.deepEqual(verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }),
+ verifyEvidence(cliRead.body, sources, { workspace: 'alpha', scope: cliRead.scope }));
+ });
+ check(`${harness}: direct target visibility enforced by MCP`, () => {
+ if (harness === 'codex') assert.equal(payload(result[2]).memory.id, targeted.id);
+ else assert.equal(result[2].result.isError, true);
+ });
+ check(`${harness}: scope elevation, identity spoofing and trust promotion rejected`, () => {
+ for (const response of result.slice(3)) assert.equal(response.error?.code, -32602);
+ });
+ check(`${harness}: query reproducible across process restart`, () => {
+ assert.deepEqual(payload(mcp(harness, [['memory_search', { query: 'orbit' }]])[0]), payload(result[0]));
+ });
+ }
+ check('MCP write identity and evidence survive CLI handoff read', () => {
+ sources.set('fixture:handoff', Object.freeze({ workspace: 'alpha', scope: 'project', text: 'Synthetic handoff.',
+ observedAt: startedAt, sessionId: 'fixture-hermes-session', checkpointId: 'fixture-handoff' }));
+ const handoffBody = encodeEvidence('fixture:handoff', sources, evidenceContext);
+ const saved = payload(mcp('hermes', [['memory_save', { title: 'handoff fixture', body: handoffBody,
+ kind: 'handoff', targetHarnesses: ['codex'], links: [shared.id] }]])[0]).memory;
+ assert.equal(saved.sourceHarness, 'hermes');
+ assert.equal(saved.trust, 'unreviewed');
+ const read = payload(mcp('codex', [['memory_read', { id: saved.id }]])[0]).memory;
+ assert.deepEqual(read.links, [shared.id]);
+ const cliRead = cli(['read', saved.id]).memory;
+ assert.equal(cliRead.body, handoffBody);
+ assert.equal(cliRead.sourceHarness, 'hermes');
+ assert.equal(cliRead.trust, 'unreviewed');
+ assert.deepEqual(verifyEvidence(cliRead.body, sources, { workspace: 'alpha', scope: cliRead.scope }),
+ verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }));
+ });
+ check('operator opt-in enables only explicit user recall', () => {
+ const result = mcp('hermes', [['memory_search', { query: 'orbit', scopes: ['user'] }],
+ ['memory_search', { query: 'orbit' }]], 'alpha', true);
+ assert.deepEqual(payload(result[0]).results.map(item => item.memory.id), [user.id]);
+ assert.ok(!payload(result[1]).results.some(item => item.memory.id === user.id));
+ });
+ check('separate project root excludes alpha records', () => {
+ const read = mcp('hermes', [['memory_search', { query: 'orbit' }], ['memory_read', { id: shared.id }]], 'beta');
+ assert.deepEqual(payload(read[0]).results.map(item => item.memory.id), [other.id]);
+ assert.equal(read[1].result.isError, true);
+ });
+ check('CLI direct read is operator access, not target authorization', () => {
+ assert.equal(cli(['read', targeted.id]).memory.id, targeted.id);
+ });
+ check('missing configured identity prevents MCP startup', () => {
+ const result = run('scripts/memory-mcp.mjs', [], '', envFor('alpha', null));
+ assert.equal(result.status, 1);
+ assert.match(result.stderr, /ECC_MEMORY_HARNESS/);
+ });
+ check('recalled evidence rejects tamper, unavailable source and foreign context', () => {
+ const read = payload(mcp('codex', [['memory_read', { id: shared.id }]])[0]).memory;
+ const altered = JSON.stringify({ ...JSON.parse(read.body), text: 'Synthetic altered evidence.' });
+ assert.throws(() => verifyEvidence(altered, sources, evidenceContext), { code: 'SOURCE_MISMATCH' });
+ assert.throws(() => verifyEvidence(read.body, new Map(), evidenceContext), { code: 'SOURCE_UNAVAILABLE' });
+ assert.throws(() => verifyEvidence(read.body, sources, { ...evidenceContext, workspace: 'beta' }),
+ { code: 'CONTEXT_MISMATCH' });
+ assert.throws(() => verifyEvidence(read.body, sources, { ...evidenceContext, scope: 'user' }),
+ { code: 'CONTEXT_MISMATCH' });
+ });
+ check('stored altered content and digest fail evidence verification after MCP recall', () => {
+ for (const change of [{ text: 'Synthetic altered content.' }, { sha256: '0'.repeat(64) }]) {
+ const altered = JSON.stringify({ ...JSON.parse(body), ...change });
+ const saved = save('evidence rejection fixture', 'project', 'all', 'alpha', altered);
+ const read = payload(mcp('hermes', [['memory_read', { id: saved.id }]])[0]).memory;
+ assert.equal(read.id, saved.id);
+ assert.equal(read.body, altered);
+ assert.equal(read.trust, 'unreviewed');
+ assert.throws(() => verifyEvidence(read.body, sources, { workspace: 'alpha', scope: read.scope }),
+ { code: 'SOURCE_MISMATCH' });
+ }
+ });
+ check('synthetic private-key marker rejected without changing recalled dataset', () => {
+ // Deliberately incomplete synthetic marker; never a real key or private input.
+ const marker = '-----BEGIN PRIVATE KEY-----\nSynthetic non-key fixture.';
+ const beforePrivacy = cli(['search', 'orbit', '--target-harness', 'codex']).results;
+ const cliDenied = run('scripts/memory.js', ['save', '--title', 'orbit rejected fixture', '--stdin', '--json'],
+ marker, envFor());
+ assert.equal(cliDenied.status, 1, 'Synthetic sensitive write must be rejected');
+ assert.equal(cliDenied.error, undefined, 'CLI rejection must not be a subprocess failure');
+ assert.match(cliDenied.stderr, /suspected secret/i);
+ const mcpDenied = mcp('codex', [['memory_save', { title: 'orbit rejected fixture', body: marker }]])[0];
+ assert.equal(mcpDenied.result.isError, true, 'Synthetic sensitive write must be a tool rejection');
+ const rejection = JSON.parse(mcpDenied.result.content.find(item => item.type === 'text').text);
+ assert.equal(rejection.error.code, 'MEMORY_WRITE_REJECTED');
+ assert.equal(rejection.error.message, 'Memory operation rejected a suspected secret.');
+ assert.deepEqual(cli(['search', 'orbit', '--target-harness', 'codex']).results, beforePrivacy);
+ assert.deepEqual(payload(mcp('codex', [['memory_search', { query: 'orbit' }]])[0]).results, beforePrivacy);
+ });
+ check('source files and HEAD unchanged after execution', () => {
+ assert.deepEqual(snapshot(), before);
+ assert.equal(sourceHead(), headBefore);
+ });
+ process.stdout.write(JSON.stringify({ schemaVersion: 'ecc.memory.conformance.receipt.v1',
+ status: 'passed', startedAt, completedAt: new Date().toISOString(), nodeVersion: process.version,
+ source: { head: headBefore, files: before,
+ executionMode: 'local source files with existing dependencies; no fetch performed',
+ identityBoundary: 'File digests identify executed source; HEAD alone does not establish a clean tree.' },
+ exampleSha256: sha256(fs.readFileSync(__filename)), checks,
+ evidenceBoundary: 'Synthetic real CLI/stdio execution. No live harness, Graphiti, OAuth, replication or deployment verification.',
+ }, null, 2) + '\n');
+} catch (error) {
+ // Never print raw process output or assertion values into the receipt.
+ process.stderr.write(JSON.stringify({ status: 'failed', passedChecks: checks.map(item => item.name),
+ errorType: error.name, message: 'Conformance failed after the listed checks; inspect the next synthetic operation.' }) + '\n');
+ process.exitCode = 1;
+} finally {
+ fs.rmSync(root, { recursive: true, force: true });
+}
diff --git a/examples/unified-memory/evidence.cjs b/examples/unified-memory/evidence.cjs
new file mode 100644
index 000000000..ba12aaf92
--- /dev/null
+++ b/examples/unified-memory/evidence.cjs
@@ -0,0 +1,79 @@
+'use strict';
+
+// Example-only integrity checks. A host-owned catalog is not an identity provider.
+const { createHash } = require('node:crypto');
+const SCHEMA = 'ecc.memory.example-evidence.v1';
+const MAX_BODY_BYTES = 16 * 1024;
+const MAX_TEXT_BYTES = 8 * 1024;
+const ENVELOPE_KEYS = ['schema', 'sourceRef', 'sha256', 'text', 'observedAt', 'sessionId', 'checkpointId'];
+const SOURCE_KEYS = ['workspace', 'scope', 'text', 'observedAt', 'sessionId', 'checkpointId'];
+const slug = value => typeof value === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(value);
+const sourceRefIsValid = value => typeof value === 'string' && /^fixture:[a-z][a-z0-9-]{0,63}$/.test(value);
+const digest = text => createHash('sha256').update(text, 'utf8').digest('hex');
+
+function fail(code) {
+ const error = new Error(`Memory example evidence: ${code}`);
+ error.code = code;
+ throw error;
+}
+function hasExactKeys(value, keys) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+ && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
+}
+function validObservation(value) {
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false;
+ const date = new Date(value);
+ return Number.isFinite(date.getTime()) && date.toISOString() === value;
+}
+function validSourceFields(value) {
+ return typeof value.text === 'string' && value.text.length > 0 && value.text.length <= MAX_TEXT_BYTES
+ && Buffer.byteLength(value.text, 'utf8') <= MAX_TEXT_BYTES
+ // eslint-disable-next-line no-control-regex -- Intentionally reject C0 except tab/LF/CR, and DEL.
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value.text)
+ && validObservation(value.observedAt) && slug(value.sessionId) && slug(value.checkpointId);
+}
+function validateEnvelope(value) {
+ if (!hasExactKeys(value, ENVELOPE_KEYS) || value.schema !== SCHEMA || !sourceRefIsValid(value.sourceRef)
+ || typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || !validSourceFields(value)) {
+ fail('INVALID_ENVELOPE');
+ }
+}
+function getSource(sourceRef, catalog, context) {
+ if (!hasExactKeys(context, ['workspace', 'scope']) || !slug(context.workspace)
+ || !['project', 'team', 'user'].includes(context.scope)) fail('INVALID_CONTEXT');
+ if (!(catalog instanceof Map) || !sourceRefIsValid(sourceRef)) fail('INVALID_SOURCE');
+ const source = catalog.get(sourceRef);
+ if (source === undefined) fail('SOURCE_UNAVAILABLE');
+ if (!hasExactKeys(source, SOURCE_KEYS) || !validSourceFields(source) || !slug(source.workspace)
+ || !['project', 'team', 'user'].includes(source.scope)) fail('INVALID_SOURCE');
+ if (source.workspace !== context.workspace || source.scope !== context.scope) fail('CONTEXT_MISMATCH');
+ return source;
+}
+function decode(body) {
+ if (typeof body !== 'string' || body.length > MAX_BODY_BYTES || Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES) {
+ fail('INVALID_ENVELOPE');
+ }
+ let value;
+ try { value = JSON.parse(body); } catch { fail('INVALID_ENVELOPE'); }
+ validateEnvelope(value);
+ return value;
+}
+
+function encodeEvidence(sourceRef, catalog, context) {
+ const source = getSource(sourceRef, catalog, context);
+ const body = JSON.stringify({ schema: SCHEMA, sourceRef, sha256: digest(source.text), text: source.text,
+ observedAt: source.observedAt, sessionId: source.sessionId, checkpointId: source.checkpointId });
+ decode(body);
+ return body;
+}
+
+function verifyEvidence(body, catalog, context) {
+ const value = decode(body);
+ const source = getSource(value.sourceRef, catalog, context);
+ if (value.sha256 !== digest(source.text) || value.text !== source.text
+ || value.observedAt !== source.observedAt || value.sessionId !== source.sessionId
+ || value.checkpointId !== source.checkpointId) fail('SOURCE_MISMATCH');
+ return Object.freeze({ status: 'source-content-match', sourceRef: value.sourceRef, sha256: value.sha256 });
+}
+
+module.exports = { encodeEvidence, verifyEvidence };
diff --git a/examples/unified-memory/evidence.test.cjs b/examples/unified-memory/evidence.test.cjs
new file mode 100644
index 000000000..510a67e9c
--- /dev/null
+++ b/examples/unified-memory/evidence.test.cjs
@@ -0,0 +1,109 @@
+'use strict';
+
+// Pure synthetic checks: no subprocess, filesystem fixture, provider or server.
+const assert = require('node:assert/strict');
+const { encodeEvidence, verifyEvidence } = require('./evidence.cjs');
+const sourceRef = 'fixture:orbit';
+const source = Object.freeze({ workspace: 'alpha', scope: 'project',
+ text: 'Synthetic orbit evidence: calibration color is amber.',
+ observedAt: '2026-01-01T00:00:00.000Z', sessionId: 'fixture-session', checkpointId: 'fixture-checkpoint' });
+const context = Object.freeze({ workspace: 'alpha', scope: 'project' });
+const catalog = new Map([[sourceRef, source]]);
+const body = () => encodeEvidence(sourceRef, catalog, context);
+const edit = change => JSON.stringify({ ...JSON.parse(body()), ...change });
+let passed = 0;
+function test(name, fn) {
+ try { fn(); passed += 1; }
+ catch { throw new Error(`Synthetic evidence check failed: ${name}`); }
+}
+function rejects(fn, code) {
+ assert.throws(fn, error => error.code === code
+ && error.message === `Memory example evidence: ${code}`);
+}
+
+test('valid source content and provenance match', () => {
+ const result = verifyEvidence(body(), catalog, context);
+ assert.equal(result.status, 'source-content-match');
+ assert.equal(result.sourceRef, sourceRef);
+ assert.equal(result.sha256, JSON.parse(body()).sha256);
+ assert.ok(Object.isFrozen(result));
+});
+test('deterministic encoding preserves input catalog', () => {
+ const before = JSON.stringify([...catalog]);
+ assert.equal(body(), body());
+ assert.equal(JSON.stringify([...catalog]), before);
+});
+for (const [name, change] of [
+ ['changed text', { text: 'Synthetic altered content.' }],
+ ['changed digest', { sha256: '0'.repeat(64) }],
+ ['changed observation', { observedAt: '2026-01-02T00:00:00.000Z' }],
+ ['changed session', { sessionId: 'other-session' }],
+ ['changed checkpoint', { checkpointId: 'other-checkpoint' }],
+]) {
+ test(name, () => rejects(() => verifyEvidence(edit(change), catalog, context), 'SOURCE_MISMATCH'));
+}
+test('missing source never becomes successful empty evidence', () => {
+ rejects(() => verifyEvidence(body(), new Map(), context), 'SOURCE_UNAVAILABLE');
+});
+test('same reference in another workspace is denied', () => {
+ rejects(() => verifyEvidence(body(), catalog, { ...context, workspace: 'beta' }), 'CONTEXT_MISMATCH');
+});
+test('project evidence cannot be relabeled as user evidence', () => {
+ rejects(() => verifyEvidence(body(), catalog, { ...context, scope: 'user' }), 'CONTEXT_MISMATCH');
+});
+test('creation enforces host context too', () => {
+ rejects(() => encodeEvidence(sourceRef, catalog, { ...context, workspace: 'beta' }), 'CONTEXT_MISMATCH');
+});
+for (const [name, value] of [
+ ['unknown schema', () => edit({ schema: 'unrecognized' })],
+ ['unknown authority field', () => edit({ trust: 'verified' })],
+ ['external URL is not a source lookup', () => edit({ sourceRef: 'https://example.invalid/source' })],
+ ['path is not a source lookup', () => edit({ sourceRef: '../private-source' })],
+ ['invalid timestamp', () => edit({ observedAt: '2026-02-30T00:00:00.000Z' })],
+ ['missing checkpoint', () => { const value = JSON.parse(body()); delete value.checkpointId; return JSON.stringify(value); }],
+ ['malformed JSON', () => '{'],
+ ['non-object JSON', () => 'null'],
+ ['oversized body', () => 'x'.repeat(16385)],
+]) {
+ test(name, () => rejects(() => verifyEvidence(value(), catalog, context), 'INVALID_ENVELOPE'));
+}
+test('unavailable source is also denied during creation', () => {
+ rejects(() => encodeEvidence(sourceRef, new Map(), context), 'SOURCE_UNAVAILABLE');
+});
+test('changed catalog content invalidates a previously encoded body', () => {
+ const changed = new Map([[sourceRef, { ...source, text: 'Synthetic revised evidence.' }]]);
+ rejects(() => verifyEvidence(body(), changed, context), 'SOURCE_MISMATCH');
+});
+test('recomputed attacker digest does not replace host source binding', () => {
+ const crypto = require('node:crypto');
+ const text = 'Synthetic attacker replacement.';
+ const sha256 = crypto.createHash('sha256').update(text).digest('hex');
+ rejects(() => verifyEvidence(edit({ text, sha256 }), catalog, context), 'SOURCE_MISMATCH');
+});
+test('invalid host source is not a record success', () => {
+ const invalid = new Map([[sourceRef, { ...source, text: '' }]]);
+ rejects(() => encodeEvidence(sourceRef, invalid, context), 'INVALID_SOURCE');
+});
+test('invalid host context is denied before source lookup', () => {
+ rejects(() => verifyEvidence(body(), catalog, { workspace: 'alpha', scope: 'all' }), 'INVALID_CONTEXT');
+});
+test('rejects forbidden C0 controls and DEL in source and recalled text', () => {
+ const codes = [...Array.from({ length: 32 }, (_, code) => code), 127]
+ .filter(code => ![9, 10, 13].includes(code));
+ for (const code of codes) {
+ const text = `Synthetic ${String.fromCodePoint(code)} content.`;
+ const invalid = new Map([[sourceRef, { ...source, text }]]);
+ rejects(() => encodeEvidence(sourceRef, invalid, context), 'INVALID_SOURCE');
+ rejects(() => verifyEvidence(edit({ text }), catalog, context), 'INVALID_ENVELOPE');
+ }
+});
+test('preserves allowed whitespace, printable boundaries and non-C0 Unicode', () => {
+ for (const code of [9, 10, 13, 32, 126, 128, 0x2028, 0x1f642]) {
+ const text = `Synthetic ${String.fromCodePoint(code)} content.`;
+ const allowed = new Map([[sourceRef, { ...source, text }]]);
+ const encoded = encodeEvidence(sourceRef, allowed, context);
+ assert.equal(verifyEvidence(encoded, allowed, context).status, 'source-content-match');
+ }
+});
+process.stdout.write(`${JSON.stringify({ status: 'passed', checks: passed,
+ boundary: 'Synthetic in-memory evidence checks; no authentication or runtime-service verification.' })}\n`);
From c7d62c0c6aded44250d33feb5fc8d549dfc54012 Mon Sep 17 00:00:00 2001
From: Affaan Mustafa
Date: Thu, 10 Sep 2026 14:11:51 +0300
Subject: [PATCH 035/141] Distinguish declared goals, open sessions and overlap
risk in coordination inventory (#3028)
* feat: add read-only coordination inventory and overlap evaluation
* test: make coordination process fixtures platform explicit
* test: report bounded Stop wrapper failure diagnostics
* test: clean up failed memory MCP sessions deterministically
* fix: update js-yaml to patched 4.3.2
* feat(coordination): distinguish declared goals from open sessions
---
examples/coordination-inventory/README.md | 150 +++++++
examples/coordination-inventory/benchmark.js | 58 +++
examples/coordination-inventory/evaluate.js | 19 +
examples/coordination-inventory/fixtures.json | 255 ++++++++++++
examples/coordination-inventory/goals.json | 18 +
examples/coordination-inventory/manifest.json | 42 ++
scripts/coordination-inventory.js | 33 ++
scripts/lib/agent-proximity/graph.js | 4 +-
scripts/lib/coordination-inventory.js | 261 ++++++++++++
tests/hooks/stop-hooks-stdout.test.js | 19 +-
tests/scripts/coordination-goals.test.js | 124 ++++++
tests/scripts/coordination-inventory.test.js | 155 ++++++++
tests/scripts/memory-mcp.test.js | 372 +++++++++++++-----
13 files changed, 1405 insertions(+), 105 deletions(-)
create mode 100644 examples/coordination-inventory/README.md
create mode 100644 examples/coordination-inventory/benchmark.js
create mode 100644 examples/coordination-inventory/evaluate.js
create mode 100644 examples/coordination-inventory/fixtures.json
create mode 100644 examples/coordination-inventory/goals.json
create mode 100644 examples/coordination-inventory/manifest.json
create mode 100644 scripts/coordination-inventory.js
create mode 100644 scripts/lib/coordination-inventory.js
create mode 100644 tests/scripts/coordination-goals.test.js
create mode 100644 tests/scripts/coordination-inventory.test.js
diff --git a/examples/coordination-inventory/README.md b/examples/coordination-inventory/README.md
new file mode 100644
index 000000000..97789209e
--- /dev/null
+++ b/examples/coordination-inventory/README.md
@@ -0,0 +1,150 @@
+# Read-only coordination inventory
+
+One local JSON report joins declared task IDs and parent IDs, heartbeat age,
+optional process metadata, OS RAM, declared resource leases and path/import
+warnings. It reuses ECC's orchestration status parser and agent-proximity
+scoring. It does not start a server or send messages.
+
+From the repository root, with Node 18 or newer and no dependency install:
+
+```sh
+node scripts/coordination-inventory.js --manifest examples/coordination-inventory/manifest.json --now 2026-09-08T06:30:00.000Z
+node scripts/coordination-inventory.js --manifest examples/coordination-inventory/goals.json --now 2026-09-08T06:30:00.000Z
+node scripts/coordination-inventory.js --coordination /path/to/coordination --live
+node examples/coordination-inventory/evaluate.js
+node --test tests/scripts/coordination-inventory.test.js
+node --test tests/scripts/coordination-goals.test.js
+node examples/coordination-inventory/benchmark.js
+```
+
+The first command uses a **synthetic** fixed-time fixture. It demonstrates a
+parent/child pair with an import dependency, a stale heartbeat and conflicting
+browser ownership declarations. The file grants no browser access.
+
+`--coordination` reads direct child directories with `STATUS.md` or legacy
+`status.md`. Structured `- State:` and UTC `- Updated:` fields use the existing
+orchestration parser. Freeform status has unknown state/heartbeat; modification
+time is reported separately. Symlink task directories and final status files
+are not followed. Unreadable child directories make discovery partial; an
+unavailable root is explicit, not an empty successful inventory.
+
+`--live` samples OS total/free bytes and, for explicitly declared positive PIDs,
+`ps` PID, parent PID, RSS, elapsed time and state flags on macOS/Linux. It uses a
+two-second timeout without shell expansion. It never reads argv, environment,
+transcripts or process executable names. Unsupported platforms and inaccessible
+process telemetry are explicit. Free memory is not macOS memory pressure or a
+safe allocation budget. No PID supplied means no process scan. PID identity and
+PID reuse are not verified. An old heartbeat means inspection is useful; it
+cannot prove that a process is stuck.
+
+## Manifest contract
+
+See `manifest.json`. Version 1 accepts repositories with IDs and source snippet
+maps, tasks with IDs, optional parent IDs, repository IDs, repo-relative declared
+paths, optional PIDs/status/UTC heartbeat times, and leases with resource, owner
+and UTC expiry. Parent IDs can reference an external orchestrator. Repository
+IDs scope warnings across separate checkouts; use the same logical repo ID for
+workers editing the same repository. Duplicate task IDs are rejected, including
+when combining a manifest with discovered status files.
+
+Bounds: 1 MiB JSON, 64 tasks/repositories, 128 paths per task, 128 snippets per
+repository, 1 KiB per snippet and 32 KiB snippets total, 128 leases. Snippets can
+be just import statements plus empty entries for known targets. They are parsed
+as text, never executed or emitted in the report. An aggregate comparison budget
+rejects excessive pair/graph work; split large inputs into smaller inventories.
+Only provide nonsensitive metadata in task IDs, status fields and paths.
+
+Every result identifies coverage. Paths are declared intentions, not a scan of
+all current edits. Only supplied relative JS/TS imports resolve. Missing paths
+or source snippets mean incomplete visibility. Existing control-pane default
+working sets use committed `base...HEAD` differences and can miss dirty and
+untracked work; this example does not claim to fix that separate adapter.
+
+Leases are owner declarations, not enforced locks. Expired entries are visible
+but excluded from simultaneous-owner conflicts. An unexpired entry does not
+prove the owner is alive or authorized. The caller supplies those declarations;
+the inventory never acquires, renews or releases leases. No lease records means
+ownership is unknown. No pause, steer, kill, settings change or allocation occurs.
+
+## Declared goals and sessions
+
+Optional `goals` and `sessions` collections add observations to the v1 manifest.
+Each accepts at most 64 records, within the same 1 MiB total input budget. IDs
+are unique within each collection. A goal accepts `id`, optional `taskId`,
+`kind` (`native` or `unknown`), `status` (`active`, `complete`, `blocked` or
+`unknown`), and optional UTC `updatedAt`. A session accepts `id`, optional
+`taskId`/`goalId`, `status` (`open`, `closed` or `unknown`) and optional UTC
+`updatedAt`. Omitted kind/status defaults to `unknown`; invalid supplied enum
+values and scalar collection types are rejected. Supplied non-null links must
+reference a supplied task or goal. These are associations, not exclusive owners;
+multiple sessions may reference one goal without counting that goal twice.
+
+`goals.json` is synthetic: three open sessions reference one active goal, one
+completed goal and one missing goal declaration. At its fixed example time the
+report has one `freshActiveNativeGoalDeclarations` and one
+`openSessionsWithoutGoalDeclaration`. An open session linked to a completed goal
+stays open while the goal stays complete. Neither status overwrites the other.
+
+Every goal/session record has `authority: "declared-only"`. Even `kind: "native"`
+is the caller's claim, not a native goal-tool verification. Supply a nonsensitive
+observation derived from an authorized tool receipt; do not paste raw tool blobs,
+objective text, transcripts or credentials. Unrecognized fields are omitted from
+reports. The inventory never reads private thread stores or automatically imports
+GOAL-STATE files. The caller retains the receipt and its provenance separately.
+
+`coverage.goals` and `coverage.sessions` distinguish `missing` collections from
+`declared-only` collections, including explicitly empty arrays. Neither proves
+global absence. `activity` contains declaration counts by status, native-kind
+declaration counts, open sessions without goal links and the number of fresh
+active native-kind declarations. These count records, not task associations or
+verified running processes. No goal is inferred from a terminal, task `status`,
+heartbeat, PID, resource lease or status-file modification time.
+
+Freshness uses the existing five-minute observation threshold: exactly five
+minutes old is fresh, older is stale, future observations are `clock-skew`, and
+missing timestamps are unknown. It does not rewrite declared state, and even a
+fresh active declaration does not prove current execution. Goal/session state
+never suppresses overlap warnings or expands process probing. Ownership remains
+in declared paths and resource leases; no pause, message, steer or permission
+grant is triggered by any count or warning.
+
+Existing task, warning, resource and lease outputs are unchanged. The new arrays,
+activity summary and coverage keys are additive v1 output; consumers that reject
+unknown fields need updating. Older consumers will ignore these declarations.
+This remains a source-checkout example; these commands/examples are not claimed
+to be shipped in the npm package.
+
+## Evaluation and limitations
+
+Eight authored synthetic pairs compare an exact-path baseline with ECC's
+existing overlap/import/tree heuristic, using threshold 0.35. Tree proximity
+alone does not trigger a warning. The score is not a calibrated probability.
+
+| Detector | True positive | False positive | True negative | False negative |
+| --- | ---: | ---: | ---: | ---: |
+| Exact path | 1 | 0 | 4 | 3 |
+| Path and import | 2 | 1 | 3 | 2 |
+
+The extra detection is a direct relative import. A commented import produces
+one false positive; an alias and a cross-artifact relationship are missed. These
+are explicit characterization cases, not a held-out benchmark. Source parsing
+is regex-based and incomplete; hashed visual coordinates, semantic/PCA proximity,
+predictive proximity and 85% conflict reduction are not validated here.
+
+Next experiment: freeze 20 paired isolated tasks and collect declared intent,
+actual changed paths and import edges in shadow mode. Have a human label which
+pairs needed coordination before inspecting scores. Report precision, recall,
+alerts per pair and p50/p95 overhead against exact-path and isolation-only
+baselines. After that, randomize warning display and measure conflict/rework
+rate with the same task mix. No automatic pause until warning usefulness and
+ownership enforcement are separately established.
+
+The dependency-free `benchmark.js` characterizes the legacy fixture, declared
+fixture and 64-goal/64-session limit with five warmup batches and 31 measured
+batches of ten inventory builds each. It reports median/p95 batch-average
+milliseconds, sample counts, fixed input hashes and the same eight overlap
+controls. It excludes process startup and CLI I/O; the declaration-limit workload
+is not a worst-case graph benchmark. Compare identical input hashes, Node runtime
+and parameters before/after on the same machine. Historical one-shot elapsed
+time is not a comparable speedup baseline. No performance improvement or conflict
+reduction is asserted from merely adding these observations.
diff --git a/examples/coordination-inventory/benchmark.js b/examples/coordination-inventory/benchmark.js
new file mode 100644
index 000000000..c1171aeba
--- /dev/null
+++ b/examples/coordination-inventory/benchmark.js
@@ -0,0 +1,58 @@
+#!/usr/bin/env node
+'use strict';
+const { performance } = require('node:perf_hooks');
+const { createHash } = require('node:crypto');
+const { buildInventory } = require('../../scripts/lib/coordination-inventory');
+const legacy = require('./manifest.json');
+const declared = require('./goals.json');
+const controls = require('./fixtures.json');
+const now = '2026-09-08T06:30:00.000Z';
+const parameters = { warmupBatches: 5, samples: 31, iterationsPerSample: 10 };
+const atLimit = { ...legacy,
+ goals: Array.from({ length: 64 }, (_, i) => ({ id: `g${i}`, taskId: 'a',
+ kind: 'native', status: 'active', updatedAt: now })),
+ sessions: Array.from({ length: 64 }, (_, i) => ({ id: `s${i}`, taskId: 'a',
+ goalId: `g${i}`, status: 'open', updatedAt: now }))
+};
+
+function measure(name, manifest) {
+ const batch = () => {
+ for (let i = 0; i < parameters.iterationsPerSample; i += 1) buildInventory(manifest, { now });
+ };
+ for (let i = 0; i < parameters.warmupBatches; i += 1) batch();
+ const samples = Array.from({ length: parameters.samples }, () => {
+ const start = performance.now(); batch();
+ return (performance.now() - start) / parameters.iterationsPerSample;
+ }).sort((a, b) => a - b);
+ const report = buildInventory(manifest, { now });
+ const input = JSON.stringify(manifest);
+ return { name, inputBytes: Buffer.byteLength(input),
+ inputSha256: createHash('sha256').update(input).digest('hex'),
+ medianMs: samples[Math.floor(samples.length / 2)],
+ p95Ms: samples[Math.ceil(samples.length * 0.95) - 1], samplesMs: samples,
+ warnings: report.warnings, activity: report.activity ?? null };
+}
+
+const rows = controls.map(control => {
+ const [a, b] = control.manifest.tasks;
+ return { id: control.id, needsReview: control.needsReview,
+ exactPath: a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p)),
+ pathAndImport: buildInventory(control.manifest, { now }).warnings.length > 0 };
+});
+const matrix = detector => rows.reduce((result, row) => {
+ const key = row.needsReview ? (row[detector] ? 'truePositive' : 'falseNegative')
+ : (row[detector] ? 'falsePositive' : 'trueNegative');
+ return { ...result, [key]: result[key] + 1 };
+}, { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 });
+const report = {
+ version: 1, mode: 'synthetic-local-characterization', node: process.version,
+ platform: process.platform, parameters,
+ workloads: [measure('legacy', legacy), measure('declared', declared), measure('declaration-limit', atLimit)],
+ overlapControls: { dataset: 'eight-authored-synthetic-pairs-v1', rows,
+ baseline: matrix('exactPath'), candidate: matrix('pathAndImport') },
+ limits: ['Batch average buildInventory time excludes process startup and CLI I/O.',
+ 'Declaration-limit uses 64 goals and 64 sessions; it is not a maximum graph-work benchmark.',
+ 'Timing is machine-dependent; no production conflict reduction or 85% improvement claim.',
+ 'Declarations are caller input, not verified native goal or session execution.']
+};
+process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
diff --git a/examples/coordination-inventory/evaluate.js b/examples/coordination-inventory/evaluate.js
new file mode 100644
index 000000000..6449b52ea
--- /dev/null
+++ b/examples/coordination-inventory/evaluate.js
@@ -0,0 +1,19 @@
+#!/usr/bin/env node
+'use strict';
+const { performance } = require('node:perf_hooks');
+const { buildInventory } = require('../../scripts/lib/coordination-inventory');
+const cases = require('./fixtures.json');
+function matrix() { return { truePositive: 0, falsePositive: 0, trueNegative: 0, falseNegative: 0 }; }
+function add(m, expected, actual) { m[expected ? actual ? 'truePositive' : 'falseNegative' : actual ? 'falsePositive' : 'trueNegative'] += 1; }
+const baseline = matrix(); const candidate = matrix();
+const started = performance.now();
+const rows = cases.map(c => {
+ const report = buildInventory(c.manifest, { now: '2026-09-08T06:30:00.000Z' });
+ const [a,b] = c.manifest.tasks;
+ const exactPath = a.repoId === b.repoId && a.paths.some(p => b.paths.includes(p));
+ const warning = report.warnings.length > 0;
+ add(baseline,c.needsReview,exactPath); add(candidate,c.needsReview,warning);
+ return { id: c.id, needsReview: c.needsReview, exactPath, pathAndImport: warning };
+});
+process.stdout.write(`${JSON.stringify({ version:1, dataset:'eight-authored-synthetic-pairs-v1', rows, baseline, candidate,
+ elapsedMs: performance.now()-started, conclusion:'Fixture detection only. Not a measured reduction in conflicts or validation of semantic/PCA proximity.' },null,2)}\n`);
diff --git a/examples/coordination-inventory/fixtures.json b/examples/coordination-inventory/fixtures.json
new file mode 100644
index 000000000..84dddfde5
--- /dev/null
+++ b/examples/coordination-inventory/fixtures.json
@@ -0,0 +1,255 @@
+[
+ {
+ "id": "same-path",
+ "needsReview": true,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {}
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "direct-relative-import",
+ "needsReview": true,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {
+ "src/a.js": "require('../lib/b')",
+ "lib/b.js": ""
+ }
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "lib/b.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "independent",
+ "needsReview": false,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {}
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "docs/guide.md"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "same-directory",
+ "needsReview": false,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {}
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "src/b.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "separate-repositories",
+ "needsReview": false,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {}
+ },
+ {
+ "id": "other",
+ "sources": {}
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "other",
+ "paths": [
+ "src/a.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "comment-false-positive",
+ "needsReview": false,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {
+ "src/a.js": "// require('../lib/b')",
+ "lib/b.js": ""
+ }
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "lib/b.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "alias-false-negative",
+ "needsReview": true,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {
+ "src/a.js": "import b from '@lib/b'",
+ "lib/b.js": ""
+ }
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "lib/b.js"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ },
+ {
+ "id": "cross-artifact-false-negative",
+ "needsReview": true,
+ "manifest": {
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {}
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "specs/login.md"
+ ]
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "ui/login.html"
+ ]
+ }
+ ],
+ "leases": []
+ }
+ }
+]
diff --git a/examples/coordination-inventory/goals.json b/examples/coordination-inventory/goals.json
new file mode 100644
index 000000000..415eb0fb1
--- /dev/null
+++ b/examples/coordination-inventory/goals.json
@@ -0,0 +1,18 @@
+{
+ "version": 1,
+ "repositories": [{ "id": "repo", "sources": { "src/a.js": "require('../lib/b')", "lib/b.js": "" } }],
+ "tasks": [
+ { "id": "a", "repoId": "repo", "paths": ["src/a.js"], "status": "running" },
+ { "id": "b", "repoId": "repo", "paths": ["lib/b.js"], "parentId": "a" }
+ ],
+ "goals": [
+ { "id": "goal-active", "taskId": "a", "kind": "native", "status": "active", "updatedAt": "2026-09-08T06:30:00.000Z" },
+ { "id": "goal-complete", "taskId": "b", "kind": "native", "status": "complete", "updatedAt": "2026-09-08T06:30:00.000Z" }
+ ],
+ "sessions": [
+ { "id": "session-active", "taskId": "a", "goalId": "goal-active", "status": "open", "updatedAt": "2026-09-08T06:30:00.000Z" },
+ { "id": "session-open-complete", "taskId": "b", "goalId": "goal-complete", "status": "open" },
+ { "id": "terminal-only", "status": "open" }
+ ],
+ "leases": []
+}
diff --git a/examples/coordination-inventory/manifest.json b/examples/coordination-inventory/manifest.json
new file mode 100644
index 000000000..c0657731e
--- /dev/null
+++ b/examples/coordination-inventory/manifest.json
@@ -0,0 +1,42 @@
+{
+ "version": 1,
+ "repositories": [
+ {
+ "id": "repo",
+ "sources": {
+ "src/a.js": "require('../lib/b')",
+ "lib/b.js": ""
+ }
+ }
+ ],
+ "tasks": [
+ {
+ "id": "a",
+ "repoId": "repo",
+ "paths": [
+ "src/a.js"
+ ],
+ "heartbeatAt": "2026-09-08T06:00:00Z"
+ },
+ {
+ "id": "b",
+ "repoId": "repo",
+ "paths": [
+ "lib/b.js"
+ ],
+ "parentId": "a"
+ }
+ ],
+ "leases": [
+ {
+ "resource": "browser:chrome",
+ "owner": "root",
+ "expiresAt": "2026-09-08T07:00:00Z"
+ },
+ {
+ "resource": "browser:chrome",
+ "owner": "worker",
+ "expiresAt": "2026-09-08T07:00:00Z"
+ }
+ ]
+}
diff --git a/scripts/coordination-inventory.js b/scripts/coordination-inventory.js
new file mode 100644
index 000000000..ec655df1f
--- /dev/null
+++ b/scripts/coordination-inventory.js
@@ -0,0 +1,33 @@
+#!/usr/bin/env node
+'use strict';
+const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('./lib/coordination-inventory');
+
+function main(argv = process.argv.slice(2)) {
+ if (argv.length === 1 && ['--help', '-h'].includes(argv[0])) {
+ process.stdout.write('Usage: node scripts/coordination-inventory.js [--manifest file.json] [--coordination directory] [--live] [--now ISO-UTC]\nRead-only JSON inventory. Live probes only OS memory and declared PIDs. No processes are executed from input.\n');
+ return;
+ }
+ const options = {};
+ for (let i = 0; i < argv.length; i += 1) {
+ const flag = argv[i];
+ if (flag === '--live' && !options.live) options.live = true;
+ else if (['--manifest', '--coordination', '--now'].includes(flag) && !options[flag.slice(2)] && argv[i+1] && !argv[i+1].startsWith('--')) options[flag.slice(2)] = argv[++i];
+ else throw new Error('Invalid inventory arguments. Use --help.');
+ }
+ let manifest = options.manifest ? readJson(options.manifest) : { version: 1, tasks: [], repositories: [], leases: [] };
+ let discovery = null;
+ if (options.coordination) {
+ discovery = collectTaskFiles(options.coordination);
+ // Duplicate IDs are rejected; never silently replace declared ownership.
+ manifest = { ...manifest, tasks: [...(manifest.tasks || []), ...discovery.tasks] };
+ }
+ const normalized = normalizeManifest(manifest);
+ const resources = options.live ? collectResources(normalized.tasks) : undefined;
+ const report = buildInventory(manifest, { now: options.now, resources });
+ if (discovery) report.discovery = { status: discovery.status, unreadable: discovery.unreadable };
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
+}
+if (require.main === module) {
+ try { main(); } catch { process.stderr.write('Inventory failed: invalid arguments or unreadable/invalid input. Use --help.\n'); process.exitCode = 1; }
+}
+module.exports = { main };
diff --git a/scripts/lib/agent-proximity/graph.js b/scripts/lib/agent-proximity/graph.js
index 98bc05a3d..3d4c42ad6 100644
--- a/scripts/lib/agent-proximity/graph.js
+++ b/scripts/lib/agent-proximity/graph.js
@@ -25,9 +25,11 @@ function toRepoRel(repoRoot, absPath) {
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
// and never the target of an in-repo collision.
+// Consume import whitespace once; a word boundary before `from` avoids
+// overlapping whitespace quantifiers on incomplete import statements.
const SPEC_PATTERNS = [
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
- /import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
+ /import\s+(?!\s)(?:[^'"]*?\bfrom\s+)?['"](\.[^'"]+)['"]/g,
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
];
diff --git a/scripts/lib/coordination-inventory.js b/scripts/lib/coordination-inventory.js
new file mode 100644
index 000000000..3835b0453
--- /dev/null
+++ b/scripts/lib/coordination-inventory.js
@@ -0,0 +1,261 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const os = require('node:os');
+const { execFileSync } = require('node:child_process');
+const { collisionRisk } = require('./agent-proximity/distance');
+const { buildDependencyGraphFromSources } = require('./agent-proximity/graph');
+const { parseWorkerStatus } = require('./orchestration-session');
+
+const MAX_BYTES = 1024 * 1024;
+const STALE_MS = 5 * 60 * 1000;
+function invalid() { throw new Error('Invalid coordination input.'); }
+function record(value) {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
+ return value;
+}
+function list(value, max = 64) {
+ if (!Array.isArray(value) || value.length > max) invalid();
+ return value;
+}
+function text(value, max = 200) {
+ if (typeof value !== 'string' || !value.length || value.length > max || [...value].some(c => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127)) invalid();
+ return value;
+}
+function missing(value) { return value === null || value === undefined; }
+function identifier(value) {
+ text(value);
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(value) || ['__proto__', 'constructor', 'prototype'].includes(value)) invalid();
+ return value;
+}
+function timestamp(value) {
+ text(value);
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value) || !Number.isFinite(Date.parse(value))) invalid();
+ const canonical = value.replace(/(?:\.(\d{1,3}))?Z$/, (_, fraction) => `.${(fraction || '').padEnd(3, '0')}Z`);
+ if (new Date(value).toISOString() !== canonical) invalid();
+ return value;
+}
+function relativePath(value) {
+ const p = text(value, 1024).replace(/\\/g, '/').replace(/^\.\//, '');
+ const parts = p.split('/');
+ if (p.startsWith('/') || /^[A-Za-z]:/.test(p) || parts.some(x => !x || ['.', '..', '__proto__', 'constructor', 'prototype'].includes(x))) invalid();
+ return p;
+}
+function unique(items, key) {
+ if (new Set(items.map(x => x[key])).size !== items.length) invalid();
+ return items;
+}
+function normalizeTask(value) {
+ const t = record(value);
+ if (!missing(t.pid) && (!Number.isSafeInteger(t.pid) || t.pid <= 0)) invalid();
+ const id = identifier(t.id);
+ const parentId = t.parentId ? identifier(t.parentId) : null;
+ if (parentId === id) invalid();
+ return {
+ id, parentId, repoId: missing(t.repoId) ? null : identifier(t.repoId),
+ paths: [...new Set(list(t.paths || [], 128).map(relativePath))].sort(),
+ pid: t.pid ?? null, status: missing(t.status) ? 'unknown' : text(t.status),
+ heartbeatAt: missing(t.heartbeatAt) ? null : timestamp(t.heartbeatAt),
+ statusFileModifiedAt: missing(t.statusFileModifiedAt) ? null : timestamp(t.statusFileModifiedAt)
+ };
+}
+function declarationStatus(value, allowed) {
+ if (value === undefined) return 'unknown';
+ if (!allowed.includes(value)) invalid();
+ return value;
+}
+function normalizeDeclarations(manifest, tasks) {
+ const taskIds = new Set(tasks.map(t => t.id));
+ const link = (value, ids) => {
+ if (missing(value)) return null;
+ const id = identifier(value);
+ if (!ids.has(id)) invalid();
+ return id;
+ };
+ const common = value => ({ id: identifier(value.id), taskId: link(value.taskId, taskIds),
+ updatedAt: missing(value.updatedAt) ? null : timestamp(value.updatedAt) });
+ const goals = unique(list(manifest.goals === undefined ? [] : manifest.goals).map(value => {
+ const g = record(value);
+ return { ...common(g), kind: declarationStatus(g.kind, ['native', 'unknown']),
+ status: declarationStatus(g.status, ['active', 'complete', 'blocked', 'unknown']) };
+ }), 'id');
+ const goalIds = new Set(goals.map(g => g.id));
+ const sessions = unique(list(manifest.sessions === undefined ? [] : manifest.sessions).map(value => {
+ const s = record(value);
+ return { ...common(s), goalId: link(s.goalId, goalIds),
+ status: declarationStatus(s.status, ['open', 'closed', 'unknown']) };
+ }), 'id');
+ return { goals, sessions, declarationCoverage: {
+ goals: manifest.goals === undefined ? 'missing' : 'declared-only',
+ sessions: manifest.sessions === undefined ? 'missing' : 'declared-only'
+ } };
+}
+function normalizeManifest(value) {
+ const m = record(value);
+ if (m.version !== 1 || Buffer.byteLength(JSON.stringify(m)) > MAX_BYTES) invalid();
+ let sourceBytes = 0;
+ const repositories = unique(list(m.repositories ?? []).map(value => {
+ const r = record(value); const sourceEntries = Object.entries(record(r.sources ?? {}));
+ if (sourceEntries.length > 128) invalid();
+ const entries = sourceEntries.map(([p, source]) => {
+ // Existing regex extractor is for snippets, not arbitrary full source files.
+ if (typeof source !== 'string' || Buffer.byteLength(source) > 1024) invalid();
+ sourceBytes += Buffer.byteLength(source);
+ if (sourceBytes > 32768) invalid();
+ return [relativePath(p), source];
+ });
+ if (new Set(entries.map(([p]) => p)).size !== entries.length) invalid();
+ return { id: identifier(r.id), sources: Object.fromEntries(entries) };
+ }), 'id');
+ const tasks = unique(list(m.tasks).map(normalizeTask), 'id');
+ const ids = new Set(repositories.map(r => r.id));
+ if (tasks.some(t => t.repoId !== null && !ids.has(t.repoId))) invalid();
+ const leases = list(m.leases ?? [], 128).map(value => {
+ const l = record(value);
+ return { resource: identifier(l.resource), owner: identifier(l.owner), expiresAt: timestamp(l.expiresAt) };
+ });
+ return { version: 1, repositories, tasks, leases, ...normalizeDeclarations(m, tasks) };
+}
+
+function heartbeat(value, nowMs) {
+ if (!value) return { state: 'unknown', ageMs: null };
+ const ageMs = nowMs - Date.parse(value);
+ return { state: ageMs < 0 ? 'clock-skew' : ageMs > STALE_MS ? 'stale' : 'fresh', ageMs };
+}
+function declarationInventory(manifest, nowMs) {
+ const observe = item => ({ ...item, authority: 'declared-only', freshness: heartbeat(item.updatedAt, nowMs) });
+ const goals = manifest.goals.map(observe);
+ const sessions = manifest.sessions.map(observe);
+ const counts = (items, statuses) => Object.fromEntries(statuses.map(status =>
+ [status, items.filter(item => item.status === status).length]));
+ const statuses = ['active', 'complete', 'blocked', 'unknown'];
+ const native = goals.filter(g => g.kind === 'native');
+ return { goals, sessions, activity: {
+ declaredGoalsByStatus: counts(goals, statuses),
+ declaredNativeGoalsByStatus: counts(native, statuses),
+ declaredSessionsByStatus: counts(sessions, ['open', 'closed', 'unknown']),
+ openSessionsWithoutGoalDeclaration: sessions.filter(s => s.status === 'open' && s.goalId === null).length,
+ freshActiveNativeGoalDeclarations: native.filter(g => g.status === 'active' && g.freshness.state === 'fresh').length
+ } };
+}
+function proximityWarnings(manifest) {
+ const warnings = [];
+ let workBudget = 200000;
+ for (const repo of manifest.repositories) {
+ const tasks = manifest.tasks.filter(t => t.repoId === repo.id && t.paths.length > 0).sort((a,b) => a.id < b.id ? -1 : 1);
+ if (tasks.length < 2) continue;
+ const parsed = buildDependencyGraphFromSources(repo.sources);
+ const graph = { ...parsed, adjacency: Object.assign(Object.create(null), parsed.adjacency) };
+ const graphCost = 1 + graph.files.length + Object.values(graph.adjacency).reduce((sum, edges) => sum + edges.length, 0);
+ const pathPairs = tasks.reduce((sum, task, i) => sum + task.paths.length * tasks.slice(i + 1).reduce((n, other) => n + other.paths.length, 0), 0);
+ workBudget -= pathPairs * graphCost;
+ if (workBudget < 0) throw new Error('Inventory comparison budget exceeded; split the manifest.');
+ for (let i = 0; i < tasks.length; i += 1) {
+ for (let j = i + 1; j < tasks.length; j += 1) {
+ const a = tasks[i]; const b = tasks[j];
+ const score = collisionRisk({ files: a.paths.map(p => ({ path: p })) }, { files: b.paths.map(p => ({ path: p })) }, graph);
+ if (score.risk < 0.35) continue;
+ const reasons = [];
+ if (score.channels.overlap) reasons.push('path_overlap');
+ if (score.channels.dependency) reasons.push('import_dependency');
+ warnings.push({ repoId: repo.id, tasks: [a.id, b.id], reasons, score: score.risk, channels: score.channels, action: 'review-declared-work' });
+ }
+ }
+ }
+ return warnings;
+}
+function buildInventory(input, options = {}) {
+ const m = normalizeManifest(input);
+ const now = timestamp(options.now || new Date().toISOString());
+ const nowMs = Date.parse(now);
+ const resources = options.resources || { memory: null, processStatus: 'not-requested', processes: [] };
+ const processes = new Map(resources.processes.map(p => [p.pid, p]));
+ const tasks = m.tasks.map(t => ({ ...t, heartbeat: heartbeat(t.heartbeatAt, nowMs),
+ process: processes.has(t.pid) ? { ...processes.get(t.pid), state: 'observed' }
+ : { state: t.pid && resources.processStatus === 'ok' ? 'not-observed' : 'unknown' }
+ }));
+ const leases = m.leases.map(l => ({ ...l, state: Date.parse(l.expiresAt) > nowMs ? 'unexpired' : 'expired', authority: 'declared-only' }));
+ const active = new Map();
+ for (const l of leases.filter(l => l.state === 'unexpired')) {
+ active.set(l.resource, new Set([...(active.get(l.resource) || []), l.owner]));
+ }
+ const leaseConflicts = [...active].filter(([,owners]) => owners.size > 1)
+ .map(([resource,owners]) => ({ resource, owners: [...owners].sort() })).sort((a,b) => a.resource < b.resource ? -1 : 1);
+ return {
+ version: 1, mode: 'read-only', observedAt: now, tasks, leases, leaseConflicts,
+ ...declarationInventory(m, nowMs),
+ resources, warnings: proximityWarnings(m),
+ coverage: { tasks: 'declared-or-status-files-only', workingSets: 'declared-paths-only', imports: 'provided-source-map-relative-js-ts-only', leases: 'declared-only', processes: 'declared-pids-only', ...m.declarationCoverage },
+ limits: ['Score is a heuristic, not a calibrated probability.', 'No warning does not establish collision-free work.',
+ 'Goal/session states and native kind are caller declarations, not verified execution or authority.',
+ 'Open sessions, task status and observed PIDs do not establish an active native goal.',
+ 'Missing declarations and empty lists do not establish global absence; fresh declarations do not prove current execution.',
+ 'Stale heartbeat is not proof of a stuck process; PID reuse is not resolved.',
+ 'Import regex may match comments and misses aliases, nonliteral and non-JS imports.',
+ 'No semantic/PCA proximity or conflict-reduction claim is validated.',
+ 'Leases are observations, not locks or permission grants.']
+ };
+}
+
+function collectResources(tasks, deps = {}) {
+ const memory = { totalBytes: (deps.totalmem || os.totalmem)(), freeBytes: (deps.freemem || os.freemem)(),
+ source: 'os', note: 'OS free memory is not application headroom or macOS memory pressure.' };
+ const pids = [...new Set(tasks.map(t => t.pid).filter(pid => Number.isSafeInteger(pid) && pid > 0))];
+ if (!pids.length) return { memory, processStatus: 'not-requested', processes: [] };
+ if (!['darwin', 'linux'].includes(deps.platform || process.platform)) return { memory, processStatus: 'unsupported', processes: [] };
+ try {
+ const result = (deps.execFileSync || execFileSync)('ps', ['-p', pids.join(','), '-o', 'pid=,ppid=,rss=,etime=,stat='],
+ { encoding: 'utf8', timeout: 2000, maxBuffer: 65536, shell: false, stdio: ['ignore','pipe','pipe'] });
+ const processes = String(result).split('\n').filter(l => l.trim()).map(line => {
+ const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:-]+)\s+([A-Za-z+<>NsElLW]+)$/);
+ if (!match) throw new Error('Invalid process metadata.');
+ const values = match.slice(1,4).map(Number);
+ if (values.some(v => !Number.isSafeInteger(v)) || !pids.includes(values[0])) throw new Error('Invalid process metadata.');
+ return { pid: values[0], parentPid: values[1], rssBytes: values[2] * 1024, elapsed: match[4], flags: match[5] };
+ });
+ return { memory, processStatus: 'ok', processes };
+ } catch { return { memory, processStatus: 'unavailable', processes: [] }; }
+}
+
+function readBounded(file, limit = MAX_BYTES) {
+ // Refuse symlink final components, devices and files beyond the byte budget.
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
+ try {
+ const stat = fs.fstatSync(fd);
+ if (!stat.isFile() || stat.size > limit) throw new Error('Input exceeds file limit.');
+ const buffer = Buffer.alloc(limit + 1);
+ let size = 0; let count;
+ do { count = fs.readSync(fd, buffer, size, buffer.length - size, null); size += count; } while (count && size < buffer.length);
+ if (size > limit) throw new Error('Input exceeds file limit.');
+ return { content: buffer.subarray(0,size).toString('utf8'), modifiedAt: stat.mtime.toISOString() };
+ } finally { fs.closeSync(fd); }
+}
+function readJson(file) {
+ try { return JSON.parse(readBounded(file).content); }
+ catch (error) { throw new Error(error.message === 'Input exceeds file limit.' ? error.message : 'Cannot read coordination JSON.'); }
+}
+function collectTaskFiles(directory) {
+ try {
+ const entries = fs.readdirSync(directory, { withFileTypes: true }).filter(e => e.isDirectory() && !e.name.startsWith('.')).sort((a,b) => a.name < b.name ? -1 : 1);
+ if (entries.length > 64) throw new Error('Too many task directories.');
+ const tasks = []; const unreadable = [];
+ for (const entry of entries) {
+ let loaded = false;
+ for (const name of ['STATUS.md', 'status.md']) {
+ try {
+ const data = readBounded(path.join(directory, entry.name, name), 65536);
+ const parsed = parseWorkerStatus(data.content);
+ let heartbeatAt = null;
+ try { if (parsed.updated) heartbeatAt = timestamp(parsed.updated); } catch { /* Unknown timestamp, not a heartbeat. */ }
+ tasks.push(normalizeTask({ id: entry.name, paths: [], status: parsed.state || 'unknown', heartbeatAt, statusFileModifiedAt: data.modifiedAt }));
+ loaded = true; break;
+ } catch { /* Try legacy lowercase status filename; report unreadable below. */ }
+ }
+ if (!loaded) unreadable.push(entry.name);
+ }
+ return { status: unreadable.length ? 'partial' : 'ok', tasks, unreadable };
+ } catch { return { status: 'unavailable', tasks: [], unreadable: [] }; }
+}
+
+module.exports = { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson };
diff --git a/tests/hooks/stop-hooks-stdout.test.js b/tests/hooks/stop-hooks-stdout.test.js
index 02a4bf3ce..3d0617c57 100644
--- a/tests/hooks/stop-hooks-stdout.test.js
+++ b/tests/hooks/stop-hooks-stdout.test.js
@@ -127,6 +127,21 @@ function assertStdoutContract(result, label) {
}
}
+function formatSpawnFailure(result, elapsedMs) {
+ const token = value => typeof value === 'string' && /^[A-Z][A-Z0-9_]{0,47}$/.test(value)
+ ? value : null;
+ // Keep decoded UTF-8 byte counts, never stream contents or error messages.
+ const byteCount = value => typeof value === 'string' ? Buffer.byteLength(value, 'utf8') : null;
+ return JSON.stringify({
+ elapsedMs: Number.isSafeInteger(elapsedMs) && elapsedMs >= 0 ? elapsedMs : null,
+ status: Number.isSafeInteger(result.status) ? result.status : null,
+ signal: token(result.signal),
+ errorCode: token(result.error && result.error.code),
+ stdoutBytes: byteCount(result.stdout),
+ stderrBytes: byteCount(result.stderr)
+ });
+}
+
// All registered Stop hooks (hooks/hooks.json).
const STOP_HOOKS = [
['stop:format-typecheck', 'scripts/hooks/stop-format-typecheck.js'],
@@ -163,11 +178,13 @@ const realisticPayload = stopPayload(100 * 1024);
for (const entry of hooksConfig.hooks.Stop) {
if (
test(`${entry.id} registered wrapper flushes a 100KB Stop payload`, () => {
+ const startedAt = process.hrtime.bigint();
const result = runRegisteredStopHook(entry, realisticPayload);
+ const elapsedMs = Math.round(Number(process.hrtime.bigint() - startedAt) / 1e6);
assert.strictEqual(
result.status,
0,
- `${entry.id}: expected exit 0, got ${result.status}: ${result.stderr}`
+ result.status === 0 ? undefined : `${entry.id}: expected exit 0; ${formatSpawnFailure(result, elapsedMs)}`
);
assert.ok(
result.stdout === realisticPayload,
diff --git a/tests/scripts/coordination-goals.test.js b/tests/scripts/coordination-goals.test.js
new file mode 100644
index 000000000..d2b9e1b14
--- /dev/null
+++ b/tests/scripts/coordination-goals.test.js
@@ -0,0 +1,124 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const { buildInventory, normalizeManifest } = require('../../scripts/lib/coordination-inventory');
+const now = '2026-09-09T01:00:00.000Z';
+const fixture = () => ({ version: 1,
+ repositories: [{ id: 'repo', sources: {} }],
+ tasks: [{ id: 'worker', repoId: 'repo', paths: ['src/shared.js'], status: 'running', pid: 42 },
+ { id: 'peer', repoId: 'repo', paths: ['src/shared.js'] }], leases: [] });
+const inventory = value => buildInventory(value, { now });
+
+test('goal collections distinguish missing observations from explicit empty declarations', () => {
+ const missing = inventory(fixture());
+ const empty = inventory({ ...fixture(), goals: [], sessions: [] });
+ assert.equal(missing.coverage.goals, 'missing');
+ assert.equal(missing.coverage.sessions, 'missing');
+ assert.equal(empty.coverage.goals, 'declared-only');
+ assert.equal(empty.coverage.sessions, 'declared-only');
+ assert.deepEqual(missing.goals, []);
+ assert.deepEqual(missing.sessions, []);
+ assert.deepEqual(missing.activity, empty.activity);
+ assert.equal(missing.activity.freshActiveNativeGoalDeclarations, 0);
+});
+
+test('goal activity is never inferred from an open session, running task, heartbeat or observed PID', () => {
+ const input = fixture(); input.tasks[0].heartbeatAt = now;
+ input.sessions = [{ id: 'terminal', taskId: 'worker', status: 'open', updatedAt: now }];
+ const report = buildInventory(input, { now, resources: {
+ memory: null, processStatus: 'ok', processes: [{ pid: 42, ppid: 1, rssBytes: 1024 }] } });
+ assert.equal(report.tasks[0].process.state, 'observed');
+ assert.equal(report.tasks[0].heartbeat.state, 'fresh');
+ assert.equal(report.activity.declaredSessionsByStatus.open, 1);
+ assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 1);
+ assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 0, complete: 0, blocked: 0, unknown: 0 });
+ assert.equal(report.coverage.goals, 'missing');
+});
+
+test('goal and session declarations remain independent and count a shared goal once', () => {
+ const input = { ...fixture(), goals: [
+ { id: 'active', taskId: 'worker', kind: 'native', status: 'active', updatedAt: now },
+ { id: 'done', kind: 'native', status: 'complete', updatedAt: now },
+ { id: 'unverified', status: 'active', updatedAt: now },
+ { id: 'blocked', kind: 'native', status: 'blocked' }, { id: 'unknown' }
+ ], sessions: [
+ { id: 'closed', goalId: 'active', status: 'closed' },
+ { id: 'other', goalId: 'active', taskId: 'peer', status: 'open' },
+ { id: 'open-done', goalId: 'done', status: 'open' }, { id: 'unknown-session' }
+ ] };
+ const before = JSON.stringify(input); const report = inventory(input);
+ assert.deepEqual(report.activity.declaredGoalsByStatus, { active: 2, complete: 1, blocked: 1, unknown: 1 });
+ assert.deepEqual(report.activity.declaredNativeGoalsByStatus, { active: 1, complete: 1, blocked: 1, unknown: 0 });
+ assert.deepEqual(report.activity.declaredSessionsByStatus, { open: 2, closed: 1, unknown: 1 });
+ assert.equal(report.activity.freshActiveNativeGoalDeclarations, 1);
+ assert.equal(report.activity.openSessionsWithoutGoalDeclaration, 0);
+ assert.equal(report.goals[2].kind, 'unknown');
+ assert.equal(report.goals[4].status, 'unknown');
+ assert.equal(report.sessions[3].status, 'unknown');
+ assert.equal(report.goals[0].authority, 'declared-only');
+ assert.equal(report.sessions[0].authority, 'declared-only');
+ assert.equal(JSON.stringify(input), before);
+ assert.deepEqual(inventory(input), report);
+});
+
+test('goal freshness exposes missing stale future and boundary observations without rewriting status', () => {
+ const times = [null, '2026-09-09T00:54:59.999Z', '2026-09-09T01:00:00.001Z',
+ '2026-09-09T00:55:00.000Z', now];
+ const report = inventory({ ...fixture(), goals: times.map((updatedAt, i) =>
+ ({ id: `g${i}`, kind: 'native', status: 'active', updatedAt })) });
+ assert.deepEqual(report.goals.map(g => g.freshness.state), ['unknown', 'stale', 'clock-skew', 'fresh', 'fresh']);
+ assert.equal(report.activity.declaredNativeGoalsByStatus.active, 5);
+ assert.equal(report.activity.freshActiveNativeGoalDeclarations, 2);
+ assert.ok(report.goals.every(g => g.status === 'active'));
+});
+
+test('goal declarations do not change existing task resource lease or overlap outputs', () => {
+ const base = fixture();
+ base.leases = [{ resource: 'browser', owner: 'worker', expiresAt: now }];
+ const legacy = inventory(base);
+ const report = inventory({ ...base, goals: [{ id: 'completed', status: 'complete' }],
+ sessions: [{ id: 'closed', status: 'closed', goalId: 'completed' }] });
+ for (const key of ['tasks', 'warnings', 'resources', 'leases', 'leaseConflicts']) {
+ assert.deepEqual(report[key], legacy[key]);
+ }
+ assert.equal(report.warnings.length, 1);
+ assert.equal(report.warnings[0].action, 'review-declared-work');
+});
+
+test('goal metadata drops objectives commands native blobs and other unrecognized fields', () => {
+ const report = inventory({ ...fixture(), goals: [{ id: 'g', objective: 'CANARY',
+ tool_result: { secret: 'CANARY' }, status: 'active', authority: 'CANARY' }],
+ sessions: [{ id: 's', goalId: 'g', command: 'CANARY', environment: 'CANARY' }] });
+ assert.ok(!JSON.stringify(report).includes('CANARY'));
+ assert.equal(report.goals[0].authority, 'declared-only');
+});
+
+test('goal input rejects malformed scalars enums dates duplicate IDs and dangling links', () => {
+ for (const collection of ['goals', 'sessions']) {
+ for (const value of [null, false, '', {}, 1]) {
+ assert.throws(() => normalizeManifest({ ...fixture(), [collection]: value }), /Invalid coordination input/);
+ }
+ for (const value of [null, false, [], 1, { id: 'bad/id' }, { id: '__proto__' },
+ { id: 'x', status: null }, { id: 'x', status: true }, { id: 'x', status: 'running' },
+ { id: 'x', updatedAt: '2026-02-30T00:00:00Z' }, { id: 'x', updatedAt: true },
+ { id: 'x', taskId: 'missing' }, { id: 'x', taskId: 1 }]) {
+ assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [value] }), /Invalid coordination input/);
+ }
+ assert.throws(() => normalizeManifest({ ...fixture(), [collection]: [{ id: 'same' }, { id: 'same' }] }));
+ }
+ for (const kind of [null, true, 1, 'verified', 'declared']) {
+ assert.throws(() => normalizeManifest({ ...fixture(), goals: [{ id: 'g', kind }] }));
+ }
+ assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 'missing' }] }));
+ assert.throws(() => normalizeManifest({ ...fixture(), sessions: [{ id: 's', goalId: 1 }] }));
+});
+
+test('goal and session cardinality and total input bounds remain enforced', () => {
+ const declarations = Array.from({ length: 64 }, (_, i) => ({ id: `item${i}` }));
+ const report = inventory({ ...fixture(), goals: declarations, sessions: declarations });
+ assert.equal(report.goals.length, 64); assert.equal(report.sessions.length, 64);
+ for (const collection of ['goals', 'sessions']) {
+ assert.throws(() => inventory({ ...fixture(), [collection]: [...declarations, { id: 'extra' }] }));
+ }
+ assert.throws(() => inventory({ ...fixture(), goals: [{ id: 'g', ignored: 'x'.repeat(1024 * 1024) }] }));
+});
diff --git a/tests/scripts/coordination-inventory.test.js b/tests/scripts/coordination-inventory.test.js
new file mode 100644
index 000000000..ebcdb4728
--- /dev/null
+++ b/tests/scripts/coordination-inventory.test.js
@@ -0,0 +1,155 @@
+'use strict';
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { spawnSync } = require('node:child_process');
+const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, readJson } = require('../../scripts/lib/coordination-inventory');
+const now = '2026-09-08T06:30:00.000Z';
+const task = (id, paths, extra = {}) => ({ id, repoId: 'repo', paths, ...extra });
+const fixture = () => ({ version: 1, repositories: [{ id: 'repo', sources: { 'src/a.js': "require('../lib/b')", 'lib/b.js': '' } }], tasks: [task('a', ['src/a.js']), task('b', ['lib/b.js'])], leases: [] });
+const run = value => buildInventory(value, { now });
+
+test('direct import warns when exact-path baseline would miss it; deterministic JSON', () => {
+ const f = fixture(); const before = JSON.stringify(f); const r = run(f);
+ assert.equal(r.warnings.length, 1); assert.deepEqual(r.warnings[0].reasons, ['import_dependency']);
+ assert.equal(r.warnings[0].channels.dependency, 1);
+ assert.equal(JSON.stringify(run(f)), JSON.stringify(r)); assert.equal(JSON.stringify(f), before);
+});
+test('normalized exact paths warn, tree-only neighbors and cross-repo pairs do not', () => {
+ const f = fixture(); f.tasks[1].paths = ['./src/a.js'];
+ assert.deepEqual(run(f).warnings[0].reasons, ['path_overlap']);
+ f.tasks[1].paths = ['src/c.js']; assert.equal(run(f).warnings.length, 0);
+ f.repositories.push({ id: 'other', sources: {} }); f.tasks[1] = task('b', ['src/a.js'], { repoId: 'other' });
+ assert.equal(run(f).warnings.length, 0);
+});
+test('leases show owner, expiry, conflicts and do not grant authority', () => {
+ const f = fixture(); f.leases = [
+ { resource: 'browser:chrome', owner: 'root', expiresAt: '2026-09-08T07:00:00Z' },
+ { resource: 'browser:chrome', owner: 'worker', expiresAt: '2026-09-08T07:00:00Z' },
+ { resource: 'browser:chrome', owner: 'old', expiresAt: now }
+ ]; const r = run(f);
+ assert.equal(r.leases[2].state, 'expired');
+ assert.deepEqual(r.leaseConflicts, [{ resource: 'browser:chrome', owners: ['root', 'worker'] }]);
+ assert.equal(r.mode, 'read-only'); assert.equal(r.leases[0].authority, 'declared-only');
+});
+test('stale heartbeat is not a proven stuck process; absent/future telemetry stays unknown', () => {
+ const f = fixture(); f.tasks = [task('a', [], { heartbeatAt: '2026-09-08T06:00:00Z', pid: 12 }), task('b', [], { heartbeatAt: '2026-09-08T07:00:00Z' }), task('c', [])];
+ const r = run(f); assert.equal(r.tasks[0].heartbeat.state, 'stale'); assert.equal(r.tasks[0].process.state, 'unknown');
+ assert.equal(r.tasks[1].heartbeat.state, 'clock-skew'); assert.equal(r.tasks[2].heartbeat.state, 'unknown');
+});
+test('task parents, status and bounded observations survive without source payload', () => {
+ const f = fixture(); f.tasks[1].parentId = 'a'; f.tasks[0].status = 'running'; f.tasks[0].unexpectedSecret = 'CANARY_SECRET';
+ f.repositories[0].sources['lib/b.js'] = 'CANARY_SOURCE';
+ const r = run(f); assert.equal(r.tasks[1].parentId, 'a'); assert.equal(r.tasks[0].status, 'running');
+ assert.ok(!JSON.stringify(r).includes('CANARY')); assert.equal(r.coverage.workingSets, 'declared-paths-only');
+});
+test('invalid shapes, IDs, paths, dates and missing repos fail closed', () => {
+ for (const mutate of [
+ f => { f.version = 2; }, f => { f.tasks = null; }, f => { f.tasks.push(f.tasks[0]); },
+ f => { f.tasks[0].paths = ['../escape']; }, f => { f.tasks[0].paths = ['/absolute']; },
+ f => { f.tasks[0].paths = ['C:\\secret']; }, f => { f.tasks[0].paths = ['a/../b']; },
+ f => { f.tasks[0].paths = ['__proto__']; }, f => { f.tasks[0].pid = '-1'; },
+ f => { f.tasks[0].heartbeatAt = 'yesterday'; }, f => { f.tasks[0].repoId = 'absent'; },
+ f => { f.repositories[0].sources = []; }, f => { f.tasks[0].id = '\n'; },
+ f => { f.tasks[0].parentId = 'a'; }, f => { f.tasks = Array(65).fill(f.tasks[0]); },
+ f => { f.leases = [{resource:'chrome',owner:'root',expiresAt:'bad'}]; }
+ ]) { const f = fixture(); mutate(f); assert.throws(() => normalizeManifest(f), /Invalid/); }
+});
+test('process collection uses metadata-only argv, bounded timeout and no shell', () => {
+ let call; const r = collectResources([task('a', [], { pid: 12 })], { platform: 'darwin', totalmem: () => 1024, freemem: () => 512, execFileSync: (...args) => { call = args; return '12 1 32 01:30 S\n'; } });
+ assert.equal(call[0], 'ps'); assert.deepEqual(call[1], ['-p','12','-o','pid=,ppid=,rss=,etime=,stat=']);
+ assert.equal(call[2].timeout, 2000); assert.equal(call[2].shell, false);
+ assert.equal(r.processes[0].rssBytes, 32768); assert.equal(r.memory.freeBytes, 512);
+});
+test('unavailable, empty, malformed and unsupported process snapshots remain explicit', () => {
+ const tasks = [task('a', [], { pid: 12 })];
+ let runnerCalls = 0;
+ const unsupportedDeps = { platform: 'win32', execFileSync: () => { runnerCalls += 1; return ''; } };
+ const unsupported = collectResources(tasks, unsupportedDeps);
+ assert.equal(unsupported.processStatus, 'unsupported');
+ assert.equal(buildInventory({ ...fixture(), tasks }, { now, resources: unsupported }).tasks[0].process.state, 'unknown');
+ // Runner fixtures must select a supported platform independently of the host.
+ assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => { throw new Error('SECRET'); } }).processStatus, 'unavailable');
+ assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => '' }).processStatus, 'ok');
+ assert.equal(collectResources(tasks, { platform: 'darwin', execFileSync: () => 'bad row' }).processStatus, 'unavailable');
+ assert.equal(collectResources([], unsupportedDeps).processStatus, 'not-requested');
+ assert.equal(runnerCalls, 0);
+});
+test('live process snapshot enriches matching tasks and marks missing PID as unobserved', () => {
+ const f = fixture(); f.tasks[0].pid = 12; f.tasks[1].pid = 13;
+ const resources = collectResources(f.tasks, { platform: 'linux', execFileSync: () => '12 1 32 01:30 S\n' });
+ const r = buildInventory(f, { now, resources });
+ assert.equal(r.tasks[0].process.state, 'observed'); assert.equal(r.tasks[1].process.state, 'not-observed');
+});
+test('task file adapter reads structured status, labels mtime, skips symlinks and rejects oversized JSON', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-test-'));
+ try {
+ fs.mkdirSync(path.join(dir, 'worker')); fs.writeFileSync(path.join(dir, 'worker', 'STATUS.md'), '- State: running\n- Updated: 2026-09-08T06:29:00Z\n');
+ fs.symlinkSync(path.join(dir, 'worker'), path.join(dir, 'linked'));
+ const r = collectTaskFiles(dir); assert.equal(r.tasks.length, 1); assert.equal(r.tasks[0].status, 'running');
+ assert.ok(r.tasks[0].statusFileModifiedAt); assert.equal(r.tasks[0].heartbeatAt, '2026-09-08T06:29:00Z');
+ fs.writeFileSync(path.join(dir, 'large.json'), ' '.repeat(1024 * 1024 + 1));
+ assert.throws(() => readJson(path.join(dir, 'large.json')), /limit/);
+ assert.equal(collectTaskFiles(path.join(dir, 'missing')).status, 'unavailable');
+ } finally { fs.rmSync(dir, { recursive: true, force: true }); }
+});
+test('CLI JSON end to end, no output file changes and safe errors', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-cli-'));
+ const cli = path.resolve(__dirname, '../../scripts/coordination-inventory.js');
+ try {
+ const file = path.join(dir,'input.json'); fs.writeFileSync(file, JSON.stringify(fixture()));
+ const r = spawnSync(process.execPath, [cli, '--manifest', file, '--now', now], { encoding:'utf8' });
+ assert.equal(r.status,0,r.stderr); assert.equal(JSON.parse(r.stdout).warnings.length,1);
+ assert.deepEqual(fs.readdirSync(dir),['input.json']);
+ const bad = spawnSync(process.execPath,[cli,'--unknown','CANARY_SECRET'],{encoding:'utf8'});
+ assert.equal(bad.status,1); assert.ok(!bad.stderr.includes('CANARY_SECRET'));
+ const help = spawnSync(process.execPath,[cli,'--help'],{encoding:'utf8'}); assert.equal(help.status,0);
+ } finally { fs.rmSync(dir,{recursive:true,force:true}); }
+});
+
+test('prototype-named paths and strict calendar dates are safe', () => {
+ const f = fixture(); f.repositories[0].sources = {}; f.tasks[0].paths = ['toString']; f.tasks[1].paths = ['valueOf'];
+ assert.equal(run(f).warnings.length, 0);
+ for (const invalid of ['2026-02-30T00:00:00Z', '2026-09-08T24:00:00Z']) {
+ f.tasks[0].heartbeatAt = invalid; assert.throws(() => run(f), /Invalid/);
+ }
+ f.tasks[0].heartbeatAt = '2026-09-08T06:00:00.1Z'; assert.equal(run(f).tasks[0].heartbeat.state, 'stale');
+});
+test('aggregate comparison budget rejects compact but computationally excessive input', () => {
+ const f = fixture(); f.repositories[0].sources = {};
+ f.tasks = Array.from({length:64}, (_,i) => task(`task${i}`, Array.from({length:128}, (_,j) => `src/${i}/${j}.js`)));
+ assert.throws(() => run(f), /budget/);
+});
+test('CLI discovery composes normalized tasks and reports missing telemetry honestly', () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-discovery-'));
+ try {
+ fs.mkdirSync(path.join(dir,'worker')); fs.writeFileSync(path.join(dir,'worker','STATUS.md'),'Freeform progress.\n');
+ const r = spawnSync(process.execPath,[path.resolve(__dirname,'../../scripts/coordination-inventory.js'),'--coordination',dir,'--now',now],{encoding:'utf8'});
+ assert.equal(r.status,0,r.stderr); const report=JSON.parse(r.stdout);
+ assert.equal(report.tasks[0].status,'unknown'); assert.equal(report.tasks[0].heartbeat.state,'unknown');
+ assert.ok(report.tasks[0].statusFileModifiedAt); assert.equal(report.tasks[0].process.state,'unknown');
+ } finally { fs.rmSync(dir,{recursive:true,force:true}); }
+});
+test('source snippets are bounded before invoking inherited regex extractor', () => {
+ const f = fixture(); f.repositories[0].sources = { 'a.js': `import ${' '.repeat(32000)}x` }; f.tasks=[];
+ assert.throws(() => run(f), /Invalid/);
+ f.repositories[0].sources = Object.fromEntries(Array.from({length:33},(_,i) => [`${i}.js`, ' '.repeat(1024)]));
+ assert.throws(() => run(f), /Invalid/);
+});
+test('maximum accepted whitespace snippets complete within bounded subprocess timeout', () => {
+ const code = `const {buildInventory}=require('./scripts/lib/coordination-inventory');
+ const source='import '+' '.repeat(1016)+'x';
+ const sources=Object.fromEntries(Array.from({length:32},(_,i)=>[i+'.js',source]));
+ const r=buildInventory({version:1,repositories:[{id:'r',sources}],tasks:[{id:'a',repoId:'r',paths:['0.js']},{id:'b',repoId:'r',paths:['1.js']}]});
+ if(r.warnings.length) process.exitCode=1;`;
+ const r=spawnSync(process.execPath,['-e',code],{cwd:path.resolve(__dirname,'../..'),encoding:'utf8',timeout:2000});
+ assert.equal(r.status,0,r.error?.message || r.stderr);
+});
+test('bounded import parsing preserves supported JS and TS import forms', () => {
+ const { buildDependencyGraphFromSources } = require('../../scripts/lib/agent-proximity/graph');
+ for (const source of ["import './b'", "import b from './b'", "import { b as c } from './b'", "import * as b from './b'", "import b, { c } from './b'", "import type { B } from './b'", "import {\n b\n} from './b'", "import('./b')"]) {
+ assert.deepEqual(buildDependencyGraphFromSources({'a.js':source,'b.js':''}).adjacency['a.js'],['b.js']);
+ }
+});
diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js
index a234d28a6..9adf37bf5 100644
--- a/tests/scripts/memory-mcp.test.js
+++ b/tests/scripts/memory-mcp.test.js
@@ -25,32 +25,43 @@ async function test(name, fn) {
passed += 1;
} catch (error) {
console.log(` FAIL ${name}`);
- console.log(` ${error.stack || error.message}`);
+ console.log(` ${error.mcpDiagnostic ? JSON.stringify(error.mcpDiagnostic) : error.stack || error.message}`);
failed += 1;
}
}
function createFixture(extraEnv = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-mcp-'));
- const projectRoot = path.join(root, 'project');
- const homeDir = path.join(root, 'home');
- fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
- fs.mkdirSync(homeDir, { recursive: true });
- return {
- root,
- projectRoot,
- env: Object.fromEntries(
- Object.entries({
- ...process.env,
- HOME: homeDir,
- USERPROFILE: homeDir,
- ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
- ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
- ECC_MEMORY_HARNESS: 'claude',
- ...extraEnv,
- }).filter(([, value]) => typeof value === 'string')
- ),
- };
+ try {
+ const projectRoot = path.join(root, 'project');
+ const homeDir = path.join(root, 'home');
+ fs.mkdirSync(path.join(projectRoot, '.git'), { recursive: true });
+ fs.mkdirSync(homeDir, { recursive: true });
+ return {
+ root,
+ projectRoot,
+ env: Object.fromEntries(
+ Object.entries({
+ ...process.env,
+ HOME: homeDir,
+ USERPROFILE: homeDir,
+ ECC_MEMORY_PROJECT_ROOT: path.join(projectRoot, '.ecc', 'memory'),
+ ECC_MEMORY_USER_ROOT: path.join(homeDir, '.ecc', 'memory'),
+ ECC_MEMORY_HARNESS: 'claude',
+ ECC_MEMORY_ALLOW_USER_SCOPE: '0',
+ ...extraEnv,
+ }).filter(([, value]) => typeof value === 'string')
+ ),
+ };
+ } catch (error) {
+ try { fs.rmSync(root, { recursive: true, force: true }); }
+ catch {
+ const failure = new Error('MCP fixture cleanup failed', { cause: error });
+ failure.mcpCleanupFailure = 'fixture_removal_error';
+ throw failure;
+ }
+ throw error;
+ }
}
function parseTextResult(result) {
@@ -60,105 +71,260 @@ function parseTextResult(result) {
}
async function withClient(fn, options = {}) {
- const fixture = createFixture(options.env);
- const child = spawn(process.execPath, [options.server || SERVER], {
- cwd: fixture.projectRoot,
- env: fixture.env,
- stdio: ['pipe', 'pipe', 'pipe'],
- });
+ const started = Date.now();
const pending = new Map();
+ const mode = options.env?.ECC_MEMORY_ALLOW_USER_SCOPE === '1' ? 'allow' : 'deny';
+ let fixture;
+ let child;
+ let phase = 'setup';
let nextId = 1;
- let stdout = '';
- let stderr = '';
+ let stdout = Buffer.alloc(0);
+ let stdoutBytes = 0;
+ let stderrBytes = 0;
+ let closed = false;
+ let tearingDown = false;
+ let transportError;
+ let primaryError;
+ let primaryFailed = false;
+ let failureKind;
+ let failureElapsedMs;
+ let teardownStarted;
+ let failurePhase;
+ let cleanupFailure;
+ let killStatus = 'not_attempted';
+ let notifyClose;
+ const closePromise = new Promise(resolve => { notifyClose = resolve; });
+ let rejectTransport;
+ const transportFailure = new Promise((_, reject) => { rejectTransport = reject; });
+ // The child may fail before the initialize or callback race is installed.
+ transportFailure.catch(() => {});
- child.stdout.on('data', chunk => {
- stdout += chunk.toString('utf8');
- let newlineIndex = stdout.indexOf('\n');
- while (newlineIndex >= 0) {
- const line = stdout.slice(0, newlineIndex);
- stdout = stdout.slice(newlineIndex + 1);
- if (line.trim()) {
- const message = JSON.parse(line);
+ const bounded = value => Math.min(2147483647, Math.max(0, Math.trunc(value)));
+ const safeCode = error => [
+ 'EPIPE', 'ENOENT', 'EACCES', 'EPERM', 'EINVAL', 'ECONNRESET',
+ 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END', 'ERR_ASSERTION',
+ ].includes(error?.code) ? error.code : null;
+ const diagnostic = () => ({
+ phase: failurePhase || phase,
+ mode,
+ reason: failureKind || cleanupFailure || 'assertion_or_callback',
+ failureElapsedMs: failureElapsedMs ?? null,
+ teardownElapsedMs: bounded(Date.now() - teardownStarted),
+ elapsedMs: bounded(Date.now() - started),
+ stdoutBytes,
+ stderrBytes,
+ pendingRequests: pending.size,
+ childStarted: Boolean(child?.pid),
+ childClosed: closed,
+ exitCode: Number.isInteger(child?.exitCode) ? child.exitCode : null,
+ signal: ['SIGTERM', 'SIGKILL', 'SIGINT'].includes(child?.signalCode) ? child.signalCode : null,
+ errorCode: safeCode(primaryError),
+ cleanupFailure: cleanupFailure || null,
+ killStatus,
+ });
+ function settleAll(error) {
+ for (const waiter of pending.values()) waiter.reject(error);
+ pending.clear();
+ }
+ function fail(kind, cause) {
+ if (tearingDown) {
+ cleanupFailure ||= kind;
+ return;
+ }
+ if (transportError) return;
+ transportError = new Error(`MCP test client ${kind}`);
+ if (safeCode(cause)) transportError.code = safeCode(cause);
+ failurePhase = phase;
+ failureKind = kind;
+ settleAll(transportError);
+ rejectTransport(transportError);
+ }
+ function send(message) {
+ if (transportError) throw transportError;
+ try {
+ child.stdin.write(`${JSON.stringify(message)}\n`, error => {
+ if (error) fail('stdin_write_error', error);
+ });
+ } catch (error) {
+ fail('stdin_write_error', error);
+ throw transportError;
+ }
+ }
+ function request(method, params = {}) {
+ const id = nextId++;
+ const promise = new Promise((resolve, reject) => {
+ if (transportError || tearingDown || closed) {
+ reject(transportError || new Error('MCP test client is closed'));
+ return;
+ }
+ const timer = setTimeout(() => {
+ fail('request_timeout');
+ }, 5000);
+ function settle(fn, value) {
+ clearTimeout(timer);
+ pending.delete(id);
+ fn(value);
+ }
+ pending.set(id, {
+ resolve: value => settle(resolve, value),
+ reject: error => settle(reject, error),
+ });
+ send({ jsonrpc: '2.0', id, method, params });
+ });
+ // Teardown rejects abandoned requests too, without an unhandled rejection.
+ promise.catch(() => {});
+ return promise;
+ }
+
+ try {
+ fixture = createFixture(options.env);
+ phase = 'spawn';
+ child = spawn(process.execPath, [options.server || SERVER], {
+ cwd: fixture.projectRoot,
+ env: fixture.env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ });
+ child.on('error', error => fail('child_error', error));
+ child.on('exit', () => {
+ if (!tearingDown) fail('child_exit');
+ });
+ child.once('close', () => {
+ closed = true;
+ notifyClose();
+ if (!tearingDown) fail('child_close');
+ });
+ for (const stream of ['stdin', 'stdout', 'stderr']) {
+ child[stream].on('error', error => fail(`${stream}_error`, error));
+ }
+ child.stdout.on('end', () => { if (!tearingDown) fail('stdout_end'); });
+ for (const stream of ['stdin', 'stdout']) {
+ child[stream].on('close', () => { if (!tearingDown) fail(`${stream}_close`); });
+ }
+ child.stderr.on('data', chunk => {
+ stderrBytes = bounded(stderrBytes + chunk.length);
+ });
+ child.stdout.on('data', chunk => {
+ stdoutBytes = bounded(stdoutBytes + chunk.length);
+ if (transportError || tearingDown) return;
+ // Decode complete lines, so a UTF-8 character split across chunks survives.
+ stdout = Buffer.concat([stdout, chunk]);
+ let newlineIndex;
+ while ((newlineIndex = stdout.indexOf(10)) >= 0) {
+ if (newlineIndex > 1024 * 1024) { fail('oversized_frame'); return; }
+ const line = stdout.subarray(0, newlineIndex).toString('utf8');
+ stdout = stdout.subarray(newlineIndex + 1);
+ if (!line.trim()) continue;
+ let message;
+ try {
+ message = JSON.parse(line);
+ if (!message || message.jsonrpc !== '2.0' || !Number.isInteger(message.id)
+ || (Object.hasOwn(message, 'result') === Object.hasOwn(message, 'error'))
+ || (Object.hasOwn(message, 'error') && (!message.error
+ || !Number.isInteger(message.error.code) || typeof message.error.message !== 'string'))) {
+ fail('invalid_frame');
+ return;
+ }
+ } catch {
+ fail('malformed_frame');
+ return;
+ }
const waiter = pending.get(message.id);
if (waiter) {
- pending.delete(message.id);
if (message.error) {
+ // Existing authorization/protocol assertions inspect this RPC error.
+ // The test logger emits only mcpDiagnostic when it escapes the helper.
waiter.reject(new Error(`${message.error.code}: ${message.error.message}`));
} else {
waiter.resolve(message.result);
}
}
}
- newlineIndex = stdout.indexOf('\n');
+ if (stdout.length > 1024 * 1024) fail('oversized_frame');
+ });
+
+ phase = 'initialize';
+ const initialized = await request('initialize', {
+ protocolVersion: '2025-11-25',
+ capabilities: {},
+ clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
+ });
+ phase = 'protocol';
+ assert.strictEqual(initialized.protocolVersion, '2025-11-25');
+ phase = 'notification';
+ send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
+ const client = {
+ listTools: () => request('tools/list'),
+ listToolsRaw: params => request('tools/list', params),
+ callTool: ({ name, arguments: toolArguments }) => request(
+ 'tools/call',
+ { name, arguments: toolArguments }
+ ),
+ callToolRaw: params => request('tools/call', params),
+ };
+ phase = 'callback';
+ await Promise.race([Promise.resolve().then(() => fn(client, fixture)), transportFailure]);
+ if (transportError) throw transportError;
+ assert.strictEqual(pending.size, 0, 'MCP callback must await its requests');
+ } catch (error) {
+ primaryError = error;
+ primaryFailed = true;
+ failurePhase ||= phase;
+ failureElapsedMs = bounded(Date.now() - started);
+ if (error?.mcpCleanupFailure === 'fixture_removal_error') {
+ cleanupFailure ||= 'fixture_removal_error';
}
- });
- child.stderr.on('data', chunk => {
- stderr += chunk.toString('utf8');
- });
-
- function send(message) {
- child.stdin.write(`${JSON.stringify(message)}\n`);
- }
-
- function request(method, params = {}) {
- const id = nextId;
- nextId += 1;
- return new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- pending.delete(id);
- reject(new Error(`Timed out waiting for ${method}. stderr: ${stderr}`));
- }, 5000);
- pending.set(id, {
- resolve: value => {
- clearTimeout(timeout);
- resolve(value);
- },
- reject: error => {
- clearTimeout(timeout);
- reject(error);
- },
- });
- send({ jsonrpc: '2.0', id, method, params });
- });
- }
-
- const initialized = await request('initialize', {
- protocolVersion: '2025-11-25',
- capabilities: {},
- clientInfo: { name: 'ecc-memory-test', version: '1.0.0' },
- });
- assert.strictEqual(initialized.protocolVersion, '2025-11-25');
- send({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} });
-
- const client = {
- listTools: () => request('tools/list'),
- listToolsRaw: params => request('tools/list', params),
- callTool: ({ name, arguments: toolArguments }) => request(
- 'tools/call',
- { name, arguments: toolArguments }
- ),
- callToolRaw: params => request('tools/call', params),
- };
-
- try {
- await fn(client, fixture);
} finally {
- child.stdin.end();
- await new Promise(resolve => {
- if (child.exitCode !== null) {
- resolve();
- return;
+ tearingDown = true;
+ teardownStarted = Date.now();
+ phase = 'teardown';
+ settleAll(new Error('MCP test client is closing'));
+ stdout = Buffer.alloc(0);
+ if (child && !closed) {
+ // Keep the original total 2000 ms budget. Reserve its latter half for
+ // direct-child termination and stdio close, including on Windows.
+ let killTimer;
+ let deadlineTimer;
+ function terminate() {
+ try { killStatus = child.kill() ? 'requested' : 'not_sent'; }
+ catch { killStatus = 'error'; }
}
- const timeout = setTimeout(() => {
- child.kill();
- resolve();
- }, 2000);
- child.once('exit', () => {
- clearTimeout(timeout);
- resolve();
+ const deadline = new Promise(resolve => {
+ deadlineTimer = setTimeout(resolve, 2000);
+ killTimer = setTimeout(terminate, 1000);
});
- });
- fs.rmSync(fixture.root, { recursive: true, force: true });
+ try {
+ try { child.stdin.end(); }
+ catch {
+ cleanupFailure ||= 'stdin_end_error';
+ clearTimeout(killTimer);
+ terminate();
+ }
+ await Promise.race([closePromise, deadline]);
+ } finally {
+ clearTimeout(killTimer);
+ clearTimeout(deadlineTimer);
+ }
+ if (!closed) cleanupFailure ||= 'child_close_timeout';
+ }
+ if (fixture && (!child || closed)) {
+ try { fs.rmSync(fixture.root, { recursive: true, force: true }); }
+ catch { cleanupFailure ||= 'fixture_removal_error'; }
+ }
+ }
+ if (primaryFailed || cleanupFailure) {
+ if (!primaryFailed) primaryError = new Error('MCP test client cleanup failed');
+ // Keep the primary assertion/RPC/callback error; cleanup must not replace it.
+ // A wrapper retains non-extensible or non-Error thrown values as its cause.
+ if (!primaryError || typeof primaryError !== 'object' || !Object.isExtensible(primaryError)
+ || Object.getOwnPropertyDescriptor(primaryError, 'mcpDiagnostic')?.configurable === false
+ || Object.getOwnPropertyDescriptor(primaryError, 'mcpCleanupFailure')?.configurable === false) {
+ primaryError = new Error('MCP test client failed', { cause: primaryError });
+ }
+ Object.defineProperty(primaryError, 'mcpDiagnostic', { value: diagnostic(), configurable: true });
+ if (cleanupFailure) {
+ Object.defineProperty(primaryError, 'mcpCleanupFailure', { value: cleanupFailure, configurable: true });
+ }
+ throw primaryError;
}
}
From d2b352c20275b643f0966857a89bff5d925345aa Mon Sep 17 00:00:00 2001
From: Affaan Mustafa
Date: Thu, 10 Sep 2026 14:13:06 +0300
Subject: [PATCH 036/141] feat: ship verified Fusion presets with compatibility
provenance (#3010)
---
skills/video-editing/SKILL.md | 6 ++
.../ITO_PROD_HighlightBloom.setting | 10 +++
.../ITO_PROD_LumaHalo.setting | 16 ++++
.../ITO_PROD_RGBFringe.setting | 15 ++++
.../assets/fusion/ito-production-v1/README.md | 33 ++++++++
.../install_ito_production_v1.lua | 51 +++++++++++++
.../fusion/ito-production-v1/provenance.json | 45 +++++++++++
.../ITO_V28_FlashEtherealBloom.setting | 7 ++
.../ito-v28/ITO_V28_RGBDisplacement.setting | 7 ++
.../ito-v28/ITO_V28_SubjectHalo.setting | 7 ++
.../assets/fusion/ito-v28/README.md | 35 +++++++++
.../assets/fusion/ito-v28/install_ito_v28.lua | 51 +++++++++++++
.../assets/fusion/ito-v28/provenance.json | 39 ++++++++++
tests/ci/fusion-bundle.test.js | 75 +++++++++++++++++++
14 files changed, 397 insertions(+)
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_HighlightBloom.setting
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_LumaHalo.setting
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_RGBFringe.setting
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/README.md
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/install_ito_production_v1.lua
create mode 100644 skills/video-editing/assets/fusion/ito-production-v1/provenance.json
create mode 100644 skills/video-editing/assets/fusion/ito-v28/ITO_V28_FlashEtherealBloom.setting
create mode 100644 skills/video-editing/assets/fusion/ito-v28/ITO_V28_RGBDisplacement.setting
create mode 100644 skills/video-editing/assets/fusion/ito-v28/ITO_V28_SubjectHalo.setting
create mode 100644 skills/video-editing/assets/fusion/ito-v28/README.md
create mode 100644 skills/video-editing/assets/fusion/ito-v28/install_ito_v28.lua
create mode 100644 skills/video-editing/assets/fusion/ito-v28/provenance.json
create mode 100644 tests/ci/fusion-bundle.test.js
diff --git a/skills/video-editing/SKILL.md b/skills/video-editing/SKILL.md
index ca580d765..5e12aed99 100644
--- a/skills/video-editing/SKILL.md
+++ b/skills/video-editing/SKILL.md
@@ -304,6 +304,12 @@ identify the 5 most engaging 30-second clips for social media."
5. **Generate selectively.** Only use AI generation for assets that don't exist, not for everything.
6. **Taste is the last layer.** AI clears repetitive work. You make the final creative calls.
+## Native Fusion Presets
+
+[ITO Production v1](assets/fusion/ito-production-v1/README.md) provides restrained highlight bloom, opposing RGB spatial offsets and a luminance/edge halo. The exact files passed prior native import, save/reopen and short motion-render checks after two-source visual review. These are starting values requiring shot-specific review; the halo does not detect or track subjects.
+
+[ITO V28](assets/fusion/ito-v28/README.md) contains preserved, native-verified Fusion graph snippets and an idempotent Lua installer. These are technical compatibility examples, **not recommended production defaults**: their documented visual limitations require tuning and taste review before use. See the bundle provenance for the scope of prior import and render checks.
+
## Related Skills
- `fal-ai-media` — AI image, video, and audio generation
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_HighlightBloom.setting b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_HighlightBloom.setting
new file mode 100644
index 000000000..b8137a62a
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_HighlightBloom.setting
@@ -0,0 +1,10 @@
+{
+ Tools = ordered() {
+ ITO_PROD_Bloom = SoftGlow { Inputs = {
+ Threshold = Input { Value = 0.70, }, Gain = Input { Value = 0.12, },
+ XGlowSize = Input { Value = 6.0, }, YGlowSize = Input { Value = 6.0, },
+ Blend = Input { Value = 0.22, }, Alpha = Input { Value = 0, },
+ ClippingMode = Input { Value = FuID { "Frame" }, },
+ } },
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_LumaHalo.setting b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_LumaHalo.setting
new file mode 100644
index 000000000..448686ae7
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_LumaHalo.setting
@@ -0,0 +1,16 @@
+{
+ Tools = ordered() {
+ ITO_PROD_Contours = Filter { Inputs = { FilterType = Input { Value = 3, }, Power = Input { Value = 1, }, Alpha = Input { Value = 0, }, } },
+ ITO_PROD_EdgeMask = BitmapMask { Inputs = {
+ Image = Input { SourceOp = "ITO_PROD_Contours", Source = "Output", },
+ Channel = Input { Value = FuID { "Luminance" }, }, Low = Input { Value = 0.07, }, High = Input { Value = 0.35, },
+ } },
+ ITO_PROD_Halo = SoftGlow { Inputs = {
+ Threshold = Input { Value = 0.55, }, Gain = Input { Value = 0.10, },
+ XGlowSize = Input { Value = 2.0, }, YGlowSize = Input { Value = 2.0, }, Blend = Input { Value = 0.25, }, Alpha = Input { Value = 0, },
+ ClippingMode = Input { Value = FuID { "Frame" }, },
+ EffectMask = Input { SourceOp = "ITO_PROD_EdgeMask", Source = "Mask", },
+ GlowMask = Input { SourceOp = "ITO_PROD_EdgeMask", Source = "Mask", },
+ } },
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_RGBFringe.setting b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_RGBFringe.setting
new file mode 100644
index 000000000..ca5dd1517
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/ITO_PROD_RGBFringe.setting
@@ -0,0 +1,15 @@
+{
+ Tools = ordered() {
+ ITO_PROD_RedOffset = Transform { Inputs = { Center = Input { Value = { 0.499, 0.5 }, }, Edges = Input { Value = 2, }, } },
+ ITO_PROD_BlueOffset = Transform { Inputs = { Center = Input { Value = { 0.501, 0.5 }, }, Edges = Input { Value = 2, }, } },
+ ITO_PROD_RedCopy = ChannelBoolean { Inputs = {
+ Operation = Input { Value = 0, }, ToRed = Input { Value = 0, }, ToGreen = Input { Value = 6, }, ToBlue = Input { Value = 7, }, ToAlpha = Input { Value = 8, },
+ Foreground = Input { SourceOp = "ITO_PROD_RedOffset", Source = "Output", },
+ } },
+ ITO_PROD_BlueCopy = ChannelBoolean { Inputs = {
+ Operation = Input { Value = 0, }, ToRed = Input { Value = 5, }, ToGreen = Input { Value = 6, }, ToBlue = Input { Value = 2, }, ToAlpha = Input { Value = 8, },
+ Background = Input { SourceOp = "ITO_PROD_RedCopy", Source = "Output", },
+ Foreground = Input { SourceOp = "ITO_PROD_BlueOffset", Source = "Output", },
+ } },
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/README.md b/skills/video-editing/assets/fusion/ito-production-v1/README.md
new file mode 100644
index 000000000..84b2972de
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/README.md
@@ -0,0 +1,33 @@
+# ITO Production v1 native Fusion presets
+
+These restrained presets are separate from the preserved ITO_V28 compatibility examples. The parent review approved their two-source still previews. Exact installed imports, parameter/connection readbacks, save/reopen and six 30-frame renders passed verification. The Lua installer also passed actual installation and an idempotent rerun, with original presets unchanged. They do not change the finished V28 film.
+
+## Presets
+
+| Preset | Behavior | Starting strength |
+|---|---|---|
+| HighlightBloom | Glow limited to brighter image content, without an exposure or color-gain node | Threshold 0.70, glow gain 0.12, 6px glow size, 22% blend |
+| RGBFringe | Opposing red/blue spatial offsets with unchanged original green/alpha routing and duplicated edge pixels | Normalized horizontal offsets −0.001/+0.001, approximately −1.92/+1.92 px at 1920 px width |
+| LumaHalo | Thin glow through a Sobel/luminance mask recomputed from each source frame, without translating the image | 2 px glow size, glow gain 0.10, 25% blend |
+
+The halo follows image edges through its per-frame mask. It performs no object detection or tracking. These are conservative starting values, not a universal match for every shot or reference. RGB output is recombined from actual spatially offset channels; it does not remap green from alpha as the compatibility example does. Both glow nodes use frame clipping. Alpha preservation is established by node routing/disabled alpha processing; H264 proof renders do not contain alpha.
+
+## Install
+
+Keep the three .setting files beside install_ito_production_v1.lua. On macOS run the saved installer from its absolute path:
+
+```sh
+"/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fuscript" -l lua "/absolute/path/to/reusable/install_ito_production_v1.lua"
+```
+
+It installs into your user Fusion/Macros/ITO_Production_v1 directory. It preflights every source, refuses conflicting installed bytes and verifies readback. Identical reruns are allowed. Existing ITO_V22 and ITO_V28 files remain untouched.
+
+## Import and connect
+
+Use Resolve's TimelineItem.ImportFusionComp with the actual installed .setting path in a new composition or duplicated clip. These are serialized tool-graph snippets, so add the clip's MediaIn and MediaOut boundaries. Internal links are already serialized.
+
+- HighlightBloom: connect MediaIn to ITO_PROD_Bloom.Input; connect Bloom output to MediaOut.
+- RGBFringe: connect MediaIn to RedOffset.Input, BlueOffset.Input and RedCopy.Background. Connect BlueCopy output to MediaOut. Each node name carries the ITO_PROD_ prefix.
+- LumaHalo: connect MediaIn to Contours.Input and Halo.Input. Connect Halo output to MediaOut. Each node name carries the ITO_PROD_ prefix.
+
+[provenance.json](provenance.json) records exact shipped hashes and a sanitized summary of prior native verification, with hashes of the separately retained evidence. The earlier [ITO V28 compatibility examples](../ito-v28/README.md) are not recommended production defaults. This package does not include the source footage, native project or proof renders.
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/install_ito_production_v1.lua b/skills/video-editing/assets/fusion/ito-production-v1/install_ito_production_v1.lua
new file mode 100644
index 000000000..76d1c9a09
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/install_ito_production_v1.lua
@@ -0,0 +1,51 @@
+-- Run this file with Resolve's fuscript interpreter or Lua dofile().
+-- Keep the three .setting files beside it. Existing ITO_V22 and ITO_V28 files are untouched.
+local function need(ok, message)
+ if not ok then error(message, 0) end
+ return ok
+end
+local function read(path)
+ local file, message, code = io.open(path, "rb")
+ if not file then
+ need(code == 2, "Could not read " .. path .. ": " .. tostring(message))
+ return nil
+ end
+ local value = file:read("*a")
+ file:close()
+ need(value ~= nil, "Read failed: " .. path)
+ return value
+end
+local function quote(value)
+ return "'" .. value:gsub("'", "'\\''") .. "'"
+end
+local script = debug.getinfo(1, "S").source
+need(script:sub(1, 1) == "@", "Run the saved installer file, not pasted text")
+local sourceDir = need(script:sub(2):match("^(.*)/[^/]+$"), "Use the installer absolute path")
+local userHome = need(os.getenv("HOME"), "HOME is unavailable")
+local targetDir = userHome .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Macros/ITO_Production_v1"
+local names = {
+ "ITO_PROD_HighlightBloom.setting",
+ "ITO_PROD_RGBFringe.setting",
+ "ITO_PROD_LumaHalo.setting",
+}
+local payloads = {}
+for _, name in ipairs(names) do
+ local payload = need(read(sourceDir .. "/" .. name), "Missing source setting: " .. name)
+ need(#payload > 0, "Empty source setting: " .. name)
+ local existing = read(targetDir .. "/" .. name)
+ need(existing == nil or existing == payload, "Refusing to overwrite a different installed setting: " .. name)
+ payloads[name] = payload
+end
+local result = os.execute("mkdir -p " .. quote(targetDir))
+need(result == 0 or result == true, "Could not create ITO_Production_v1 directory")
+for _, name in ipairs(names) do
+ local path = targetDir .. "/" .. name
+ if read(path) == nil then
+ local file = need(io.open(path, "wb"), "Could not create: " .. name)
+ need(file:write(payloads[name]), "Write failed: " .. name)
+ need(file:close(), "Close failed: " .. name)
+ end
+ need(read(path) == payloads[name], "Installed readback mismatch: " .. name)
+ print("VERIFIED " .. name)
+end
+print("ITO_PRODUCTION_V1_INSTALLED " .. targetDir)
diff --git a/skills/video-editing/assets/fusion/ito-production-v1/provenance.json b/skills/video-editing/assets/fusion/ito-production-v1/provenance.json
new file mode 100644
index 000000000..3a83ec0f9
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-production-v1/provenance.json
@@ -0,0 +1,45 @@
+{
+ "bundle": "ITO_Production_v1",
+ "classification": "visually approved restrained starting presets; review each shot",
+ "files": {
+ "ITO_PROD_HighlightBloom.setting": "859f20d4c3e29a91367cc0c1e4472be5f85919d7953957549a0afde5bac74ba1",
+ "ITO_PROD_RGBFringe.setting": "81aa1cde3412782d6ec4e0ca815ba52f1eaca1065df96d813a0885f72a9add9b",
+ "ITO_PROD_LumaHalo.setting": "fa767774ef6a20d52422f8dfea7f1577a1308261212d730cdba39a4df2169186",
+ "install_ito_production_v1.lua": "fcde297435ac97ae7b9f01b65615ed7b0cd6622774a9952b1a041d642808d2b8"
+ },
+ "native_verification": {
+ "interpreter": "DaVinci Resolve bundled fuscript -l lua",
+ "actual_install_passed": true,
+ "second_idempotent_run_passed": true,
+ "prior_v22_and_v28_preserved": true,
+ "import_api": "TimelineItem.ImportFusionComp",
+ "saved_and_reopened": true,
+ "parameter_and_connection_readback_passed": true,
+ "renders": {
+ "count": 6,
+ "width": 1920,
+ "height": 1080,
+ "frames_each": 30,
+ "fps": 30,
+ "fully_decoded": true,
+ "new_black_border_pixels": 0,
+ "border_width_pixels": 4
+ },
+ "visual_review": "Two-source full-frame and detail contacts approved before installation; installed bytes matched approved previews.",
+ "alpha_scope": "Graph routing and disabled alpha processing only; H264 proof renders do not contain alpha.",
+ "main_film_modified": false,
+ "scope": "Prior native verification reported by the video owner; packaging does not rerun the native application."
+ },
+ "limitations": [
+ "Starting strengths require shot-specific taste review.",
+ "LumaHalo uses a per-frame Sobel/luminance mask, not object detection or tracking."
+ ],
+ "evidence_sha256": {
+ "installation_verification.json": "a49d1dc7a1efec4e4f2d5e21b8bca8e8ec4553cda669ae4bc85b0017c9f2658e",
+ "final_receipt.json": "19faa40f6bd52954a72faecb044b2f79c040a7a81b0b8302842316a1da6ce794",
+ "final_pixel_qc.json": "1355db7b73b69a9502cb274c977098fb8b8eeb583679ab53ddd8344a37822448",
+ "final_main_restore.json": "c2ead0f4151f467da0cd75b80c96658b68df5c38cfd4980b41ecd82d018066b8",
+ "production_preview_pixel_qc.json": "25c2d3f79caf8e6313d790101190269be1d5e1bb13e18e2861b313c8a593b0dc",
+ "VALIDATION.md": "00deff144c000dcfedc84d794377d97f870989fd2f7a7b4fde9f70a5b52b3d0f"
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-v28/ITO_V28_FlashEtherealBloom.setting b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_FlashEtherealBloom.setting
new file mode 100644
index 000000000..753c16859
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_FlashEtherealBloom.setting
@@ -0,0 +1,7 @@
+{
+ Tools = ordered() {
+ ITO_V28_FlashGain = BrightnessContrast { Inputs = { Gain = Input { Value = 1.22, }, Contrast = Input { Value = 1.16, }, } },
+ ITO_V28_FlashBloom = SoftGlow { Inputs = { Gain = Input { Value = 0.72, }, GlowSize = Input { Value = 18.0, }, Input = Input { SourceOp = "ITO_V28_FlashGain", Source = "Output", }, } },
+ ITO_V28_FlashColor = ColorGain { Inputs = { GainRed = Input { Value = 0.93, }, GainGreen = Input { Value = 1.04, }, GainBlue = Input { Value = 1.16, }, Input = Input { SourceOp = "ITO_V28_FlashBloom", Source = "Output", }, } }
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-v28/ITO_V28_RGBDisplacement.setting b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_RGBDisplacement.setting
new file mode 100644
index 000000000..d6bf3c2fd
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_RGBDisplacement.setting
@@ -0,0 +1,7 @@
+{
+ Tools = ordered() {
+ ITO_V28_RGBBase = Transform { Inputs = { Size = Input { Value = 1.008, }, } },
+ ITO_V28_RGBShift = ChannelBoolean { Inputs = { ToRed = Input { Value = 4, }, ToGreen = Input { Value = 3, }, ToBlue = Input { Value = 2, }, Background = Input { SourceOp = "ITO_V28_RGBBase", Source = "Output", }, Foreground = Input { SourceOp = "ITO_V28_RGBBase", Source = "Output", }, } },
+ ITO_V28_RGBSmear = DirectionalBlur { Inputs = { Length = Input { Value = 0.018, }, Angle = Input { Value = 0.0, }, Input = Input { SourceOp = "ITO_V28_RGBShift", Source = "Output", }, } }
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-v28/ITO_V28_SubjectHalo.setting b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_SubjectHalo.setting
new file mode 100644
index 000000000..dcc01da11
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/ITO_V28_SubjectHalo.setting
@@ -0,0 +1,7 @@
+{
+ Tools = ordered() {
+ ITO_V28_SubjectRect = RectangleMask { Inputs = { Width = Input { Value = 0.28, }, Height = Input { Value = 0.34, }, BorderWidth = Input { Value = 0.012, }, Solid = Input { Value = 0, }, } },
+ ITO_V28_SubjectGlow = SoftGlow { Inputs = { Gain = Input { Value = 0.85, }, GlowSize = Input { Value = 12.0, }, EffectMask = Input { SourceOp = "ITO_V28_SubjectRect", Source = "Mask", }, } },
+ ITO_V28_SubjectFrame = Transform { Inputs = { Center = Input { Value = { 0.5, 0.42 }, }, Input = Input { SourceOp = "ITO_V28_SubjectGlow", Source = "Output", }, } }
+ }
+}
diff --git a/skills/video-editing/assets/fusion/ito-v28/README.md b/skills/video-editing/assets/fusion/ito-v28/README.md
new file mode 100644
index 000000000..3a86bc3f7
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/README.md
@@ -0,0 +1,35 @@
+# ITO V28 Fusion compatibility examples
+
+These are preserved technical compatibility examples, **not recommended production defaults**. Native import and render checks establish that the graphs execute; visual review found blown highlights in Bloom, a strong green channel remap in RGB, and a translated full-frame border in Halo. Tune and visually review any derived look before production use.
+
+This versioned bundle preserves the original ITO_V22 installation. It contains three serialized Fusion tool graphs and a Lua installer. A sanitized summary and hashes of the separately retained native evidence are recorded in [provenance.json](provenance.json); installation alone is not import/render proof.
+
+## Install on macOS
+
+Keep the three .setting files beside install_ito_v28.lua. Run the installer from its absolute path with Resolve's bundled interpreter:
+
+```sh
+"/Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/Libraries/Fusion/fuscript" -l lua "/absolute/path/to/reusable/install_ito_v28.lua"
+```
+
+The installer writes to your user Fusion/Macros/ITO_V28 folder, verifies exact bytes and is safe to rerun when those bytes match. It refuses a conflicting existing file and never replaces ITO_V22. The prior native verification run passed both the initial execution and an idempotent second execution using the bundled Lua runtime. See [provenance.json](provenance.json).
+
+## Import and wire
+
+The verified host API route is TimelineItem.ImportFusionComp with the actual .setting path. These files are tool-graph snippets, not complete footage compositions or one-click tracked effects. Connect the clip's MediaIn output to the first image tool, then the last image tool to MediaOut. Preserve the serialized internal links.
+
+| Setting | External image chain |
+|---|---|
+| FlashEtherealBloom | MediaIn → ITO_V28_FlashGain → FlashBloom → FlashColor → MediaOut |
+| RGBDisplacement | MediaIn → ITO_V28_RGBBase → RGBShift → RGBSmear → MediaOut |
+| SubjectHalo | MediaIn → ITO_V28_SubjectGlow → SubjectFrame → MediaOut; SubjectRect connects to SubjectGlow's EffectMask |
+
+Names after the first node in the table also carry the ITO_V28_ prefix. Use a new composition or duplicate clip when trying these effects, so the existing composition stays available.
+
+## Scope and correction
+
+The original RGB file used the unavailable ChannelBooleans registry identifier and an invalid image input name. V28 uses the live registered ChannelBoolean with Background and Foreground connected to RGBBase. It preserves the original selectors 4/3/2. The resulting effect is channel remapping, slight scale and directional smear; its historical filename does not establish separate-channel spatial displacement.
+
+SubjectHalo is a static rectangular effect mask plus a position adjustment. It performs no subject detection or tracking. Bloom and Halo otherwise retain their original numeric parameters. Original settings and failure evidence remain preserved.
+
+These native tests are separate from the finished V28 film, whose source-derived treatments use rendered media. They do not modify that film or its portable archive.
diff --git a/skills/video-editing/assets/fusion/ito-v28/install_ito_v28.lua b/skills/video-editing/assets/fusion/ito-v28/install_ito_v28.lua
new file mode 100644
index 000000000..a745e363a
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/install_ito_v28.lua
@@ -0,0 +1,51 @@
+-- Run this file with Resolve's fuscript interpreter or Lua dofile().
+-- Keep the three .setting files beside it. Existing ITO_V22 files are untouched.
+local function need(ok, message)
+ if not ok then error(message, 0) end
+ return ok
+end
+local function read(path)
+ local file, message, code = io.open(path, "rb")
+ if not file then
+ need(code == 2, "Could not read " .. path .. ": " .. tostring(message))
+ return nil
+ end
+ local value = file:read("*a")
+ file:close()
+ need(value ~= nil, "Read failed: " .. path)
+ return value
+end
+local function quote(value)
+ return "'" .. value:gsub("'", "'\\''") .. "'"
+end
+local script = debug.getinfo(1, "S").source
+need(script:sub(1, 1) == "@", "Run the saved installer file, not pasted text")
+local sourceDir = need(script:sub(2):match("^(.*)/[^/]+$"), "Use the installer absolute path")
+local userHome = need(os.getenv("HOME"), "HOME is unavailable")
+local targetDir = userHome .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Macros/ITO_V28"
+local names = {
+ "ITO_V28_FlashEtherealBloom.setting",
+ "ITO_V28_RGBDisplacement.setting",
+ "ITO_V28_SubjectHalo.setting",
+}
+local payloads = {}
+for _, name in ipairs(names) do
+ local payload = need(read(sourceDir .. "/" .. name), "Missing source setting: " .. name)
+ need(#payload > 0, "Empty source setting: " .. name)
+ local existing = read(targetDir .. "/" .. name)
+ need(existing == nil or existing == payload, "Refusing to overwrite a different installed setting: " .. name)
+ payloads[name] = payload
+end
+local result = os.execute("mkdir -p " .. quote(targetDir))
+need(result == 0 or result == true, "Could not create ITO_V28 directory")
+for _, name in ipairs(names) do
+ local path = targetDir .. "/" .. name
+ if read(path) == nil then
+ local file = need(io.open(path, "wb"), "Could not create: " .. name)
+ need(file:write(payloads[name]), "Write failed: " .. name)
+ need(file:close(), "Close failed: " .. name)
+ end
+ need(read(path) == payloads[name], "Installed readback mismatch: " .. name)
+ print("VERIFIED " .. name)
+end
+print("ITO_V28_INSTALLED " .. targetDir)
diff --git a/skills/video-editing/assets/fusion/ito-v28/provenance.json b/skills/video-editing/assets/fusion/ito-v28/provenance.json
new file mode 100644
index 000000000..1be8ede2a
--- /dev/null
+++ b/skills/video-editing/assets/fusion/ito-v28/provenance.json
@@ -0,0 +1,39 @@
+{
+ "bundle": "ITO_V28",
+ "classification": "technical compatibility examples; not recommended production defaults",
+ "files": {
+ "ITO_V28_FlashEtherealBloom.setting": "6bc178e29cc1a39458f58d53d397357eb8ecb4510780d5993fb25cef30b26d47",
+ "ITO_V28_RGBDisplacement.setting": "66598d0c60e8551e1704932ef014691fc8cd684d50f1bf0646b39e3d2f4ccf36",
+ "ITO_V28_SubjectHalo.setting": "ac70fca7f622da29ad34963a737f668c73b526a7852c2504f66b719d2c14b638",
+ "install_ito_v28.lua": "b111d9ed0773e29682a8e426fa9f65099022cfac3de4330db187e9303d4a0dd4"
+ },
+ "native_verification": {
+ "reported_at_utc": "2026-09-07T11:45:06.454774+00:00",
+ "interpreter": "DaVinci Resolve bundled fuscript -l lua",
+ "syntax_passed": true,
+ "actual_install_passed": true,
+ "second_idempotent_run_passed": true,
+ "import_api": "TimelineItem.ImportFusionComp",
+ "saved_and_reopened": true,
+ "renders": {
+ "count": 6,
+ "width": 1920,
+ "height": 1080,
+ "frames_each": 30,
+ "fps": 30,
+ "fully_decoded": true
+ },
+ "original_v22_preserved": true,
+ "scope": "Prior native compatibility run. This packaging task does not rerun native installation or certify production visual quality."
+ },
+ "visual_limitations": [
+ "Bloom defaults blow highlights.",
+ "RGB performs channel remapping and smear, not independent RGB spatial displacement; defaults introduce a strong green tint.",
+ "Halo uses a static rectangular mask with no subject detection or tracking; full-frame translation introduces a border."
+ ],
+ "evidence_sha256": {
+ "installation_verification.json": "28e86f95029b8d76054236a5667cf67f181cd9b40a94b81fa0bdc1075b879bf2",
+ "verified_bundle_receipt.json": "d84f60dfefb110a8dd3ae3b8c4cfc3d73857b86aecbddf46fbcdd15f55df2008",
+ "VALIDATION.md": "76a7959394432e70e2946c166e395a2cd492a695f26668b2dbbc807bd1e8d46b"
+ }
+}
diff --git a/tests/ci/fusion-bundle.test.js b/tests/ci/fusion-bundle.test.js
new file mode 100644
index 000000000..5f969f9bd
--- /dev/null
+++ b/tests/ci/fusion-bundle.test.js
@@ -0,0 +1,75 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const crypto = require("node:crypto");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { spawnSync } = require("node:child_process");
+
+const root = path.resolve(__dirname, "../..");
+const relative = "skills/video-editing/assets/fusion/ito-v28";
+const bundle = path.join(root, relative);
+const provenance = JSON.parse(fs.readFileSync(path.join(bundle, "provenance.json")));
+const digest = (bytes) => crypto.createHash("sha256").update(bytes).digest("hex");
+
+for (const [name, expected] of Object.entries(provenance.files)) {
+ assert.equal(digest(fs.readFileSync(path.join(bundle, name))), expected, name);
+}
+assert.equal(Object.keys(provenance.files).length, 4);
+const rgb = fs.readFileSync(path.join(bundle, "ITO_V28_RGBDisplacement.setting"), "utf8");
+assert.match(rgb, /ChannelBoolean \{/);
+assert.doesNotMatch(rgb, /ChannelBooleans/);
+for (const port of ["Background", "Foreground"]) {
+ assert.ok(rgb.includes(`${port} = Input { SourceOp = "ITO_V28_RGBBase"`));
+}
+const readme = fs.readFileSync(path.join(bundle, "README.md"), "utf8");
+assert.match(readme, /not recommended production defaults/);
+assert.match(readme, /channel remapping/);
+assert.match(readme, /static rectangular/);
+assert.match(readme, /no subject detection or tracking/);
+assert.match(readme, /ImportFusionComp/);
+assert.match(readme, /provenance.json/);
+assert.ok(JSON.parse(fs.readFileSync(path.join(root, "package.json"))).files.includes("skills/video-editing/"));
+console.log("Fusion source hashes, registered wiring, scope and package ownership passed.");
+
+const productionRelative = "skills/video-editing/assets/fusion/ito-production-v1";
+const production = path.join(root, productionRelative);
+const productionProvenance = JSON.parse(fs.readFileSync(path.join(production, "provenance.json")));
+assert.equal(Object.keys(productionProvenance.files).length, 4);
+for (const [name, expected] of Object.entries(productionProvenance.files)) {
+ assert.equal(digest(fs.readFileSync(path.join(production, name))), expected, name);
+}
+const productionReadme = fs.readFileSync(path.join(production, "README.md"), "utf8");
+assert.match(productionReadme, /no object detection or tracking/);
+assert.match(productionReadme, /H264 proof renders do not contain alpha/);
+assert.match(productionReadme, /provenance.json/);
+console.log("Approved production source hashes and documented limits passed.");
+
+if (process.env.ECC_TEST_NPM_PACK === "1") {
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), "ecc-fusion-pack-"));
+ try {
+ const packed = spawnSync("npm", ["pack", "--ignore-scripts", "--json", "--pack-destination", temp], {
+ cwd: root, encoding: "utf8", timeout: 120000,
+ });
+ assert.equal(packed.status, 0, packed.stderr);
+ const info = JSON.parse(packed.stdout)[0];
+ const archive = path.join(temp, info.filename);
+ const bundles = [
+ { relative, bundle, provenance },
+ { relative: productionRelative, bundle: production, provenance: productionProvenance },
+ ];
+ for (const item of bundles) {
+ for (const name of [...Object.keys(item.provenance.files), "README.md", "provenance.json"]) {
+ const extracted = spawnSync("tar", ["-xOf", archive, `package/${item.relative}/${name}`], {
+ maxBuffer: 1024 * 1024, timeout: 30000,
+ });
+ assert.equal(extracted.status, 0, String(extracted.stderr));
+ assert.deepEqual(extracted.stdout, fs.readFileSync(path.join(item.bundle, name)), name);
+ }
+ }
+ console.log("Actual npm tarball contains all twelve Fusion bundle files byte-for-byte.");
+ } finally {
+ fs.rmSync(temp, { recursive: true, force: true });
+ }
+}
From f8640355e454b5942fa671e0a6297d3ecd050f69 Mon Sep 17 00:00:00 2001
From: Affaan Mustafa
Date: Thu, 10 Sep 2026 13:20:52 +0100
Subject: [PATCH 037/141] Consolidate recovered eval framework and operator
workflows (#3040)
* feat: consolidate offline eval and operator workflows
Compose the retained framework, operator skill, roadmap and cleanup ranges on current main. Preserve current release dependencies and keep candidate execution disabled pending OS containment. Repair draft/DOCX behavior, obligation uniqueness, trusted send and audience guidance, runner provenance and eval diagnostics.
Source-PR: 2930 0abe3727d2b500c6e4830bdeb47ed67cae3f4785
Source-PR: 2931 992b49c44ed872def49675b791168b8fcd091df6
Source-PR: 2932 4a193dd13041cb7a6bebf4d2e910a0cd32bcc797
Source-PR: 2933 59cdfe500a91949ba1415f1edd7279620f21e804
Source-Base: ca185ef5f7667078a1e70a763bd3a9c71c48acf0
* fix: repair foundation CI and update js-yaml
* fix: reconcile pending-delete capsule locks after close
---------
Co-authored-by: Claude Fable 5.1
---
.claude-plugin/marketplace.json | 2 +-
.claude-plugin/plugin.json | 2 +-
.claude/workflows/ecc-pro-security-roadmap.js | 2 +-
AGENTS.md | 4 +-
README.md | 945 +++++++-----------
README.zh-CN.md | 2 +-
RULES.md | 38 -
SOUL.md | 2 +-
WORKING-CONTEXT.md | 179 ----
agent.yaml | 4 +-
commands/plan-prd.md | 2 +
docs/ARCHITECTURE-IMPROVEMENTS.md | 146 ---
docs/ECC-2.0-SESSION-ADAPTER-DISCOVERY.md | 322 ------
docs/HERMES-OPENCLAW-MIGRATION.md | 4 +-
docs/MEGA-PLAN-REPO-PROMPTS-2026-03-12.md | 286 ------
docs/PHASE1-ISSUE-BUNDLE-2026-03-12.md | 272 -----
docs/PR-399-REVIEW-2026-03-12.md | 59 --
docs/PR-QUEUE-TRIAGE-2026-03-13.md | 355 -------
docs/ROADMAP.md | 152 +++
docs/SELECTIVE-INSTALL-DESIGN.md | 489 ---------
docs/architecture/cross-harness.md | 3 +
docs/architecture/eval-harness-frameworks.md | 330 ++++++
.../session-adapter-contract.md} | 0
docs/fixes/HOOK-FIX-20260421-ADDENDUM.md | 109 --
.../INSTALL-HOOK-WRAPPER-FIX-20260422.md | 66 --
.../PATCH-SETTINGS-SIMPLE-FIX-20260422.md | 78 --
docs/ja-JP/skills/motion-ui/SKILL.md | 11 -
.../1.10.0/discussion-announcement.md | 55 -
docs/releases/1.8.0/x-quote-eval-skills.md | 5 -
.../releases/1.8.0/x-quote-plankton-deslop.md | 5 -
.../2.1.0/assets/ecc-plan-canvas-demo.webm | Bin 286856 -> 0 bytes
.../2.2.0}/ecc-2.2-release-readiness.tdd.md | 0
.../2.2.0}/ecc-ito-real-cli-bridge.tdd.md | 0
docs/tr/AGENTS.md | 4 +-
docs/zh-CN/AGENTS.md | 4 +-
docs/zh-CN/README.md | 6 +-
ecc2/src/main.rs | 1 -
examples/eval-harness/README.md | 33 +
examples/eval-harness/gate.config.json | 12 +
examples/eval-harness/run-example.js | 146 +++
examples/eval-harness/taskset.json | 19 +
.../eval-harness/variants/baseline/run.js | 12 +
.../variants/baseline/variant.json | 6 +
.../eval-harness/variants/candidate/run.js | 15 +
.../variants/candidate/variant.json | 6 +
.../eval-harness/variants/reward-hack/run.js | 45 +
.../variants/reward-hack/variant.json | 6 +
manifests/install-components.json | 10 +-
manifests/install-modules.json | 30 +-
manifests/install-profiles.json | 1 +
package.json | 9 +-
research/ecc2-codebase-analysis.md | 172 ----
schemas/capsule-envelope.schema.json | 79 ++
scripts/eval-harness.js | 147 +++
scripts/lib/eval-harness/canonical.js | 52 +
scripts/lib/eval-harness/capsule.js | 410 ++++++++
scripts/lib/eval-harness/effect-fence.js | 5 +
scripts/lib/eval-harness/envelope.js | 251 +++++
scripts/lib/eval-harness/gate-child.js | 5 +
scripts/lib/eval-harness/gate.js | 258 +++++
scripts/lib/eval-harness/index.js | 22 +
scripts/lib/eval-harness/receipt.js | 180 ++++
scripts/lib/eval-harness/replay.js | 152 +++
skills/benchmark-methodology/SKILL.md | 7 +-
.../counterparty-channel-discipline/SKILL.md | 170 ++++
.../references/channel-policy.example.yaml | 42 +
.../references/strict-prompt.template.md | 27 +
skills/esign-field-placement/SKILL.md | 199 ++++
.../references/placement-checklist.md | 81 ++
skills/eval-harness/SKILL.md | 26 +
skills/frontend-a11y/SKILL.md | 2 +-
skills/master-agreement-generator/SKILL.md | 230 +++++
.../references/master-template.example.md | 85 ++
.../references/spec.example.json | 16 +
.../scripts/build-agreement.js | 226 +++++
skills/motion-ui/SKILL.md | 576 -----------
skills/operator-approval-loop/SKILL.md | 238 +++++
.../references/approval-ledger.sql | 230 +++++
.../references/approval_claims.py | 171 ++++
skills/plan-canvas/SKILL.md | 2 +
skills/taste/SKILL.md | 4 +-
skills/tdd-workflow/SKILL.md | 2 +-
tests/lib/eval-harness/canonical.test.js | 112 +++
tests/lib/eval-harness/capsule.test.js | 575 +++++++++++
tests/lib/eval-harness/cli.test.js | 151 +++
tests/lib/eval-harness/envelope.test.js | 178 ++++
tests/lib/eval-harness/gate.test.js | 104 ++
tests/lib/eval-harness/helpers.js | 58 ++
tests/lib/eval-harness/receipt.test.js | 334 +++++++
tests/lib/eval-harness/replay.test.js | 165 +++
tests/lib/eval-harness/security.test.js | 189 ++++
tests/scripts/eval-harness-package.test.js | 122 +++
tests/scripts/install-readme-clarity.test.js | 40 +-
tests/scripts/ito-compute-sponsor.test.js | 6 +-
tests/scripts/npm-publish-surface.test.js | 38 +-
tests/skills/build-agreement.test.js | 422 ++++++++
tests/skills/desk-pattern-skills.test.js | 287 ++++++
tests/skills/test_approval_delivery_claims.py | 443 ++++++++
98 files changed, 7738 insertions(+), 3847 deletions(-)
delete mode 100644 RULES.md
delete mode 100644 WORKING-CONTEXT.md
delete mode 100644 docs/ARCHITECTURE-IMPROVEMENTS.md
delete mode 100644 docs/ECC-2.0-SESSION-ADAPTER-DISCOVERY.md
delete mode 100644 docs/MEGA-PLAN-REPO-PROMPTS-2026-03-12.md
delete mode 100644 docs/PHASE1-ISSUE-BUNDLE-2026-03-12.md
delete mode 100644 docs/PR-399-REVIEW-2026-03-12.md
delete mode 100644 docs/PR-QUEUE-TRIAGE-2026-03-13.md
create mode 100644 docs/ROADMAP.md
delete mode 100644 docs/SELECTIVE-INSTALL-DESIGN.md
create mode 100644 docs/architecture/eval-harness-frameworks.md
rename docs/{SESSION-ADAPTER-CONTRACT.md => architecture/session-adapter-contract.md} (100%)
delete mode 100644 docs/fixes/HOOK-FIX-20260421-ADDENDUM.md
delete mode 100644 docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md
delete mode 100644 docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md
delete mode 100644 docs/ja-JP/skills/motion-ui/SKILL.md
delete mode 100644 docs/releases/1.10.0/discussion-announcement.md
delete mode 100644 docs/releases/1.8.0/x-quote-eval-skills.md
delete mode 100644 docs/releases/1.8.0/x-quote-plankton-deslop.md
delete mode 100644 docs/releases/2.1.0/assets/ecc-plan-canvas-demo.webm
rename docs/{testing => releases/2.2.0}/ecc-2.2-release-readiness.tdd.md (100%)
rename docs/{testing => releases/2.2.0}/ecc-ito-real-cli-bridge.tdd.md (100%)
create mode 100644 examples/eval-harness/README.md
create mode 100644 examples/eval-harness/gate.config.json
create mode 100644 examples/eval-harness/run-example.js
create mode 100644 examples/eval-harness/taskset.json
create mode 100644 examples/eval-harness/variants/baseline/run.js
create mode 100644 examples/eval-harness/variants/baseline/variant.json
create mode 100644 examples/eval-harness/variants/candidate/run.js
create mode 100644 examples/eval-harness/variants/candidate/variant.json
create mode 100644 examples/eval-harness/variants/reward-hack/run.js
create mode 100644 examples/eval-harness/variants/reward-hack/variant.json
delete mode 100644 research/ecc2-codebase-analysis.md
create mode 100644 schemas/capsule-envelope.schema.json
create mode 100644 scripts/eval-harness.js
create mode 100644 scripts/lib/eval-harness/canonical.js
create mode 100644 scripts/lib/eval-harness/capsule.js
create mode 100644 scripts/lib/eval-harness/effect-fence.js
create mode 100644 scripts/lib/eval-harness/envelope.js
create mode 100644 scripts/lib/eval-harness/gate-child.js
create mode 100644 scripts/lib/eval-harness/gate.js
create mode 100644 scripts/lib/eval-harness/index.js
create mode 100644 scripts/lib/eval-harness/receipt.js
create mode 100644 scripts/lib/eval-harness/replay.js
create mode 100644 skills/counterparty-channel-discipline/SKILL.md
create mode 100644 skills/counterparty-channel-discipline/references/channel-policy.example.yaml
create mode 100644 skills/counterparty-channel-discipline/references/strict-prompt.template.md
create mode 100644 skills/esign-field-placement/SKILL.md
create mode 100644 skills/esign-field-placement/references/placement-checklist.md
create mode 100644 skills/master-agreement-generator/SKILL.md
create mode 100644 skills/master-agreement-generator/references/master-template.example.md
create mode 100644 skills/master-agreement-generator/references/spec.example.json
create mode 100755 skills/master-agreement-generator/scripts/build-agreement.js
delete mode 100644 skills/motion-ui/SKILL.md
create mode 100644 skills/operator-approval-loop/SKILL.md
create mode 100644 skills/operator-approval-loop/references/approval-ledger.sql
create mode 100644 skills/operator-approval-loop/references/approval_claims.py
create mode 100644 tests/lib/eval-harness/canonical.test.js
create mode 100644 tests/lib/eval-harness/capsule.test.js
create mode 100644 tests/lib/eval-harness/cli.test.js
create mode 100644 tests/lib/eval-harness/envelope.test.js
create mode 100644 tests/lib/eval-harness/gate.test.js
create mode 100644 tests/lib/eval-harness/helpers.js
create mode 100644 tests/lib/eval-harness/receipt.test.js
create mode 100644 tests/lib/eval-harness/replay.test.js
create mode 100644 tests/lib/eval-harness/security.test.js
create mode 100644 tests/scripts/eval-harness-package.test.js
create mode 100644 tests/skills/build-agreement.test.js
create mode 100644 tests/skills/desk-pattern-skills.test.js
create mode 100644 tests/skills/test_approval_delivery_claims.py
diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 4f3624062..f76fcc4ba 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -11,7 +11,7 @@
{
"name": "ecc",
"source": "./",
- "description": "Harness-native ECC operator layer - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
+ "description": "Harness-native ECC operator layer - 68 agents, 289 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
"version": "2.2.1",
"author": {
"name": "Affaan Mustafa",
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index f3e48987a..57725413a 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ecc",
"version": "2.2.1",
- "description": "Harness-native ECC plugin for engineering teams - 68 agents, 286 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
+ "description": "Harness-native ECC plugin for engineering teams - 68 agents, 289 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
"author": {
"name": "Affaan Mustafa",
"url": "https://x.com/affaanmustafa"
diff --git a/.claude/workflows/ecc-pro-security-roadmap.js b/.claude/workflows/ecc-pro-security-roadmap.js
index 60f6abb67..43df1ecfc 100644
--- a/.claude/workflows/ecc-pro-security-roadmap.js
+++ b/.claude/workflows/ecc-pro-security-roadmap.js
@@ -124,7 +124,7 @@ phase('Survey');
const surveyThunks = [
() =>
agent(
- `${GUARDRAILS}\n\nSURVEY AgentShield's CURRENT detection capability. Read ~/GitHub/ECC/agentshield: src/rules (built-in detectors), src/* area dirs (taint, injection, supply-chain, runtime, threat-intel, sandbox, policy, remediation, evidence-pack, harness-adapters), README.md, CHANGELOG.md, WORKING-CONTEXT.md. Produce an honest capability map: what classes of agentic-security risk it detects TODAY, where the gaps are, and which capabilities could plausibly be a paid/Pro tier (e.g. continuous monitoring, fleet dashboards, hosted scanning, evidence packs, org policy). area="agentshield-capability".`,
+ `${GUARDRAILS}\n\nSURVEY AgentShield's CURRENT detection capability. Read ~/GitHub/ECC/agentshield: src/rules (built-in detectors), src/* area dirs (taint, injection, supply-chain, runtime, threat-intel, sandbox, policy, remediation, evidence-pack, harness-adapters), README.md, CHANGELOG.md. Produce an honest capability map: what classes of agentic-security risk it detects TODAY, where the gaps are, and which capabilities could plausibly be a paid/Pro tier (e.g. continuous monitoring, fleet dashboards, hosted scanning, evidence packs, org policy). area="agentshield-capability".`,
{ label: 'survey:agentshield-capability', phase: 'Survey', agentType: 'general-purpose', schema: CAPABILITY_SCHEMA }
),
() =>
diff --git a/AGENTS.md b/AGENTS.md
index 98f6f0ff4..90a36e744 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# Everything Claude Code (ECC) — Agent Instructions
-This is a **production-ready AI coding plugin** providing 68 specialized agents, 286 skills, 94 commands, and automated hook workflows for software development.
+This is a **production-ready AI coding plugin** providing 68 specialized agents, 289 skills, 94 commands, and automated hook workflows for software development.
**Version:** 2.2.1
@@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat
```
agents/ — 68 specialized subagents
-skills/ — 286 workflow skills and domain knowledge
+skills/ — 289 workflow skills and domain knowledge
commands/ — 94 slash commands
hooks/ — Trigger-based automations
rules/ — Always-follow guidelines (common + per-language)
diff --git a/README.md b/README.md
index 73b4aa7f2..ae9c2efda 100644
--- a/README.md
+++ b/README.md
@@ -68,33 +68,7 @@
## Install with Claude Code
-Run the canonical guided setup from your terminal:
-
-```bash
-npx ecc-universal setup
-```
-
-If npm reports a version or cache error, confirm the registry version before retrying:
-
-```bash
-npm view ecc-universal version
-```
-
-This path requires Node.js 18 or newer, Git, and Claude Code 2.1 or newer on
-`PATH`. It safely installs, updates, or moves one `ecc@ecc` plugin scope and
-records the hook profile you choose.
-
-Alternatively, run Claude Code's native plugin commands inside Claude Code:
-
-```text
-/plugin marketplace add https://github.com/affaan-m/ECC
-/plugin install ecc@ecc
-```
-
-The native path installs ECC's skills, agents, commands, and plugin-managed hooks. If you choose it, stop there. Do not also run a full manual install into Claude Code.
-
-> Both paths install the same `ecc@ecc` plugin. Choose one and do not stack
-> another manual Claude install on top.
+Use the [guided setup](#install-ecc) or [native plugin commands](#claude-code-details). Both install the same `ecc@ecc` plugin. Choose one and do not stack a full manual Claude install on top.
@@ -162,12 +136,12 @@ Instead of rebuilding that process in every prompt, you install it once and make
ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity.
-Access to 68 agents, 286 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
+Access to 68 agents, 289 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work.
| Included | Count | What it gives you |
| ---------------- | ----------: | ------------------------------------------------------------------------------------ |
| Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work |
-| Skills | 286 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
+| Skills | 289 skills | TDD, research, security, docs, frontend, data, ML, operations, and more |
| Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface |
| Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls |
| Rules | Selective | Always-loaded standards you choose by language or project |
@@ -191,25 +165,81 @@ Access to 68 agents, 286 skills, and 94 legacy command shims, plus hooks, rules,
### Recommended: universal guided setup
-Run the package command from your terminal. For Claude Code setup, updates,
-scope changes, and hook-profile changes:
+For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
```bash
-npx ecc-universal setup
+npx ecc-universal@2.2.1 setup
```
-To configure Claude Code, Codex, or Kimi Code in one reviewed flow:
+If npm reports a version or cache error, confirm the registry version before retrying:
```bash
-npx ecc-universal install --guided
+npm view ecc-universal version
```
+ECC 2.2 supports the same guided setup through modern package runners:
+
+| Package runner | Guided setup command |
+|---|---|
+| npm / npx | `npx ecc-universal@2.2.1 setup` |
+| pnpm | `pnpm dlx ecc-universal@2.2.1 setup` |
+| Yarn 2+ | `yarn dlx ecc-universal@2.2.1 setup` |
+| Bun | `bunx ecc-universal@2.2.1 setup` |
+
+The examples select [the published ECC 2.2.1 release](https://www.npmjs.com/package/ecc-universal/v/2.2.1), matching this repository's release version. A version pin is not a security audit or an integrity check. Review the release source and registry integrity before running package code; use a reviewed checkout for unreleased changes.
+
+Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
+
+The wizard inventories the official marketplace and every native Claude install scope before making changes, then installs, updates, or safely moves `ecc@ecc` to the scope you choose. Rerun the same command whenever you want to update ECC, change scope, or change its hook profile. This setup wizard currently configures the Claude Code plugin; use the multi-harness wizard below for Codex or Kimi Code.
+
+To configure more than one coding agent in one reviewed flow, use the multi-harness wizard:
+
+```bash
+npx ecc-universal@2.2.1 install --guided
+```
+
+It lets you select any combination of Claude Code, Codex, and Kimi Code, shows each install channel and destination, preflights every selection before the first write, and asks for one final confirmation.
+
+| Harness | Guided install behavior |
+|---|---|
+| Claude Code | Native `ecc@ecc` plugin with one `user`, `project`, or `local` scope and an ECC hook profile |
+| Codex | Native Codex marketplace/plugin lifecycle; hook review and trust remain Codex-owned |
+| Kimi Code | Managed project files under `./.kimi-code`; ECC hooks, model/provider settings, and authentication are not configured |
+
+For automation, make every provider-specific choice explicit:
+
+```bash
+npx ecc-universal@2.2.1 install --guided \
+ --harness claude --harness codex --harness kimi \
+ --claude-scope local --claude-hooks standard \
+ --profile core --yes
+```
+
+Verify the native guided Codex path and managed Kimi path without writing first:
+
+```bash
+npx ecc-universal@2.2.1 install --guided --harness codex --dry-run
+npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run
+```
+
+Additional package-name commands are also available through the 2.2 alias:
+
+```bash
+npx ecc-universal@2.2.1 consult "security reviews" --target claude
+npx ecc-universal@2.2.1 install --profile minimal --target claude --with capability:machine-learning
+npx ecc-universal@2.2.1 doctor --target kimi
+```
+
+Do not use `npx ecc-install --profile minimal --target claude`: `ecc-install` is a binary name inside `ecc-universal`, not a separately published npm package.
+
+ECC also ships advanced managed adapters for `cursor`, `antigravity`, `gemini`, `opencode`, `codebuddy`, `joycode`, `qwen`, `zed`, `hermes`, and `openclaw`. Those targets still use their documented `ecc install --target ...` paths until each adapter has passed the guided collision, update, repair, and uninstall lifecycle matrix. Neither wizard silently installs into every detected harness.
+
### Pick one path only (per harness)
You can use ECC with Claude Code, Codex, and other harnesses at the same time. Choose one install method for each harness:
- **Recommended default:** run the guided Claude plugin setup above
-- **Also supported for Claude Code:** use the [native plugin commands above](#install-with-claude-code)
+- **Also supported for Claude Code:** use the [native plugin commands](#claude-code-details)
- **Available in release 2.2:** guided package setup for Claude Code, Codex, and Kimi Code
- **Works:** Claude Code plugin + Codex native plugin
- **Works:** Claude Code plugin + the legacy Codex sync flow
@@ -224,6 +254,15 @@ If you already layered multiple installs and things look duplicated, skip straig
### Claude Code details
+Alternatively, run Claude Code's native plugin commands inside Claude Code:
+
+```text
+/plugin marketplace add https://github.com/affaan-m/ECC
+/plugin install ecc@ecc
+```
+
+The native path installs ECC's skills, agents, commands, and plugin-managed hooks. If you choose it, stop there. Do not also run a full manual install into Claude Code.
+
Claude Code owns these built-in commands, including their errors when a marketplace, plugin, or conflicting scope already exists. ECC cannot intercept that parser. If either native command reports an existing install or scope conflict, use the 2.2 guided setup or resolve the conflicting Claude plugin scope before retrying; do not layer a manual install on top.
After ECC is installed, `/ecc:configure-ecc` is the namespaced in-Claude reconfiguration skill. It delegates to the same safe setup flow, but it is available only after the plugin is installed and cannot replace Claude Code's built-in `/plugin` command during a first install.
@@ -350,74 +389,8 @@ Cursor installs agent definitions under `.cursor/agents/ecc-*.md`. Cursor-native
Deep per-harness notes (feature parity, hook adapters, limitations) live in [Platform Support](#platform-support) below.
-## Self-Hosted Models and Custom Endpoints
-
-ECC works through each harness's normal configuration, so you can use an official provider, a compatible custom API endpoint or model gateway, or a self-hosted model without changing ECC's workflows.
-
-For Claude Code, ECC does not hardcode Anthropic-hosted transport settings. Minimal gateway example:
-
-```bash
-export ANTHROPIC_BASE_URL=https://your-gateway.example.com
-export ANTHROPIC_AUTH_TOKEN=your-token
-claude
-```
-
-If your gateway remaps model names, configure that in Claude Code rather than in ECC. ECC's hooks, skills, commands, and rules are model-provider agnostic once the `claude` CLI is already working. See Anthropic's [LLM gateway documentation](https://docs.anthropic.com/en/docs/claude-code/llm-gateway) and [model configuration documentation](https://docs.anthropic.com/en/docs/claude-code/model-config).
-
-Run or self-host any open-source model behind that gateway using separate compute and serving setup. If you need GPU capacity, [Itô](https://compute.itomarkets.com) is ECC's preferred compute sponsor; any GPU provider works. The sponsorship link is passive: it does not invoke an RFQ, reserve capacity, provision compute, or configure serving. Separately, `ecc ito find` invokes the explicitly configured canonical Itô CLI and submits a live authenticated RFQ; it does not reserve capacity. Managed inference through Itô is not live yet.
-
-### Self-host Kimi with ECC + Itô compute
-
-The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint ([get a Kimi API key](https://platform.kimi.ai?aff=ecc)) or self-host an open-weight Kimi model on your own GPU capacity. This adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`):
-
-
-
-Configure the endpoint with Kimi Code's official provider guide, then install ECC:
-
-```bash
-bash ./install.sh --target kimi --profile minimal
-node scripts/ecc.js doctor --target kimi
-kimi
-```
-
-Kimi Code discovers the installed `.kimi-code/AGENTS.md` instructions and `.kimi-code/skills/` workflows natively; project-level `.agents/skills/` is also an official discovery location. ECC safely merges project MCP entries into `.kimi-code/mcp.json` and does not change the user-level `~/.kimi-code/config.toml`. Kimi Code supports native hooks, but ECC's current managed-project adapter does not configure them, so this installer does not offer Kimi hook profiles. The installer dry-run and regression suite verify that every managed Kimi write stays inside the project-local `.kimi-code/` root.
-
-### Itô compute CLI bridge
-
-`ecc ito` delegates to the separately installed canonical Itô client; ECC does not maintain a second API client. `ecc ito login [--no-browser]` performs device authorization, opens the Itô verification page by default, and persists a device token in macOS Keychain; `--no-browser` suppresses the page handoff. ECC itself does no browser automation. `ecc ito auth` is validation-only and rejects `--no-browser`. The available operations are `ecc ito login`, `ecc ito auth`, `ecc ito find`, `ecc ito status`, and the separately gated `ecc ito evals`. The matching MCP tools remain `ito_auth`, `ito_find`, and `ito_status`; `ito_auth` validates existing credentials and node qualification is CLI-only.
-
-The `ito-compute-cli` package is currently unpublished. Build it locally from the Itô runtime repo (private while the desk hardens; design partners get access) under `cli/ito-compute-cli`, run `npm ci` and `npm run check`, then set `ECC_ITO_CLI_EXECUTABLE` to that build's absolute `dist/bin/ito.js` path. Login never inherits `ITO_API_KEY`; auth, find, and status forward `ITO_API_KEY` directly when configured, and `ITO_AUTH_MODE=legacy` is not required. `ecc ito logout` revokes the current device credential and retains its local copy if remote revocation cannot be confirmed. Device tokens use macOS Keychain by default; explicit file fallback must retain owner-only directory/file permissions. ECC does not discover this credential-bearing client through `PATH`. See the [`ito-compute` skill](skills/ito-compute/SKILL.md) for the full RFQ authority and MCP setup contract.
-
-`find` submits a live authenticated RFQ. It does not reserve capacity. `evals` requires both `ITO_ENABLE_SIXTYTWO_LIVE=1` and `--live-sixtytwo`, a separately installed `sixtytwo-cli==0.3.33`, an explicit node list, and an existing absolute configuration directory. It cannot rent, launch, recover, repair, or purchase. ECC exposes no quote lock, purchase, workload, or inference path, and it never replaces a missing client or failed live call with a local result.
-
## Advanced Install Options
-The options stay here, directly under the main install paths, so you do not have to hunt through the README when the default setup is not the right fit.
-
Low-context install with no hook runtime
@@ -426,7 +399,7 @@ The options stay here, directly under the main install paths, so you do not have
Use this when you want ECC's rules, agents, commands, platform config, and core workflows without runtime hooks:
```bash
-npx ecc-universal install --profile minimal --target claude
+npx ecc-universal@2.2.1 install --profile minimal --target claude
```
From a source checkout, the equivalent command is:
@@ -592,7 +565,7 @@ ECC-managed install and Codex sync flows will skip or remove those bundled serve
`multi-*` commands are **not** covered by the base plugin/rules install.
-To use `/multi-plan`, `/multi-execute`, `/multi-backend`, `/multi-frontend`, and `/multi-workflow`, you must also install the `ccg-workflow` runtime. Initialize it with `npx ccg-workflow`.
+To use `/multi-plan`, `/multi-execute`, `/multi-backend`, `/multi-frontend`, and `/multi-workflow`, you must also install the `ccg-workflow` runtime. Choose and review an exact release using the [upstream CCG installation guide](https://github.com/fengshao1227/ccg-workflow#readme), then initialize that installed runtime. ECC does not bundle CCG or attest to a compatible, audited CCG release; this guide does not bootstrap an unspecified registry version.
That runtime provides the external dependencies these commands expect, including:
@@ -611,11 +584,11 @@ If you installed from the universal package, run these commands from the same
project directory used for installation:
```bash
-npx ecc-universal list-installed
-npx ecc-universal doctor
-npx ecc-universal repair
-npx ecc-universal uninstall --dry-run
-npx ecc-universal uninstall
+npx ecc-universal@2.2.1 list-installed
+npx ecc-universal@2.2.1 doctor
+npx ecc-universal@2.2.1 repair
+npx ecc-universal@2.2.1 uninstall --dry-run
+npx ecc-universal@2.2.1 uninstall
```
From a source checkout, inspect the managed state before reinstalling:
@@ -646,74 +619,6 @@ If you stacked methods, clean up in this order:
4. Reinstall once, using a single path.
-## Universal guided setup details
-
-> [!IMPORTANT]
-> These package-runner commands require `ecc-universal` 2.2.0 or newer and
-> Node.js 18 or newer. Claude plugin setup also requires Git and Claude Code
-> 2.1 or newer on `PATH`.
-
-For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
-
-```bash
-npx ecc-universal setup
-```
-
-ECC 2.2 supports the same guided setup through modern package runners:
-
-| Package runner | Guided setup command |
-|---|---|
-| npm / npx | `npx ecc-universal setup` |
-| pnpm | `pnpm dlx ecc-universal setup` |
-| Yarn 2+ | `yarn dlx ecc-universal setup` |
-| Bun | `bunx ecc-universal setup` |
-
-Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
-
-The wizard inventories the official marketplace and every native Claude install scope before making changes, then installs, updates, or safely moves `ecc@ecc` to the scope you choose. Rerun the same command whenever you want to update ECC, change scope, or change its hook profile. This setup wizard currently configures the Claude Code plugin; use the multi-harness wizard below for Codex or Kimi Code.
-
-To configure more than one coding agent in one reviewed flow, use the multi-harness wizard:
-
-```bash
-npx ecc-universal install --guided
-```
-
-It lets you select any combination of Claude Code, Codex, and Kimi Code, shows each install channel and destination, preflights every selection before the first write, and asks for one final confirmation.
-
-| Harness | Guided install behavior |
-|---|---|
-| Claude Code | Native `ecc@ecc` plugin with one `user`, `project`, or `local` scope and an ECC hook profile |
-| Codex | Native Codex marketplace/plugin lifecycle; hook review and trust remain Codex-owned |
-| Kimi Code | Managed project files under `./.kimi-code`; ECC hooks, model/provider settings, and authentication are not configured |
-
-For automation, make every provider-specific choice explicit:
-
-```bash
-npx ecc-universal install --guided \
- --harness claude --harness codex --harness kimi \
- --claude-scope local --claude-hooks standard \
- --profile core --yes
-```
-
-Verify the native guided Codex path and managed Kimi path without writing first:
-
-```bash
-npx ecc-universal install --guided --harness codex --dry-run
-npx ecc-universal install --profile core --target kimi --dry-run
-```
-
-Additional package-name commands are also available through the 2.2 alias:
-
-```bash
-npx ecc-universal consult "security reviews" --target claude
-npx ecc-universal install --profile minimal --target claude --with capability:machine-learning
-npx ecc-universal doctor --target kimi
-```
-
-Do not use `npx ecc-install --profile minimal --target claude`: `ecc-install` is a binary name inside `ecc-universal`, not a separately published npm package.
-
-ECC also ships advanced managed adapters for `cursor`, `antigravity`, `gemini`, `opencode`, `codebuddy`, `joycode`, `qwen`, `zed`, `hermes`, and `openclaw`. Those targets still use their documented `ecc install --target ...` paths until each adapter has passed the guided collision, update, repair, and uninstall lifecycle matrix. Neither wizard silently installs into every detected harness.
-
## Start Using ECC
Start with the workflow you need, not the full catalog.
@@ -728,7 +633,7 @@ Start with the workflow you need, not the full catalog.
| Checking context pressure | `/context-budget` |
| Ending a long session | `/save-session` or `/learn-eval` |
| Resuming later | `/resume-session` |
-| Auditing agent config | `/security-scan` or `npx -y ecc-agentshield scan --path .` |
+| Auditing agent config | `/security-scan` with a reviewed scanner, or installed `agentshield scan --path .` |
Plugin commands and manual commands
@@ -806,271 +711,83 @@ e2e-testing skill -> e2e-runner: critical user flow
```
-## What's New: ECC 2.1
+## Self-Hosted Models and Custom Endpoints
-> [!IMPORTANT]
-> **NEW IN ECC 2.1: Plan Canvas · Kimi harness · self-hosted compute on Itô GPUs.**
-> [See the full release notes →](https://github.com/affaan-m/ECC/blob/main/docs/releases/2.1.0/release-notes.md)
+ECC works through each harness's normal configuration, so you can use an official provider, a compatible custom API endpoint or model gateway, or a self-hosted model without changing ECC's workflows.
-### Plan Canvas: review plans by pointing, not retyping
-
-Your agent writes a plan, then opens it in a loopback-only browser canvas. Click the part you mean, attach numbered annotations, chat from a side rail, and hit **Approve plan** or **Request changes**. The verdict maps straight onto `/plan`'s CONFIRM gate. Mermaid diagrams render live, and edits to the plan file reload the page.
-
-
-
-It's harness- and model-agnostic: a plain CLI (`ecc-plan-canvas`) speaking JSON, so any agent can drive it. Try it: ask your agent to `/ecc:plan` anything, then review from the page instead of the terminal.
-
-[Open the plan used in this demo →](https://github.com/affaan-m/ECC/blob/main/docs/releases/2.1.0/plan-canvas-demo.plan.md)
-
-### Also in 2.1
-
-- **Kimi Code install target** (`--target kimi`): ECC installs natively into [Moonshot AI](https://www.moonshot.ai)'s Kimi Code CLI
-- **Self-host on GPUs**: a verified path with [Itô](https://compute.itomarkets.com), ECC's preferred compute sponsor, including the opt-in `ecc ito find` RFQ bridge (details and disclosures above in [Self-Hosted Models and Custom Endpoints](#self-hosted-models-and-custom-endpoints))
-- **Moonshot AI (Kimi), Itô, and Atlas Cloud** are now public sponsors
-- **Hermes + OpenClaw install targets**, a Codex navigation guide, consolidated PostToolUse hooks, and supply-chain hardening
-
-### Current development: Unified Memory Vault
-
-`ecc memory` gives Claude, Codex, Hermes, OpenClaw, Kimi, and other harnesses one local, inspectable Markdown format for durable context and handoffs. The optional `ecc-memory-mcp` stdio server exposes the same bounded save/search/read/doctor surface without enabling itself by default. Full detail in [Share context between harnesses](#share-context-between-harnesses) below.
-
-
-Previous releases
-
-| Version | Highlights |
-|---|---|
-| [v2.0.0](https://github.com/affaan-m/ECC/releases/tag/v2.0.0) | The Agent Harness Operating System: cross-harness graduation, control-pane substrate, `orch-*` orchestrators, Discord + ECC bot, single-connector MCP policy |
-| [v1.10.0](https://github.com/affaan-m/ECC/releases/tag/v1.10.0) | Surface refresh, operator workflows, ECC 2.0 alpha |
-| [v1.9.0](https://github.com/affaan-m/ECC/releases/tag/v1.9.0) | Selective install, ECC Tools Pro, 12 language ecosystems |
-| [v1.8.0](https://github.com/affaan-m/ECC/releases/tag/v1.8.0) | Harness performance and cross-platform reliability |
-| [v1.7.0](https://github.com/affaan-m/ECC/releases/tag/v1.7.0) | Cross-platform expansion and presentation builder |
-| [v1.6.0](https://github.com/affaan-m/ECC/releases/tag/v1.6.0) | Codex Edition and the ECC Tools GitHub App |
-| [v1.5.0](https://github.com/affaan-m/ECC/releases/tag/v1.5.0) | Universal Edition |
-| [v1.4.0](https://github.com/affaan-m/ECC/releases/tag/v1.4.0) | Multi-language rules, installation wizard, PM2 orchestration |
-| [v1.3.0](https://github.com/affaan-m/ECC/releases/tag/v1.3.0) | Complete OpenCode plugin support |
-| [v1.2.0](https://github.com/affaan-m/ECC/releases/tag/v1.2.0) | Unified commands and skills |
-| [v1.1.0](https://github.com/affaan-m/ECC/releases/tag/v1.1.0) | Cross-platform support and community fixes |
-| [v1.0.0](https://github.com/affaan-m/ECC/releases/tag/v1.0.0) | Official plugin release |
-
-
-
-
-Release history in detail
-
-### v2.0.0: The Agent Harness Operating System (Jun 2026)
-
-Stable graduation of the 2.0 line: the control-pane substrate (session adapters + MCP inventory), the worktree-lifecycle service, the `orch-*` orchestrator family, and the launch of the [ECC Discord community](https://discord.gg/36yGMHGFbR). Full notes: [docs/releases/2.0.0/release-notes.md](docs/releases/2.0.0/release-notes.md).
-
-### v2.0.0-rc.1: Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
-
-- **Dashboard GUI**: New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
-- **Public surface synced to the live repo**: metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface.
-- **Operator and outbound workflow expansion**: `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
-- **Media and launch tooling**: `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
-- **Framework and product surface growth**: `nestjs-patterns`, richer Codex/OpenCode install surfaces, and expanded cross-harness packaging keep the repo usable beyond a single harness.
-- **Itô prediction-market skill pack**: the consolidated `ito-baskets` skill (read-only basket index, comparison, market briefs, and non-executable planning worksheets — replacing the former `ito-market-intelligence`, `ito-basket-compare`, `ito-trade-planner`, and `ito-data-atlas-agent` skills), plus `prediction-market-oracle-research` and `prediction-market-risk-review`, add public, non-advisory market/basket workflows while keeping live Itô API access gated and separate from ECC Tools billing.
-- **Optimization skill pack**: `parallel-execution-optimizer`, `benchmark-optimization-loop`, `data-throughput-accelerator`, `latency-critical-systems`, and `recursive-decision-ledger` turn repeated speed/recursion prompts into bounded benchmark, throughput, and decision-ledger workflows.
-- **ECC 2.0 alpha in-tree**: the Rust control-plane prototype in `ecc2/` builds locally and exposes `dashboard`, `start`, `sessions`, `status`, `stop`, `resume`, and `daemon` commands.
-- **Operator status snapshots**: `ecc status --markdown --write status.md` turns the local state store into a portable handoff covering readiness, active sessions, skill-run health, install health, pending governance events, and linked work items from Linear/GitHub/handoffs.
-- **Ecosystem hardening**: AgentShield, ECC Tools cost controls, billing portal work, and website refreshes continue to ship around the core plugin instead of drifting into separate silos.
-
-### v1.9.0: Selective Install and Language Expansion (Mar 2026)
-
-- **Selective install architecture**: Manifest-driven install pipeline with `install-plan.js` and `install-apply.js` for targeted component installation. State store tracks what's installed and enables incremental updates.
-- **6 new agents**: `typescript-reviewer`, `pytorch-build-resolver`, `java-build-resolver`, `java-reviewer`, `kotlin-reviewer`, `kotlin-build-resolver` expand language coverage to 10 languages.
-- **New skills**: `pytorch-patterns`, `documentation-lookup`, `bun-runtime`, `nextjs-turbopack`, 8 operational domain skills, and `mcp-server-patterns`.
-- **Session and state infrastructure**: SQLite state store with query CLI, session adapters for structured recording, skill evolution foundation for self-improving skills.
-- **Orchestration overhaul**: Deterministic harness audit scoring, hardened orchestration status and launcher compatibility, observer loop prevention with 5-layer guard.
-- **Observer reliability**: Memory explosion fix with throttling and tail sampling, sandbox access fix, lazy-start logic, and re-entrancy guard.
-- **12 language ecosystems**: New rules for Java, PHP, Perl, Kotlin/Android/KMP, C++, and Rust join existing TypeScript, Python, Go, and common rules.
-- **Community contributions**: Korean and Chinese translations, biome hook optimization, video processing skills, operational skills, PowerShell installer, Antigravity IDE support.
-- **CI hardening**: 19 test failure fixes, catalog count enforcement, install manifest validation, and full test suite green.
-
-### v1.8.0: Harness Performance System (Mar 2026)
-
-- **Harness-first release**: ECC is explicitly framed as an agent harness performance system, not just a config pack.
-- **Hook reliability overhaul**: SessionStart root fallback, Stop-phase session summaries, and script-based hooks replacing fragile inline one-liners.
-- **Hook runtime controls**: `ECC_HOOK_PROFILE=minimal|standard|strict` and `ECC_DISABLED_HOOKS=...` for runtime gating without editing hook files.
-- **New harness commands**: `/harness-audit`, `/loop-start`, `/loop-status`, `/quality-gate`, `/model-route`.
-- **NanoClaw v2**: model routing, skill hot-load, session branch/search/export/compact/metrics.
-- **Cross-harness parity**: behavior tightened across Claude Code, Cursor, OpenCode, and Codex app/CLI.
-- **997 internal tests passing**: full suite green after hook/runtime refactor and compatibility updates.
-
-### v1.7.0: Cross-Platform Expansion and Presentation Builder (Feb 2026)
-
-- **Codex app + CLI support**: Direct `AGENTS.md`-based Codex support, installer targeting, and Codex docs
-- **`frontend-slides` skill**: Zero-dependency HTML presentation builder with PPTX conversion guidance and strict viewport-fit rules
-- **5 new generic business/content skills**: `article-writing`, `content-engine`, `market-research`, `investor-materials`, `investor-outreach`
-- **Broader tool coverage**: Cursor, Codex, and OpenCode support tightened so the same repo ships cleanly across all major harnesses
-- **992 internal tests**: Expanded validation and regression coverage across plugin, hooks, skills, and packaging
-
-### v1.6.0: Codex CLI, AgentShield, and Marketplace (Feb 2026)
-
-- **Codex CLI support**: New `/codex-setup` command generates `codex.md` for OpenAI Codex CLI compatibility
-- **7 new skills**: `search-first`, `swift-actor-persistence`, `swift-protocol-di-testing`, `regex-vs-llm-structured-text`, `content-hash-cache-pattern`, `cost-aware-llm-pipeline`, `skill-stocktake`
-- **AgentShield integration**: `/security-scan` runs AgentShield directly from Claude Code; 1282 tests, 102 rules
-- **GitHub Marketplace**: ECC Tools GitHub App live at [github.com/marketplace/ecc-tools](https://github.com/marketplace/ecc-tools) with free/pro/enterprise tiers
-- **30+ community PRs merged**: Contributions from 30 contributors across 6 languages
-- **978 internal tests**: Expanded validation suite across agents, skills, commands, hooks, and rules
-
-### v1.4.1: Bug Fix (Feb 2026)
-
-- **Fixed instinct import content loss**: `parse_instinct_file()` was silently dropping all content after frontmatter (Action, Evidence, Examples sections) during `/instinct-import`. ([#148](https://github.com/affaan-m/ECC/issues/148), [#161](https://github.com/affaan-m/ECC/pull/161))
-
-### v1.4.0: Multi-Language Rules, Installation Wizard, and PM2 (Feb 2026)
-
-- **Interactive installation wizard**: New `configure-ecc` skill provides guided setup with merge/overwrite detection
-- **PM2 and multi-agent orchestration**: 6 new commands (`/pm2`, `/multi-plan`, `/multi-execute`, `/multi-backend`, `/multi-frontend`, `/multi-workflow`) for managing complex multi-service workflows
-- **Multi-language rules architecture**: Rules restructured from flat files into `common/` + `typescript/` + `python/` + `golang/` directories. Install only the languages you need
-- **Chinese (zh-CN) translations**: Complete translation of all agents, commands, skills, and rules (80+ files)
-- **GitHub Sponsors support**: Sponsor the project via GitHub Sponsors
-- **Enhanced CONTRIBUTING.md**: Detailed PR templates for each contribution type
-
-### v1.3.0: OpenCode Plugin Support (Feb 2026)
-
-- **Full OpenCode integration**: 12 agents, 24 commands, 16 skills with hook support via OpenCode's plugin system (20+ event types)
-- **3 native custom tools**: run-tests, check-coverage, security-audit
-- **LLM documentation**: `llms.txt` for comprehensive OpenCode docs
-
-### v1.2.0: Unified Commands and Skills (Feb 2026)
-
-- **Python/Django support**: Django patterns, security, TDD, and verification skills
-- **Java Spring Boot skills**: Patterns, security, TDD, and verification for Spring Boot
-- **Session management**: `/sessions` command for session history
-- **Continuous learning v2**: Instinct-based learning with confidence scoring, import/export, evolution
-
-See the full changelog in [Releases](https://github.com/affaan-m/ECC/releases).
-
-
-## Why Choose ECC?
-
-| Without a system | With ECC |
-| ------------------------------------------------------- | --------------------------------------------------------------------- |
-| Plans disappear into chat history | Plans become editable artifacts before implementation starts |
-| "Please use TDD" is an instruction the model may forget | TDD becomes a gated RED -> GREEN -> REFACTOR workflow with evidence |
-| The same context writes and reviews the code | A fresh-context reviewer looks for regressions and blind spots |
-| Memory means saving an enormous transcript | Sessions are distilled into summaries, instincts, and reusable skills |
-| Quality checks depend on reminders | Hooks can enforce deterministic checks outside the prompt |
-| Agent configuration is trusted by default | AgentShield scans the harness itself as an attack surface |
-
-### TDD: Test-Driven Development
-
-```text
-/ecc:plan "Add usage-based billing alerts"
- -> confirm or edit the plan
- -> activate tdd-workflow
- -> capture RED evidence before implementation
- -> implement until GREEN
- -> review from fresh context
- -> fix findings with regression tests
- -> verify build, lint, types, and tests
-```
-
-A result is not just code. It's a trail of evidence: the plan, the failing test, the passing test, the review findings, and the final verification.
-
-### Skills keep the context focused
-
-Rules, skills, agents, and hooks solve different problems. Keeping those jobs separate is how ECC adds capability without dumping the entire repository into every session.
-
-| Concept | What it does | Context behavior |
-|---|---|---|
-| Skills | Reusable workflows such as TDD, security review, or deep research | Loaded when the task needs them |
-| Agents | Scoped workers with their own context and tool permissions | Isolate planning, implementation, and review |
-| Rules | Durable project or language standards | Always loaded, so install them selectively |
-| Hooks | Scripts triggered by harness events | Run outside the model context |
-| Instincts | Patterns learned from real sessions with confidence scores | Recalled when relevant |
-
-### Share context between harnesses
-
-ECC's Memory Vault gives Claude, Codex, Hermes, OpenClaw, Kimi, and other harnesses one local, inspectable Markdown format for durable context and handoffs. Project and team memories live under `.ecc/memory/`; user memories live under `~/.ecc/memory/`.
+For Claude Code, ECC does not hardcode Anthropic-hosted transport settings. Minimal gateway example:
```bash
-npm install -g ecc-universal
-ecc memory init --scope project
-ecc memory search "authentication migration" --target-harness codex
-ecc memory doctor
+export ANTHROPIC_BASE_URL=https://your-gateway.example.com
+export ANTHROPIC_AUTH_TOKEN=your-token
+claude
```
-Memory is unreviewed context, not executable policy. Verify important claims against authoritative sources and promote accepted knowledge into governed project documentation. The optional `ecc-memory-mcp` server exposes the same bounded save, search, read, and doctor surface without enabling itself by default.
+If your gateway remaps model names, configure that in Claude Code rather than in ECC. ECC's hooks, skills, commands, and rules are model-provider agnostic once the `claude` CLI is already working. See Anthropic's [LLM gateway documentation](https://docs.anthropic.com/en/docs/claude-code/llm-gateway) and [model configuration documentation](https://docs.anthropic.com/en/docs/claude-code/model-config).
-[Open the Unified Memory workflow →](skills/unified-memory/SKILL.md)
+Run or self-host any open-source model behind that gateway using separate compute and serving setup. If you need GPU capacity, [Itô](https://compute.itomarkets.com) is ECC's preferred compute sponsor; any GPU provider works. The sponsorship link is passive: it does not invoke an RFQ, reserve capacity, provision compute, or configure serving. Separately, `ecc ito find` invokes the explicitly configured canonical Itô CLI and submits a live authenticated RFQ; it does not reserve capacity. Managed inference through Itô is not live yet.
-
-Memory Vault in depth: scopes, handoffs, and trust boundaries
+### Self-host Kimi with ECC + Itô compute
-The Memory Vault stores portable `ecc.memory.v1` Markdown documents instead of copying vendor transcripts or emailing context between agents. Project memories are protected by a fail-closed `.gitignore`; use the team scope only for human-inspected, version-controlled sharing. Team memories remain unreviewed context even after they are committed.
+The Kimi Code harness and the model-serving layer are separate. ECC configures the agent harness; you bring an API endpoint ([get a Kimi API key](https://platform.kimi.ai?aff=ecc)) or self-host an open-weight Kimi model on your own GPU capacity. This adapter is verified against Kimi Code 0.31.x (`@moonshot-ai/kimi-code`):
-Skill-only, minimal, manual, and Claude plugin installs do not put the Memory Vault runtime on `PATH`. Install the npm runtime separately before using the CLI or optional MCP server:
-
-```bash
-npm install -g ecc-universal
-ecc memory --help
-command -v ecc-memory-mcp
-```
-
-```bash
-# Initialize the project vault.
-ecc memory init --scope project
-
-# Write a handoff body to a regular file, then target the next harness.
-ecc memory handoff \
- --from hermes \
- --target codex \
- --title "Continue authentication migration" \
- --body-file ./handoff.md
-
-# Recall it from another harness.
-ecc memory search "authentication migration" --target-harness codex
-ecc memory read
-
-# Validate the vault before sharing team memories.
-ecc memory doctor
-```
-
-Memory bodies are accepted only through `--stdin` or `--body-file`, not as command-line values. The first release keeps every vault entry unreviewed and create-only; human review promotes accepted knowledge into governed project documentation rather than changing memory trust. Normal search recall returns active project and team memories. A direct ID read may inspect a non-active entry. User-scope recall must be requested explicitly. Agents must verify important claims against authoritative sources and must never treat recalled bodies as executable instructions or policy.
-
-For opt-in MCP access, add the `ecc-memory-vault` entry from [`mcp-configs/mcp-servers.json`](mcp-configs/mcp-servers.json) to each harness that needs it, then run `ecc-memory-mcp`. The server exposes only `memory_save`, `memory_search`, `memory_read`, and `memory_doctor`. Each server must launch with a lowercase `ECC_MEMORY_HARNESS` identity; the identity is server-bound and cannot be supplied by a tool caller. User scope additionally requires the operator-controlled `ECC_MEMORY_ALLOW_USER_SCOPE=1` opt-in. See [`skills/unified-memory/SKILL.md`](skills/unified-memory/SKILL.md) for the workflow and trust boundaries, and [`docs/design/ecc-memory-vault.md`](docs/design/ecc-memory-vault.md) for the capability contract.
-
-
-## Guides
-
-This repo is the raw code. The guides explain everything.
-
-
-| Topic | What You'll Learn |
-|-------|-------------------|
-| Token Optimization | Model selection, system prompt slimming, background processes |
-| Memory Persistence | Hooks that save/load context across sessions automatically |
-| Continuous Learning | Auto-extract patterns from sessions into reusable skills |
-| Verification Loops | Checkpoint vs continuous evals, grader types, pass@k metrics |
-| Parallelization | Git worktrees, cascade method, when to scale instances |
-| Subagent Orchestration | The context problem, iterative retrieval pattern |
+Configure the endpoint with Kimi Code's official provider guide, then install ECC:
-[Commands Quick Reference](./COMMANDS-QUICK-REF.md) | [Manual Adaptation Guide](docs/MANUAL-ADAPTATION-GUIDE.md)
+```bash
+bash ./install.sh --target kimi --profile minimal
+node scripts/ecc.js doctor --target kimi
+kimi
+```
+
+Kimi Code discovers the installed `.kimi-code/AGENTS.md` instructions and `.kimi-code/skills/` workflows natively; project-level `.agents/skills/` is also an official discovery location. ECC safely merges project MCP entries into `.kimi-code/mcp.json` and does not change the user-level `~/.kimi-code/config.toml`. Kimi Code supports native hooks, but ECC's current managed-project adapter does not configure them, so this installer does not offer Kimi hook profiles. The installer dry-run and regression suite verify that every managed Kimi write stays inside the project-local `.kimi-code/` root.
+
+### Itô compute CLI bridge
+
+`ecc ito` delegates to the separately installed canonical Itô client; ECC does not maintain a second API client. `ecc ito login [--no-browser]` performs device authorization, opens the Itô verification page by default, and persists a device token in macOS Keychain; `--no-browser` suppresses the page handoff. ECC itself does no browser automation. `ecc ito auth` is validation-only and rejects `--no-browser`. The available operations are `ecc ito login`, `ecc ito auth`, `ecc ito find`, `ecc ito status`, and the separately gated `ecc ito evals`. The matching MCP tools remain `ito_auth`, `ito_find`, and `ito_status`; `ito_auth` validates existing credentials and node qualification is CLI-only.
+
+The `ito-compute-cli` package is currently unpublished. Build it locally from the Itô runtime repo (private while the desk hardens; design partners get access) under `cli/ito-compute-cli`, run `npm ci` and `npm run check`, then set `ECC_ITO_CLI_EXECUTABLE` to that build's absolute `dist/bin/ito.js` path. Login never inherits `ITO_API_KEY`; auth, find, and status forward `ITO_API_KEY` directly when configured, and `ITO_AUTH_MODE=legacy` is not required. `ecc ito logout` revokes the current device credential and retains its local copy if remote revocation cannot be confirmed. Device tokens use macOS Keychain by default; explicit file fallback must retain owner-only directory/file permissions. ECC does not discover this credential-bearing client through `PATH`. See the [`ito-compute` skill](skills/ito-compute/SKILL.md) for the full RFQ authority and MCP setup contract.
+
+`find` submits a live authenticated RFQ. It does not reserve capacity. `evals` requires both `ITO_ENABLE_SIXTYTWO_LIVE=1` and `--live-sixtytwo`, a separately installed `sixtytwo-cli==0.3.33`, an explicit node list, and an existing absolute configuration directory. It cannot rent, launch, recover, repair, or purchase. ECC exposes no quote lock, purchase, workload, or inference path, and it never replaces a missing client or failed live call with a local result.
+
+## What's New
+
+Current release: **2.2.1** (2026-08-31). Highlights of the 2.2 line:
+
+- Guided, manifest-driven setup across Claude Code, Codex, and Kimi Code, with install-state ownership, doctor, repair, and uninstall.
+- Native Antigravity install, a thin Pi adapter, and the packed-artifact release gate tested on Linux, macOS, and Windows.
+- Plan Canvas browser review, the unified memory vault (`ecc memory`), and the Itô compute skill family.
+
+Full history: [CHANGELOG.md](CHANGELOG.md). Per-release notes and evidence live under [docs/releases/](docs/releases/).
+
+### v2.0.0: The Agent Harness Operating System (Jun 2026)
+
+Stable graduation of the 2.0 line: control-pane substrate, worktree lifecycle service, the `orch-*` orchestrator family, and the Discord community. Notes: [docs/releases/2.0.0/release-notes.md](docs/releases/2.0.0/release-notes.md).
## What's Inside
@@ -1324,88 +1041,6 @@ python3 ./ecc_dashboard.py
- Search and filter across all components
-## Ecosystem Tools
-
-
-Skill Creator: generate skills from your git history
-
-Two ways to generate skills from your repository:
-
-### Option A: Local Analysis (Built-in)
-
-Use the `/skill-create` command for local analysis without external services:
-
-```bash
-/skill-create # Analyze current repo
-/skill-create --instincts # Also generate instincts for continuous-learning-v2
-```
-
-This analyzes your git history locally and generates SKILL.md files.
-
-### Option B: GitHub App (Advanced)
-
-For advanced features (10k+ commits, auto-PRs, team sharing):
-
-[Install ECC Tools GitHub App](https://github.com/apps/ecc-tools) | [ecc.tools](https://ecc.tools)
-
-```bash
-# Comment on any issue:
-/ecc-tools analyze
-```
-
-Both options create:
-- **SKILL.md files**: Ready-to-use skills for the active harness
-- **Instinct collections**: For continuous-learning-v2
-- **Pattern extraction**: Learns from your commit history
-
-
-
-AgentShield: security auditor for agent configs
-
-> Built at the Claude Code Hackathon (Cerebral Valley x Anthropic, Feb 2026). 1282 tests, 98% coverage, 102 static analysis rules.
-
-Scan your agent configuration for vulnerabilities, misconfigurations, and injection risks.
-
-```bash
-# Quick scan (no install needed)
-npx ecc-agentshield scan
-
-# Auto-fix safe issues
-npx ecc-agentshield scan --fix
-
-# Deep analysis with three Opus 4.6 agents
-npx ecc-agentshield scan --opus --stream
-
-# Generate secure config from scratch
-npx ecc-agentshield init
-```
-
-**What it scans:** CLAUDE.md, settings.json, MCP configs, hooks, agent definitions, and skills across 5 categories: secrets detection (14 patterns), permission auditing, hook injection analysis, MCP server risk profiling, and agent config review.
-
-**The `--opus` flag** runs three Claude Opus 4.6 agents in a red-team/blue-team/auditor pipeline. The attacker finds exploit chains, the defender evaluates protections, and the auditor synthesizes both into a prioritized risk assessment. Adversarial reasoning, not just pattern matching.
-
-**Output formats:** Terminal (color-graded A-F), JSON (CI pipelines), Markdown, HTML. Exit code 2 on critical findings for build gates.
-
-Use `/security-scan` in Claude Code to run it, or add to CI with the [GitHub Action](https://github.com/affaan-m/agentshield).
-
-[GitHub](https://github.com/affaan-m/agentshield) | [npm](https://www.npmjs.com/package/ecc-agentshield)
-
-
-
-Continuous Learning v2: instincts
-
-The instinct-based learning system automatically learns your patterns:
-
-```bash
-/instinct-status # Show learned instincts with confidence
-/instinct-import # Import instincts from others
-/instinct-export # Export your instincts for sharing
-/evolve # Cluster related instincts into skills
-```
-
-See `skills/continuous-learning-v2/` for full documentation. Keep `continuous-learning/` only when you explicitly want the legacy v1 Stop-hook learned-skill flow.
-
-
## Key Concepts
@@ -1472,7 +1107,139 @@ rules/
See [`rules/README.md`](rules/README.md) for installation and structure details.
-## Cross-Platform Support
+## Guides
+
+This repo is the raw code. The guides explain everything.
+
+
+
+| Topic | What You'll Learn |
+|-------|-------------------|
+| Token Optimization | Model selection, system prompt slimming, background processes |
+| Memory Persistence | Hooks that save/load context across sessions automatically |
+| Continuous Learning | Auto-extract patterns from sessions into reusable skills |
+| Verification Loops | Checkpoint vs continuous evals, grader types, pass@k metrics |
+| Parallelization | Git worktrees, cascade method, when to scale instances |
+| Subagent Orchestration | The context problem, iterative retrieval pattern |
+
+[Commands Quick Reference](./COMMANDS-QUICK-REF.md) | [Manual Adaptation Guide](docs/MANUAL-ADAPTATION-GUIDE.md) | [Troubleshooting FAQ](./TROUBLESHOOTING.md) | [Roadmap](docs/ROADMAP.md)
+
+## Why Choose ECC?
+
+| Without a system | With ECC |
+| ------------------------------------------------------- | --------------------------------------------------------------------- |
+| Plans disappear into chat history | Plans become editable artifacts before implementation starts |
+| "Please use TDD" is an instruction the model may forget | TDD becomes a gated RED -> GREEN -> REFACTOR workflow with evidence |
+| The same context writes and reviews the code | A fresh-context reviewer looks for regressions and blind spots |
+| Memory means saving an enormous transcript | Sessions are distilled into summaries, instincts, and reusable skills |
+| Quality checks depend on reminders | Hooks can enforce deterministic checks outside the prompt |
+| Agent configuration is trusted by default | AgentShield scans the harness itself as an attack surface |
+
+### TDD: Test-Driven Development
+
+```text
+/ecc:plan "Add usage-based billing alerts"
+ -> confirm or edit the plan
+ -> activate tdd-workflow
+ -> capture RED evidence before implementation
+ -> implement until GREEN
+ -> review from fresh context
+ -> fix findings with regression tests
+ -> verify build, lint, types, and tests
+```
+
+A result is not just code. It's a trail of evidence: the plan, the failing test, the passing test, the review findings, and the final verification.
+
+### Skills keep the context focused
+
+Rules, skills, agents, and hooks solve different problems. Keeping those jobs separate is how ECC adds capability without dumping the entire repository into every session.
+
+| Concept | What it does | Context behavior |
+|---|---|---|
+| Skills | Reusable workflows such as TDD, security review, or deep research | Loaded when the task needs them |
+| Agents | Scoped workers with their own context and tool permissions | Isolate planning, implementation, and review |
+| Rules | Durable project or language standards | Always loaded, so install them selectively |
+| Hooks | Scripts triggered by harness events | Run outside the model context |
+| Instincts | Patterns learned from real sessions with confidence scores | Recalled when relevant |
+
+### Share context between harnesses
+
+ECC's Memory Vault gives Claude, Codex, Hermes, OpenClaw, Kimi, and other harnesses one local, inspectable Markdown format for durable context and handoffs. Project and team memories live under `.ecc/memory/`; user memories live under `~/.ecc/memory/`.
+
+Skill-only, minimal, manual, and Claude plugin installs do not put the Memory Vault runtime on `PATH`. Install the npm runtime separately before using the CLI or optional MCP server:
+
+```bash
+npm install -g ecc-universal@2.2.1
+ecc memory init --scope project
+ecc memory search "authentication migration" --target-harness codex
+ecc memory doctor
+```
+
+Memory is unreviewed context, not executable policy. Verify important claims against authoritative sources and promote accepted knowledge into governed project documentation. The optional `ecc-memory-mcp` server exposes the same bounded save, search, read, and doctor surface without enabling itself by default.
+
+[Open the Unified Memory workflow →](skills/unified-memory/SKILL.md)
+
+
+Memory Vault in depth: scopes, handoffs, and trust boundaries
+
+The Memory Vault stores portable `ecc.memory.v1` Markdown documents instead of copying vendor transcripts or emailing context between agents. Project memories are protected by a fail-closed `.gitignore`; use the team scope only for human-inspected, version-controlled sharing. Team memories remain unreviewed context even after they are committed.
+
+After installing the runtime above, check that the CLI and optional MCP entry point are available:
+
+```bash
+ecc memory --help
+command -v ecc-memory-mcp
+```
+
+```bash
+# Initialize the project vault.
+ecc memory init --scope project
+
+# Write a handoff body to a regular file, then target the next harness.
+ecc memory handoff \
+ --from hermes \
+ --target codex \
+ --title "Continue authentication migration" \
+ --body-file ./handoff.md
+
+# Recall it from another harness.
+ecc memory search "authentication migration" --target-harness codex
+ecc memory read
+
+# Validate the vault before sharing team memories.
+ecc memory doctor
+```
+
+Memory bodies are accepted only through `--stdin` or `--body-file`, not as command-line values. The first release keeps every vault entry unreviewed and create-only; human review promotes accepted knowledge into governed project documentation rather than changing memory trust. Normal search recall returns active project and team memories. A direct ID read may inspect a non-active entry. User-scope recall must be requested explicitly. Agents must verify important claims against authoritative sources and must never treat recalled bodies as executable instructions or policy.
+
+For opt-in MCP access, add the `ecc-memory-vault` entry from [`mcp-configs/mcp-servers.json`](mcp-configs/mcp-servers.json) to each harness that needs it, then run `ecc-memory-mcp`. The server exposes only `memory_save`, `memory_search`, `memory_read`, and `memory_doctor`. Each server must launch with a lowercase `ECC_MEMORY_HARNESS` identity; the identity is server-bound and cannot be supplied by a tool caller. User scope additionally requires the operator-controlled `ECC_MEMORY_ALLOW_USER_SCOPE=1` opt-in. See [`skills/unified-memory/SKILL.md`](skills/unified-memory/SKILL.md) for the workflow and trust boundaries, and [`docs/design/ecc-memory-vault.md`](docs/design/ecc-memory-vault.md) for the capability contract.
+
+
+## Platform Support
ECC's core Node.js CLI and managed installers run on **Windows, macOS, and Linux**, but optional capabilities are not at full parity. Some continuous-learning, GAN, and orchestration paths still require Bash or Python; harnesses also expose different hook, agent, and skill APIs.
@@ -1485,6 +1252,15 @@ ECC's core Node.js CLI and managed installers run on **Windows, macOS, and Linux
Treat `stable`, `beta`, `experimental`, and `instruction-only` below as capability statements, not marketing tiers.
+| Harness | Status | Recommended distribution | Important limitation |
+|---|---|---|---|
+| Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. |
+| Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. |
+| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). |
+| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
+| GitHub Copilot | Instruction-only | Checked-in instructions and prompt files | No ECC hooks, runtime agents, delegation, or native skill discovery. |
+| Gemini, Zed, Antigravity, Qwen, Hermes, OpenClaw, Kimi, CodeBuddy, JoyCode | Experimental/minimal adapters | Harness-specific selective target | File placement and instruction portability are tested; full Claude feature parity is not claimed. |
+
Package manager detection
@@ -1583,16 +1359,8 @@ Paths resolved under that root include:
See [affaan-m/ECC#2065](https://github.com/affaan-m/ECC/issues/2065).
-## Platform Support
-
-| Harness | Status | Recommended distribution | Important limitation |
-|---|---|---|---|
-| Claude Code | Stable primary | Plugin or selective installer | The plugin advertises the installed catalog to the model; use a selective/manual profile when context footprint matters. Optional shell-backed skills are not portable to every OS. |
-| Codex | Supported native plugin | Codex marketplace plugin or repo config | Native hooks require an explicit trust decision and do not use Claude's hook profiles. The legacy sync is compatibility-only. |
-| Cursor | Beta project adapter | Selective installer into `.cursor/` | Agent discovery varies by Cursor build, and ECC's installer paths do not yet expose identical hook sets ([#2419](https://github.com/affaan-m/ECC/issues/2419)). |
-| OpenCode | Beta built plugin | Build plugin, then selective installer | ECC ships a subset of the catalog; connect a provider and select a model in OpenCode ([#2617](https://github.com/affaan-m/ECC/issues/2617)). |
-| GitHub Copilot | Instruction-only | Checked-in instructions and prompt files | No ECC hooks, runtime agents, delegation, or native skill discovery. |
-| Gemini, Zed, Antigravity, Qwen, Hermes, OpenClaw, Kimi, CodeBuddy, JoyCode | Experimental/minimal adapters | Harness-specific selective target | File placement and instruction portability are tested; full Claude feature parity is not claimed. |
+
+Cross-tool capability map and per-harness notes
### Cross-tool capability map
@@ -1788,13 +1556,12 @@ The adapter writes ECC-managed files under `.zed/` and keeps BYOK/OpenRouter cre
ECC provides a beta OpenCode plugin integration with instructions, a catalog subset, commands, custom tools, and hook events. It does not provide feature parity with Claude Code. The reference config inherits the user's OpenCode model selection instead of pinning a provider-specific model.
```bash
-# Install OpenCode
-npm install -g opencode
-
-# Run in the repository root
+# Run your reviewed OpenCode installation in the repository root
opencode
```
+For installation, use the [official OpenCode instructions](https://opencode.ai/docs/), select an exact release, and verify it before execution. The upstream npm package is `opencode-ai`, not `opencode`. ECC does not attest to an audited OpenCode runtime version.
+
The configuration is automatically detected from `.opencode/opencode.json`.
#### Hook support via plugins
@@ -1821,7 +1588,7 @@ opencode
**Option 2: Install as npm package**
```bash
-npm install ecc-universal
+npm install ecc-universal@2.2.1
```
Then add to your `opencode.json`:
@@ -1899,6 +1666,7 @@ ECC v2.0.0 stabilizes the 2.0 line with the public Hermes operator story, 281 sk
- [Hermes setup guide](docs/HERMES-SETUP.md)
- [Migration guide from 1.x](docs/MIGRATION-1X-TO-2.0.md)
+
## Token Optimization
@@ -2013,10 +1781,10 @@ Install ECC only from official sources:
- GitHub App:
- Website:
-Scan a project with AgentShield:
+Scan a project with an already installed, reviewed AgentShield binary (see [runner provenance](#agentshield-runner-provenance)):
```bash
-npx -y ecc-agentshield scan --path .
+agentshield scan --path .
```
- **Report a vulnerability.** Use the private process in [SECURITY.md](SECURITY.md) (GitHub private vulnerability reporting). Please do not open public issues for security reports.
@@ -2043,6 +1811,91 @@ Security references:
- [MCP connector policy](docs/MCP-CONNECTOR-POLICY.md)
- [Supply-chain incident response](docs/security/supply-chain-incident-response.md)
+## Ecosystem Tools
+
+
+Skill Creator: generate skills from your git history
+
+Two ways to generate skills from your repository:
+
+### Option A: Local Analysis (Built-in)
+
+Use the `/skill-create` command for local analysis without external services:
+
+```bash
+/skill-create # Analyze current repo
+/skill-create --instincts # Also generate instincts for continuous-learning-v2
+```
+
+This analyzes your git history locally and generates SKILL.md files.
+
+### Option B: GitHub App (Advanced)
+
+For advanced features (10k+ commits, auto-PRs, team sharing):
+
+[Install ECC Tools GitHub App](https://github.com/apps/ecc-tools) | [ecc.tools](https://ecc.tools)
+
+```bash
+# Comment on any issue:
+/ecc-tools analyze
+```
+
+Both options create:
+- **SKILL.md files**: Ready-to-use skills for the active harness
+- **Instinct collections**: For continuous-learning-v2
+- **Pattern extraction**: Learns from your commit history
+
+
+
+AgentShield: security auditor for agent configs
+
+> Built at the Claude Code Hackathon (Cerebral Valley x Anthropic, Feb 2026). 1282 tests, 98% coverage, 102 static analysis rules.
+
+Scan your agent configuration for vulnerabilities, misconfigurations, and injection risks.
+
+
+**Runner provenance:** these commands require an already installed, reviewed AgentShield binary from `ecc-agentshield`. The [official package](https://www.npmjs.com/package/ecc-agentshield) documents the `agentshield` CLI. Record the selected release, reviewed source and verified package integrity in your installation record. Registry publication alone does not establish an audit; ECC does not supply an audited AgentShield pin here. Do not substitute an unversioned one-shot download. `/security-scan` is workflow guidance and has the same runner prerequisite.
+
+```bash
+# Scan only the intended project directory
+agentshield scan --path .
+
+# Auto-fix safe issues
+agentshield scan --path . --fix
+
+# Deep analysis with three Opus 4.6 agents
+agentshield scan --path . --opus --stream
+
+# Generate secure config from scratch
+agentshield init
+```
+
+**What it scans:** CLAUDE.md, settings.json, MCP configs, hooks, agent definitions, and skills across 5 categories: secrets detection (14 patterns), permission auditing, hook injection analysis, MCP server risk profiling, and agent config review.
+
+**The `--opus` flag** runs three Claude Opus 4.6 agents in a red-team/blue-team/auditor pipeline. The attacker finds exploit chains, the defender evaluates protections, and the auditor synthesizes both into a prioritized risk assessment. Adversarial reasoning, not just pattern matching.
+
+**Output formats:** Terminal (color-graded A-F), JSON (CI pipelines), Markdown, HTML. Exit code 2 on critical findings for build gates.
+
+Use `/security-scan` in Claude Code to run it, or add to CI with the [GitHub Action](https://github.com/affaan-m/agentshield).
+
+[GitHub](https://github.com/affaan-m/agentshield) | [npm](https://www.npmjs.com/package/ecc-agentshield)
+
+
+
+Continuous Learning v2: instincts
+
+The instinct-based learning system automatically learns your patterns:
+
+```bash
+/instinct-status # Show learned instincts with confidence
+/instinct-import # Import instincts from others
+/instinct-export # Export your instincts for sharing
+/evolve # Cluster related instincts into skills
+```
+
+See `skills/continuous-learning-v2/` for full documentation. Keep `continuous-learning/` only when you explicitly want the legacy v1 Stop-hook learned-skill flow.
+
+
## Troubleshooting
@@ -2076,55 +1929,7 @@ node scripts/codex/check-plugin-cache.js
If it reports unresolved parent references, refresh the native cache with `codex plugin marketplace upgrade ecc`, run `codex plugin add ecc@ecc` again, and restart Codex. Registration in `codex plugin list` confirms the marketplace entry, while the cache check verifies that the installed manifest can resolve its skills, MCP configuration, and assets. Use `bash scripts/sync-ecc-to-codex.sh` only when you intentionally need the legacy copied-configuration compatibility path.
-
-My context window is shrinking
-
-Too many MCP servers eat your context. Each MCP tool description consumes tokens from your 200k window, potentially reducing it to ~70k. SessionStart context is capped at 8000 characters by default; lower it with `ECC_SESSION_START_MAX_CHARS=4000` or disable it with `ECC_SESSION_START_CONTEXT=off` for local-model or low-context setups.
-
-**Fix:** Disable unused MCPs from Claude Code with `/mcp`. Claude Code writes those runtime choices to `~/.claude.json`; `.claude/settings.json` and `.claude/settings.local.json` are not reliable toggles for already-loaded MCP servers.
-
-Keep under 10 MCPs enabled and under 80 tools active.
-
-
-
-Can I use only some components (e.g., just agents)?
-
-Yes. Use the manual component copies in [Advanced Install Options](#advanced-install-options) and copy only what you need:
-
-```bash
-# Just agents
-cp agents/*.md ~/.claude/agents/
-
-# Just rules
-mkdir -p ~/.claude/rules/ecc/
-cp -r rules/common ~/.claude/rules/ecc/
-```
-
-Each component is fully independent.
-
-
-
-Does this work with Cursor / OpenCode / Codex / Antigravity / GitHub Copilot?
-
-Yes. ECC is cross-platform:
-- **Cursor**: Pre-translated configs in `.cursor/`. See [Platform Support](#platform-support).
-- **Gemini CLI**: Experimental project-local support via `.gemini/GEMINI.md` and shared installer plumbing.
-- **OpenCode**: Beta plugin integration in `.opencode/`; models follow the user's OpenCode selection, while catalog parity remains limited.
-- **Codex**: Supported native marketplace plugin for the app and CLI, plus repo-local configuration. The older sync flow remains available only for compatibility.
-- **GitHub Copilot (VS Code)**: Instruction and prompt layer via `.github/copilot-instructions.md`, `.vscode/settings.json`, and `.github/prompts/`.
-- **Antigravity**: Native Antigravity 2.0 setup for workflows, skills, custom agents, and flattened rules in `.agents/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
-- **JoyCode / CodeBuddy**: Project-local selective install adapters for commands, agents, skills, and flattened rules. See [JoyCode Adapter Guide](docs/JOYCODE-GUIDE.md).
-- **Qwen CLI**: Home-directory selective install adapter for commands, agents, skills, rules, and Qwen config. See [Qwen CLI Adapter Guide](docs/QWEN-GUIDE.md).
-- **Zed**: Project-local selective install adapter for `.zed/settings.json`, flattened rules, commands, agents, and skills.
-- **Non-native harnesses**: Manual fallback path for chat-style interfaces. See [Manual Adaptation Guide](docs/MANUAL-ADAPTATION-GUIDE.md).
-- **Claude Code**: Native. This is the primary target.
-
-
-
-My platform is not listed
-
-Use the [manual adaptation guide](docs/MANUAL-ADAPTATION-GUIDE.md), or open a [GitHub discussion](https://github.com/affaan-m/ECC/discussions) with the harness name and the file, skill, command, and hook formats it supports.
-
+More answers: [TROUBLESHOOTING.md](TROUBLESHOOTING.md) covers memory, hooks, installation, performance, and common error messages. [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) tracks workarounds for open Claude Code bugs.
## Running Tests
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 8eb90eba8..ac56bc3ea 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc
```
-**完成!** 你现在可以使用 68 个代理、286 个技能和 94 个命令。
+**完成!** 你现在可以使用 68 个代理、289 个技能和 94 个命令。
### multi-* 命令需要额外配置
diff --git a/RULES.md b/RULES.md
deleted file mode 100644
index 551f16e68..000000000
--- a/RULES.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Rules
-
-## Must Always
-- Delegate to specialized agents for domain tasks.
-- Write tests before implementation and verify critical paths.
-- Validate inputs and keep security checks intact.
-- Prefer immutable updates over mutating shared state.
-- Follow established repository patterns before inventing new ones.
-- Keep contributions focused, reviewable, and well-described.
-
-## Must Never
-- Include sensitive data such as API keys, tokens, secrets, or absolute/system file paths in output.
-- Submit untested changes.
-- Bypass security checks or validation hooks.
-- Duplicate existing functionality without a clear reason.
-- Ship code without checking the relevant test suite.
-
-## Agent Format
-- Agents live in `agents/*.md`.
-- Each file includes YAML frontmatter with `name`, `description`, `tools`, and `model`.
-- File names are lowercase with hyphens and must match the agent name.
-- Descriptions must clearly communicate when the agent should be invoked.
-
-## Skill Format
-- Skills live in `skills//SKILL.md`.
-- Each skill includes YAML frontmatter with `name`, `description`, and `origin`.
-- Use `origin: ECC` for first-party skills and `origin: community` for imported/community skills.
-- Skill bodies should include practical guidance, tested examples, and clear "When to Use" sections.
-
-## Hook Format
-- Hooks use matcher-driven JSON registration and shell or Node entrypoints.
-- Matchers should be specific instead of broad catch-alls.
-- Exit `1` only when blocking behavior is intentional; otherwise exit `0`.
-- Error and info messages should be actionable.
-
-## Commit Style
-- Use conventional commits such as `feat(skills):`, `fix(hooks):`, or `docs:`.
-- Keep changes modular and explain user-facing impact in the PR summary.
diff --git a/SOUL.md b/SOUL.md
index 38e79ffa3..bef1d69e2 100644
--- a/SOUL.md
+++ b/SOUL.md
@@ -1,7 +1,7 @@
# Soul
## Core Identity
-Everything Claude Code (ECC) is a production-ready AI coding plugin with 30 specialized agents, 135 skills, 60 commands, and automated hook workflows for software development.
+Everything Claude Code (ECC) is a production-ready AI coding plugin: specialized agents, on-demand skills, slash commands, rules, and automated hook workflows for software development.
## Core Principles
1. **Agent-First** — route work to the right specialist as early as possible.
diff --git a/WORKING-CONTEXT.md b/WORKING-CONTEXT.md
deleted file mode 100644
index 62fa3450e..000000000
--- a/WORKING-CONTEXT.md
+++ /dev/null
@@ -1,179 +0,0 @@
-# Working Context
-
-Last updated: 2026-04-08
-
-## Purpose
-
-Public ECC plugin repo for agents, skills, commands, hooks, rules, install surfaces, and ECC 2.0 platform buildout.
-
-## Current Truth
-
-- Default branch: `main`
-- Public release surface is aligned at `v1.10.0`
-- Public catalog truth is `47` agents, `79` commands, and `181` skills
-- Public plugin slug is now `ecc`; legacy `everything-claude-code` install paths remain supported for compatibility
-- Release discussion: `#1272`
-- ECC 2.0 exists in-tree and builds, but it is still alpha rather than GA
-- Main active operational work:
- - keep default branch green
- - continue issue-driven fixes from `main` now that the public PR backlog is at zero
- - continue ECC 2.0 control-plane and operator-surface buildout
-
-## Current Constraints
-
-- No merge by title or commit summary alone.
-- No arbitrary external runtime installs in shipped ECC surfaces.
-- Overlapping skills, hooks, or agents should be consolidated when overlap is material and runtime separation is not required.
-
-## Active Queues
-
-- PR backlog: reduced but active; keep direct-porting only safe ECC-native changes and close overlap, stale generators, and unaudited external-runtime lanes
-- Upstream branch backlog still needs selective mining and cleanup:
- - `origin/feat/hermes-generated-ops-skills` still has three unique commits, but only reusable ECC-native skills should be salvaged from it
- - multiple `origin/ecc-tools/*` automation branches are stale and should be pruned after confirming they carry no unique value
-- Product:
- - selective install cleanup
- - control plane primitives
- - operator surface
- - self-improving skills
- - keep `agent.yaml` export parity with the shipped `commands/` and `skills/` directories so modern install surfaces do not silently lose command registration
-- Skill quality:
- - rewrite content-facing skills to use source-backed voice modeling
- - remove generic LLM rhetoric, canned CTA patterns, and forced platform stereotypes
- - continue one-by-one audit of overlapping or low-signal skill content
- - move repo guidance and contribution flow to skills-first, leaving commands only as explicit compatibility shims
- - add operator skills that wrap connected surfaces instead of exposing only raw APIs or disconnected primitives
- - land the canonical voice system, network-optimization lane, and reusable Manim explainer lane
-- Security:
- - keep dependency posture clean
- - preserve self-contained hook and MCP behavior
-
-## Open PR Classification
-
-- Closed on 2026-04-01 under backlog hygiene / merge policy:
- - `#1069` `feat: add everything-claude-code ECC bundle`
- - `#1068` `feat: add everything-claude-code-conventions ECC bundle`
- - `#1080` `feat: add everything-claude-code ECC bundle`
- - `#1079` `feat: add everything-claude-code-conventions ECC bundle`
- - `#1064` `chore(deps-dev): bump @eslint/js from 9.39.2 to 10.0.1`
- - `#1063` `chore(deps-dev): bump eslint from 9.39.2 to 10.1.0`
-- Closed on 2026-04-01 because the content is sourced from external ecosystems and should only land via manual ECC-native re-port:
- - `#852` openclaw-user-profiler
- - `#851` openclaw-soul-forge
- - `#640` harper skills
-- Native-support candidates to fully diff-audit next:
- - `#1055` Dart / Flutter support
- - `#1043` C# reviewer and .NET skills
-- Direct-port candidates landed after audit:
- - `#1078` hook-id dedupe for managed Claude hook reinstalls
- - `#844` ui-demo skill
- - `#1110` install-time Claude hook root resolution
- - `#1106` portable Codex Context7 key extraction
- - `#1107` Codex baseline merge and sample agent-role sync
- - `#1119` stale CI/lint cleanup that still contained safe low-risk fixes
-- Port or rebuild inside ECC after full audit:
- - `#894` Jira integration
- - `#814` + `#808` rebuild as a single consolidated notifications lane for Opencode and cross-harness surfaces
-
-## Interfaces
-
-- Public truth: GitHub issues and PRs
-- Internal execution truth: linked Linear work items under the ECC program
-- Current linked Linear items:
- - `ECC-206` ecosystem CI baseline
- - `ECC-207` PR backlog audit and merge-policy enforcement
- - `ECC-208` context hygiene
- - `ECC-210` skills-first workflow migration and command compatibility retirement
-
-## Update Rule
-
-Keep this file detailed for only the current sprint, blockers, and next actions. Summarize completed work into archive or repo docs once it is no longer actively shaping execution.
-
-## Latest Execution Notes
-
-- 2026-04-05: Continued `#1213` overlap cleanup by narrowing `coding-standards` into the baseline cross-project conventions layer instead of deleting it. The skill now explicitly points detailed React/UI guidance to `frontend-patterns`, backend/API structure to `backend-patterns` / `api-design`, and keeps only reusable naming, readability, immutability, and code-quality expectations.
-- 2026-04-05: Added a packaging regression guard for the OpenCode release path after `#1287` showed the published `v1.10.0` artifact was still stale. `tests/scripts/build-opencode.test.js` now asserts the `npm pack --dry-run` tarball includes `.opencode/dist/index.js` plus compiled plugin/tool entrypoints, so future releases cannot silently omit the built OpenCode payload.
-- 2026-04-05: Landed `skills/agent-introspection-debugging` for `#829` as an ECC-native self-debugging framework. It is intentionally guidance-first rather than fake runtime automation: capture failure state, classify the pattern, apply the smallest contained recovery action, then emit a structured introspection report and hand off to `verification-loop` / `continuous-learning-v2` when appropriate.
-- 2026-04-05: Fixed the `main` npm CI break after the latest direct ports. `package-lock.json` had drifted behind `package.json` on the `globals` devDependency (`^17.1.0` vs `^17.4.0`), which caused all npm-based GitHub Actions jobs to fail at `npm ci`. Refreshed the lockfile only, verified `npm ci --ignore-scripts`, and kept the mixed-lock workspace otherwise untouched.
-- 2026-04-05: Direct-ported the useful discoverability part of `#1221` without duplicating a second healthcare compliance system. Added `skills/hipaa-compliance/SKILL.md` as a thin HIPAA-specific entrypoint that points into the canonical `healthcare-phi-compliance` / `healthcare-reviewer` lane, and wired both healthcare privacy skills into the `security` install module for selective installs.
-- 2026-04-05: Direct-ported the audited blockchain/web3 security lane from `#1222` into `main` as four self-contained skills: `defi-amm-security`, `evm-token-decimals`, `llm-trading-agent-security`, and `nodejs-keccak256`. These are now part of the `security` install module instead of living as an unmerged fork PR.
-- 2026-04-05: Finished the useful salvage pass from `#1203` directly on `main`. `skills/security-bounty-hunter`, `skills/api-connector-builder`, and `skills/dashboard-builder` are now in-tree as ECC-native rewrites instead of the thinner original community drafts. The original PR should be treated as superseded rather than merged.
-- 2026-04-02: `ECC-Tools/main` shipped `9566637` (`fix: prefer commit lookup over git ref resolution`). The PR-analysis fire is now fixed in the app repo by preferring explicit commit resolution before `git.getRef`, with regression coverage for pull refs and plain branch refs. Mirrored public tracking issue `#1184` in this repo was closed as resolved upstream.
-- 2026-04-02: Direct-ported the clean native-support core of `#1043` into `main`: `agents/csharp-reviewer.md`, `skills/dotnet-patterns/SKILL.md`, and `skills/csharp-testing/SKILL.md`. This fills the gap between existing C# rule/docs mentions and actual shipped C# review/testing guidance.
-- 2026-04-02: Direct-ported the clean native-support core of `#1055` into `main`: `agents/dart-build-resolver.md`, `commands/flutter-build.md`, `commands/flutter-review.md`, `commands/flutter-test.md`, `rules/dart/*`, and `skills/dart-flutter-patterns/SKILL.md`. The skill paths were wired into the current `framework-language` module instead of replaying the older PR's separate `flutter-dart` module layout.
-- 2026-04-02: Closed `#1081` after diff audit. The PR only added vendor-marketing docs for an external X/Twitter backend (`Xquik` / `x-twitter-scraper`) to the canonical `x-api` skill instead of contributing an ECC-native capability.
-- 2026-04-02: Direct-ported the useful Jira lane from `#894`, but sanitized it to match current supply-chain policy. `commands/jira.md`, `skills/jira-integration/SKILL.md`, and the pinned `jira` MCP template in `mcp-configs/mcp-servers.json` are in-tree, while the skill no longer tells users to install `uv` via `curl | bash`. `jira-integration` is classified under `operator-workflows` for selective installs.
-- 2026-04-02: Closed `#1125` after full diff audit. The bundle/skill-router lane hardcoded many non-existent or non-canonical surfaces and created a second routing abstraction instead of a small ECC-native index layer.
-- 2026-04-02: Closed `#1124` after full diff audit. The added agent roster was thoughtfully written, but it duplicated the existing ECC agent surface with a second competing catalog (`dispatch`, `explore`, `verifier`, `executor`, etc.) instead of strengthening canonical agents already in-tree.
-- 2026-04-02: Closed the full Argus cluster `#1098`, `#1099`, `#1100`, `#1101`, and `#1102` after full diff audit. The common failure mode was the same across all five PRs: external multi-CLI dispatch was treated as a first-class runtime dependency of shipped ECC surfaces. Any useful protocol ideas should be re-ported later into ECC-native orchestration, review, or reflection lanes without external CLI fan-out assumptions.
-- 2026-04-02: The previously open native-support / integration queue (`#1081`, `#1055`, `#1043`, `#894`) has now been fully resolved by direct-port or closure policy. The active public PR queue is currently zero; next focus stays on issue-driven mainline fixes and CI health, not backlog PR intake.
-- 2026-04-01: `main` CI was restored locally with `1723/1723` tests passing after lockfile and hook validation fixes.
-- 2026-04-01: Auto-generated ECC bundle PRs `#1068` and `#1069` were closed instead of merged; useful ideas must be ported manually after explicit diff audit.
-- 2026-04-01: Major-version ESLint bump PRs `#1063` and `#1064` were closed; revisit only inside a planned ESLint 10 migration lane.
-- 2026-04-01: Notification PRs `#808` and `#814` were identified as overlapping and should be rebuilt as one unified feature instead of landing as parallel branches.
-- 2026-04-01: External-source skill PRs `#640`, `#851`, and `#852` were closed under the new ingestion policy; copy ideas from audited source later rather than merging branded/source-import PRs directly.
-- 2026-04-01: The remaining low GitHub advisory on `ecc2/Cargo.lock` was addressed by moving `ratatui` to `0.30` with `crossterm_0_28`, which updated transitive `lru` from `0.12.5` to `0.16.3`. `cargo build --manifest-path ecc2/Cargo.toml` still passes.
-- 2026-04-01: Safe core of `#834` was ported directly into `main` instead of merging the PR wholesale. This included stricter install-plan validation, antigravity target filtering that skips unsupported module trees, tracked catalog sync for English plus zh-CN docs, and a dedicated `catalog:sync` write mode.
-- 2026-04-01: Repo catalog truth is now synced at `36` agents, `68` commands, and `142` skills across the tracked English and zh-CN docs.
-- 2026-04-01: Legacy emoji and non-essential symbol usage in docs, scripts, and tests was normalized to keep the unicode-safety lane green without weakening the check itself.
-- 2026-04-01: The remaining self-contained piece of `#834`, `docs/zh-CN/skills/browser-qa/SKILL.md`, was ported directly into the repo. After commit, `#834` should be closed as superseded-by-direct-port.
-- 2026-04-01: Content skill cleanup started with `content-engine`, `crosspost`, `article-writing`, and `investor-outreach`. The new direction is source-first voice capture, explicit anti-trope bans, and no forced platform persona shifts.
-- 2026-04-01: `node scripts/ci/check-unicode-safety.js --write` sanitized the remaining emoji-bearing Markdown files, including several `remotion-video-creation` rule docs and an old local plan note.
-- 2026-04-01: Core English repo surfaces were shifted to a skills-first posture. README, AGENTS, plugin metadata, and contributor instructions now treat `skills/` as canonical and `commands/` as legacy slash-entry compatibility during migration.
-- 2026-04-01: Follow-up bundle cleanup closed `#1080` and `#1079`, which were generated `.claude/` bundle PRs duplicating command-first scaffolding instead of shipping canonical ECC source changes.
-- 2026-04-01: Ported the useful core of `#1078` directly into `main`, but tightened the implementation so legacy no-id hook installs deduplicate cleanly on the first reinstall instead of the second. Added stable hook ids to `hooks/hooks.json`, semantic fallback aliases in `mergeHookEntries()`, and a regression test covering upgrade from pre-id settings.
-- 2026-04-01: Collapsed the obvious command/skill duplicates into thin legacy shims so `skills/` now hold the maintained bodies for NanoClaw, context-budget, DevFleet, docs lookup, E2E, evals, orchestration, prompt optimization, rules distillation, TDD, and verification.
-- 2026-04-01: Ported the self-contained core of `#844` directly into `main` as `skills/ui-demo/SKILL.md` and registered it under the `media-generation` install module instead of merging the PR wholesale.
-- 2026-04-01: Added the first connected-workflow operator lane as ECC-native skills instead of leaving the surface as raw plugins or APIs: `workspace-surface-audit`, `customer-billing-ops`, `project-flow-ops`, and `google-workspace-ops`. These are tracked under the new `operator-workflows` install module.
-- 2026-04-01: Direct-ported the real fix from the unresolved hook-path PR lane into the active installer. Claude installs now replace `${CLAUDE_PLUGIN_ROOT}` with the concrete install root in both `settings.json` and the copied `hooks/hooks.json`, which keeps PreToolUse/PostToolUse hooks working outside plugin-managed env injection.
-- 2026-04-01: Replaced the GNU-only `grep -P` parser in `scripts/sync-ecc-to-codex.sh` with a portable Node parser for Context7 key extraction. Added source-level regression coverage so BSD/macOS syncs do not drift back to non-portable parsing.
-- 2026-04-01: Targeted regression suite after the direct ports is green: `tests/scripts/install-apply.test.js`, `tests/scripts/sync-ecc-to-codex.test.js`, and `tests/scripts/codex-hooks.test.js`.
-- 2026-04-01: Ported the useful core of `#1107` directly into `main` as an add-only Codex baseline merge. `scripts/sync-ecc-to-codex.sh` now fills missing non-MCP defaults from `.codex/config.toml`, syncs sample agent role files into `~/.codex/agents`, and preserves user config instead of replacing it. Added regression coverage for sparse configs and implicit parent tables.
-- 2026-04-01: Ported the safe low-risk cleanup from `#1119` directly into `main` instead of keeping an obsolete CI PR open. This included `.mjs` eslint handling, stricter null checks, Windows home-dir coverage in bash-log tests, and longer Trae shell-test timeouts.
-- 2026-04-01: Added `brand-voice` as the canonical source-derived writing-style system and wired the content lane to treat it as the shared voice source of truth instead of duplicating partial style heuristics across skills.
-- 2026-04-01: Added `connections-optimizer` as the review-first social-graph reorganization workflow for X and LinkedIn, with explicit pruning modes, browser fallback expectations, and Apple Mail drafting guidance.
-- 2026-04-01: Added `manim-video` as the reusable technical explainer lane and seeded it with a starter network-graph scene so launch and systems animations do not depend on one-off scratch scripts.
-- 2026-04-02: Re-extracted `social-graph-ranker` as a standalone primitive because the weighted bridge-decay model is reusable outside the full lead workflow. `lead-intelligence` now points to it for canonical graph ranking instead of carrying the full algorithm explanation inline, while `connections-optimizer` stays the broader operator layer for pruning, adds, and outbound review packs.
-- 2026-04-02: Applied the same consolidation rule to the writing lane. `brand-voice` remains the canonical voice system, while `content-engine`, `crosspost`, `article-writing`, and `investor-outreach` now keep only workflow-specific guidance instead of duplicating a second Affaan/ECC voice model or repeating the full ban list in multiple places.
-- 2026-04-02: Closed fresh auto-generated bundle PRs `#1182` and `#1183` under the existing policy. Useful ideas from generator output must be ported manually into canonical repo surfaces instead of merging `.claude`/bundle PRs wholesale.
-- 2026-04-02: Ported the safe one-file macOS observer fix from `#1164` directly into `main` as a POSIX `mkdir` fallback for `continuous-learning-v2` lazy-start locking, then closed the PR as superseded by direct port.
-- 2026-04-02: Ported the safe core of `#1153` directly into `main`: markdownlint cleanup for orchestration/docs surfaces plus the Windows `USERPROFILE` and path-normalization fixes in `install-apply` / `repair` tests. Local validation after installing repo deps: `node tests/scripts/install-apply.test.js`, `node tests/scripts/repair.test.js`, and targeted `yarn markdownlint` all passed.
-- 2026-04-02: Direct-ported the safe web/frontend rules lane from `#1122` into `rules/web/`, but adapted `rules/web/hooks.md` to prefer project-local tooling and avoid remote one-off package execution examples.
-- 2026-04-02: Adapted the design-quality reminder from `#1127` into the current ECC hook architecture with a local `scripts/hooks/design-quality-check.js`, Claude `hooks/hooks.json` wiring, Cursor `after-file-edit.js` wiring, and dedicated hook coverage in `tests/hooks/design-quality-check.test.js`.
-- 2026-04-02: Fixed `#1141` on `main` in `16e9b17`. The observer lifecycle is now session-aware instead of purely detached: `SessionStart` writes a project-scoped lease, `SessionEnd` removes that lease and stops the observer when the final lease disappears, `observe.sh` records project activity, and `observer-loop.sh` now exits on idle when no leases remain. Targeted validation passed with `bash -n`, `node tests/hooks/observer-memory.test.js`, `node tests/integration/hooks.test.js`, `node scripts/ci/validate-hooks.js hooks/hooks.json`, and `node scripts/ci/check-unicode-safety.js`.
-- 2026-04-02: Fixed the remaining Windows-only hook regression behind `#1070` by making `scripts/lib/utils.js#getHomeDir()` honor explicit `HOME` / `USERPROFILE` overrides before falling back to `os.homedir()`. This restores test-isolated observer state paths for hook integration runs on Windows. Added regression coverage in `tests/lib/utils.test.js`. Targeted validation passed with `node tests/lib/utils.test.js`, `node tests/integration/hooks.test.js`, `node tests/hooks/observer-memory.test.js`, and `node scripts/ci/check-unicode-safety.js`.
-- 2026-04-02: Direct-ported NestJS support for `#1022` into `main` as `skills/nestjs-patterns/SKILL.md` and wired it into the `framework-language` install module. Synced the repo catalog afterward (`38` agents, `72` commands, `156` skills) and updated the docs so NestJS is no longer listed as an unfilled framework gap.
-- 2026-04-05: Shipped `846ffb7` (`chore: ship v1.10.0 release surface refresh`). This updated README/plugin metadata/package versions, synced the explicit plugin agent inventory, bumped stale star/fork/contributor counts, created `docs/releases/1.10.0/*`, tagged and released `v1.10.0`, and posted the announcement discussion at `#1272`.
-- 2026-04-05: Salvaged the reusable Hermes-branch operator skills in `6eba30f` without replaying the full branch. Added `skills/github-ops`, `skills/knowledge-ops`, and `skills/hookify-rules`, wired them into install modules, and re-synced the repo to `159` skills. `knowledge-ops` was explicitly adapted to the current workspace model: live code in cloned repos, active truth in GitHub/Linear, broader non-code context in the KB/archive layers.
-- 2026-04-05: Fixed the remaining OpenCode npm-publish gap in `db6d52e`. The root package now builds `.opencode/dist` during `prepack`, includes the compiled OpenCode plugin assets in the published tarball, and carries a dedicated regression test (`tests/scripts/build-opencode.test.js`) so the package no longer ships only raw TypeScript source for that surface.
-- 2026-04-05: Added `skills/council`, direct-ported the safe `code-tour` lane from `#1193`, and re-synced the repo to `162` skills. `code-tour` stays self-contained and only produces `.tours/*.tour` artifacts with real file/line anchors; no external runtime or extension install is assumed inside the skill.
-- 2026-04-05: Closed the latest auto-generated ECC bundle PR wave (`#1275`-`#1281`) after deploying `ECC-Tools/main` fix `f615905`, which now blocks repo-level issue-comment `/analyze` requests from opening repeated bundle PRs while still allowing PR-thread retry analysis to run against immutable head SHAs.
-- 2026-04-05: Filled the SEO gap by direct-porting `agents/seo-specialist.md` and `skills/seo/SKILL.md` into `main`, then wiring `skills/seo` into `business-content`. This resolves the stale `team-builder` reference to an SEO specialist and brings the public catalog to `39` agents and `163` skills without merging the stale PR wholesale.
-- 2026-04-05: Salvaged the useful common-rule deltas from `#1214` directly into `rules/common/coding-style.md` and `rules/common/testing.md` (KISS/DRY/YAGNI reminders, naming conventions, code-smell guidance, and AAA-style test guidance), then closed the original mixed deletion PR. The broad skill removals in that PR were intentionally not replayed.
-- 2026-04-05: Fixed the stale-row bug in `.github/workflows/monthly-metrics.yml` with `bf5961e`. The workflow now refreshes the current month row in issue `#1087` instead of early-returning when the month already exists, and the dispatched run updated the April snapshot to the current star/fork/release counts.
-- 2026-04-05: Recovered the useful cost-control workflow from the divergent Hermes branch as a small ECC-native operator skill instead of replaying the branch. `skills/ecc-tools-cost-audit/SKILL.md` is now wired into `operator-workflows` and focused on webhook -> queue -> worker tracing, burn containment, quota bypass, premium-model leakage, and retry fanout in the sibling `ECC-Tools` repo.
-- 2026-04-05: Added `skills/council/SKILL.md` in `753da37` as an ECC-native four-voice decision workflow. The useful protocol from PR `#1254` was retained, but the shadow `~/.claude/notes` write path was explicitly removed in favor of `knowledge-ops`, `/save-session`, or direct GitHub/Linear updates when a decision delta matters.
-- 2026-04-05: Direct-ported the safe `globals` bump from PR `#1243` into `main` as part of the council lane and closed the PR as superseded.
-- 2026-04-05: Closed PR `#1232` after full audit. The proposed `skill-scout` workflow overlaps current `search-first`, `/skill-create`, and `skill-stocktake`; if a dedicated marketplace-discovery layer returns later it should be rebuilt on top of the current install/catalog model rather than landing as a parallel discovery path.
-- 2026-04-05: Ported the safe localized README switcher fixes from PR `#1209` directly into `main` rather than merging the docs PR wholesale. The navigation now consistently includes `Português (Brasil)` and `Türkçe` across the localized README switchers, while newer localized body copy stays intact.
-- 2026-04-05: Removed the stale InsAIts shipped surface from `main`. ECC no longer ships the external Python MCP entry, opt-in hook wiring, wrapper/monitor scripts, or current docs mentions for `insa-its`; changelog history remains, but the live product surface is now fully ECC-native again.
-- 2026-04-05: Salvaged the reusable Hermes-generated operator workflow lane without replaying the whole branch. Added six ECC-native top-level skills instead of the old nested `skills/hermes-generated/*` tree: `automation-audit-ops`, `email-ops`, `finance-billing-ops`, `messages-ops`, `research-ops`, and `terminal-ops`. `research-ops` now wraps the existing research stack, while the other five extend `operator-workflows` without introducing any external runtime assumptions.
-- 2026-04-05: Added `skills/product-capability` plus `docs/examples/product-capability-template.md` as the canonical PRD-to-SRS lane for issue `#1185`. This is the ECC-native capability-contract step between vague product intent and implementation, and it lives in `business-content` rather than spawning a parallel planning subsystem.
-- 2026-04-05: Tightened `product-lens` so it no longer overlaps the new capability-contract lane. `product-lens` now explicitly owns product diagnosis / brief validation, while `product-capability` owns implementation-ready capability plans and SRS-style constraints.
-- 2026-04-05: Continued `#1213` cleanup by removing stale references to the deleted `project-guidelines-example` skill from exported inventory/docs and marking `continuous-learning` v1 as a supported legacy path with an explicit handoff to `continuous-learning-v2`.
-- 2026-04-05: Removed the last orphaned localized `project-guidelines-example` docs from `docs/ko-KR` and `docs/zh-CN`. The template now lives only in `docs/examples/project-guidelines-template.md`, which matches the current repo surface and avoids shipping translated docs for a deleted skill.
-- 2026-04-05: Added `docs/HERMES-OPENCLAW-MIGRATION.md` as the current public migration guide for issue `#1051`. It reframes Hermes/OpenClaw as source systems to distill from, not the final runtime, and maps scheduler, dispatch, memory, skill, and service layers onto the ECC-native surfaces and ECC 2.0 backlog that already exist.
-- 2026-04-05: Landed `skills/agent-sort` and the legacy `/agent-sort` shim from issue `#916` as an ECC-native selective-install workflow. It classifies agents, skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using concrete repo evidence, then hands off installation changes to `configure-ecc` instead of inventing a parallel installer. Catalog truth is now `39` agents, `73` commands, and `179` skills.
-- 2026-04-05: Direct-ported the safe README-only `#1285` slice into `main` instead of merging the branch: added a small `Community Projects` section so downstream teams can link public work built on ECC without changing install, security, or runtime surfaces. Rejected `#1286` at review because it adds an external third-party GitHub Action (`hashgraph-online/codex-plugin-scanner`) that does not meet the current supply-chain policy.
-- 2026-04-05: Re-audited `origin/feat/hermes-generated-ops-skills` by full diff. The branch is still not mergeable: it deletes current ECC-native surfaces, regresses packaging/install metadata, and removes newer `main` content. Continued the selective-salvage policy instead of branch merge.
-- 2026-04-05: Selectively salvaged `skills/frontend-design` from the Hermes branch as a self-contained ECC-native skill, mirrored it into `.agents`, wired it into `framework-language`, and re-synced the catalog to `180` skills after validation. The branch itself remains reference-only until every remaining unique file is either ported intentionally or rejected.
-- 2026-04-05: Selectively salvaged the `hookify` command bundle plus the supporting `conversation-analyzer` agent from the Hermes branch. `hookify-rules` already existed as the canonical skill; this pass restores the user-facing command surfaces (`/hookify`, `/hookify-help`, `/hookify-list`, `/hookify-configure`) without pulling in any external runtime or branch-wide regressions. Catalog truth is now `40` agents, `77` commands, and `180` skills.
-- 2026-04-05: Selectively salvaged the self-contained review/development bundle from the Hermes branch: `review-pr`, `feature-dev`, and the supporting analyzer/architecture agents (`code-architect`, `code-explorer`, `code-simplifier`, `comment-analyzer`, `pr-test-analyzer`, `silent-failure-hunter`, `type-design-analyzer`). This adds ECC-native command surfaces around PR review and feature planning without merging the branch's broader regressions. Catalog truth is now `47` agents, `79` commands, and `180` skills.
-- 2026-04-05: Ported `docs/HERMES-SETUP.md` from the Hermes branch as a sanitized operator-topology document for the migration lane. This is docs-only support for `#1051`, not a runtime change and not a sign that the Hermes branch itself is mergeable.
-- 2026-04-05: Finished the useful salvage pass over `origin/feat/hermes-generated-ops-skills`. The remaining unique files were explicitly rejected:
- - duplicate git helper commands (`commit`, `commit-push-pr`, `clean-gone`) overlap current checkpoint / publish flows
- - `scripts/hooks/security-reminder*` adds a new Python-backed hook path not justified by current runtime policy
- - `skills/oura-health` and `skills/pmx-guidelines` are user- or project-specific, not canonical ECC surfaces
- - `docs/releases/2.0.0-preview/*` is premature collateral and should be rebuilt from current product truth later
- - nested `skills/hermes-generated/*` is superseded by the top-level ECC-native operator skills already ported to `main`
-- 2026-04-08: Fixed the command-export regression reported in `#1327` by restoring a canonical `commands:` section in `agent.yaml` and adding `tests/ci/agent-yaml-surface.test.js` to enforce exact parity between the YAML export surface and the real `commands/` directory. Verified with the full repo test sweep: `1764/1764` passing.
diff --git a/agent.yaml b/agent.yaml
index ff7abe065..e3c44177f 100644
--- a/agent.yaml
+++ b/agent.yaml
@@ -100,7 +100,9 @@ skills:
- logistics-exception-management
- market-research
- mcp-server-patterns
- - motion-ui
+ - motion-advanced
+ - motion-foundations
+ - motion-patterns
- nanoclaw-repl
- nextjs-turbopack
- nutrient-document-processing
diff --git a/commands/plan-prd.md b/commands/plan-prd.md
index 205082859..192295785 100644
--- a/commands/plan-prd.md
+++ b/commands/plan-prd.md
@@ -158,3 +158,5 @@ Next step: /plan .claude/prds/{name}.prd.md
- **HYPOTHESIS_TESTABLE**: measurable outcome included.
- **SCOPE_BOUNDED**: explicit MVP and explicit out-of-scope.
- **NO_IMPLEMENTATION_DETAIL**: file paths, libraries, or task breakdowns are absent — if they appeared, move them to the `/plan` step.
+
+Background on the staged markdown flow: [docs/PLAN-PRD-PATTERN.md](../docs/PLAN-PRD-PATTERN.md).
diff --git a/docs/ARCHITECTURE-IMPROVEMENTS.md b/docs/ARCHITECTURE-IMPROVEMENTS.md
deleted file mode 100644
index 5a2803e56..000000000
--- a/docs/ARCHITECTURE-IMPROVEMENTS.md
+++ /dev/null
@@ -1,146 +0,0 @@
-# Architecture Improvement Recommendations
-
-This document captures architect-level improvements for the Everything Claude Code (ECC) project. It is written from the perspective of a Claude Code coding architect aiming to improve maintainability, consistency, and long-term quality.
-
----
-
-## 1. Documentation and Single Source of Truth
-
-### 1.1 Agent / Command / Skill Count Sync
-
-**Issue:** AGENTS.md states "13 specialized agents, 50+ skills, 33 commands" while the repo has **16 agents**, **65+ skills**, and **40 commands**. README and other docs also vary. This causes confusion for contributors and users.
-
-**Recommendation:**
-
-- **Single source of truth:** Derive counts (and optionally tables) from the filesystem or a small manifest. Options:
- - **Option A:** Add a script (e.g. `scripts/ci/catalog.js`) that scans `agents/*.md`, `commands/*.md`, and `skills/*/SKILL.md` and outputs JSON/Markdown. CI and docs can consume this.
- - **Option B:** Maintain one `docs/catalog.json` (or YAML) that lists agents, commands, and skills with metadata; scripts and docs read from it. Requires discipline to update on add/remove.
-- **Short-term:** Manually sync AGENTS.md, README.md, and CLAUDE.md with actual counts and list any new agents (e.g. chief-of-staff, loop-operator, harness-optimizer) in the agent table.
-
-**Impact:** High — affects first impression and contributor trust.
-
----
-
-### 1.2 Command → Agent / Skill Map
-
-**Issue:** There is no single machine- or human-readable map of "which command uses which agent(s) or skill(s)." This lives in README tables and individual command `.md` files, which can drift.
-
-**Recommendation:**
-
-- Add a **command registry** (e.g. in `docs/` or as frontmatter in command files) that lists for each command: name, description, primary agent(s), skills referenced. Can be generated from command file content or maintained by hand.
-- Expose a "map" in docs (e.g. `docs/COMMAND-AGENT-MAP.md`) or in the generated catalog for discoverability and for tooling (e.g. "which commands use tdd-guide?").
-
-**Impact:** Medium — improves discoverability and refactoring safety.
-
----
-
-## 2. Testing and Quality
-
-### 2.1 Test Discovery vs Hardcoded List
-
-**Issue:** `tests/run-all.js` uses a **hardcoded list** of test files. New test files are not run unless someone updates `run-all.js`, so coverage can be incomplete by omission.
-
-**Recommendation:**
-
-- **Glob-based discovery:** Discover test files by pattern (e.g. `**/*.test.js` under `tests/`) and run them, with an optional allowlist/denylist for special cases. This makes new tests automatically part of the suite.
-- Keep a single entry point (`tests/run-all.js`) that runs discovered tests and aggregates results.
-
-**Impact:** High — prevents regression where new tests exist but are never executed.
-
----
-
-### 2.2 Test Coverage Metrics
-
-**Issue:** There is no coverage tool (e.g. nyc/c8/istanbul). The project cannot assert "80%+ coverage" for its own scripts; coverage is implicit.
-
-**Recommendation:**
-
-- Introduce a coverage tool for Node scripts (e.g. `c8` or `nyc`) and run it in CI. Start with a baseline (e.g. 60%) and raise over time; or at least report coverage in CI without failing so the team can see trends.
-- Focus on `scripts/` (lib + hooks + ci) as the primary target; exclude one-off scripts if needed.
-
-**Impact:** Medium — aligns the project with its own AGENTS.md guidance (80%+ coverage) and surfaces untested paths.
-
----
-
-## 3. Schema and Validation
-
-### 3.1 Use Hooks JSON Schema in CI
-
-**Issue:** `schemas/hooks.schema.json` exists and defines the hook configuration shape, but `scripts/ci/validate-hooks.js` does **not** use it. Validation is duplicated (VALID_EVENTS, structure) and can drift from the schema.
-
-**Recommendation:**
-
-- Use a JSON Schema validator (e.g. `ajv`) in `validate-hooks.js` to validate `hooks/hooks.json` against `schemas/hooks.schema.json`. Keep the validator as the single source of truth for structure; retain only hook-specific checks (e.g. inline JS syntax) in the script.
-- Ensures schema and validator stay in sync and allows IDE/editor validation via `$schema` in hooks.json.
-
-**Impact:** Medium — reduces drift and improves contributor experience when editing hooks.
-
----
-
-## 4. Cross-Harness and i18n
-
-### 4.1 Skill/Agent Subset Sync (.agents/skills, .cursor/skills)
-
-**Issue:** `.agents/skills/` (Codex) and `.cursor/skills/` are subsets of `skills/`. Adding or removing a skill in the main repo requires manually updating these subsets, which can be forgotten.
-
-**Recommendation:**
-
-- Document in CONTRIBUTING.md that adding a skill may require updating `.agents/skills` and `.cursor/skills` (and how to do it).
-- Optionally: a CI check or script that compares `skills/` to the subsets and fails or warns if a skill is in one set but not the other when it should be (e.g. by convention or by a small manifest).
-
-**Impact:** Low–Medium — reduces cross-harness drift.
-
----
-
-### 4.2 Translation Drift (docs/ zh-CN, zh-TW, ja-JP)
-
-**Issue:** Translations in `docs/` duplicate agents, commands, skills. As the English source evolves, translations can become outdated without clear process or tooling.
-
-**Recommendation:**
-
-- Document a **translation process:** when to update (e.g. on release), who owns each locale, and how to detect stale content (e.g. diff file lists or key sections).
-- Consider: translation status file (e.g. `docs/i18n-status.md`) or CI that checks translation file existence/timestamps and warns if English was updated more recently than a translation.
-- Long-term: consider extraction/placeholder format (e.g. i18n keys) so translations reference the same structure as the English source.
-
-**Impact:** Medium — improves experience for non-English users and reduces confusion from outdated translations.
-
----
-
-## 5. Hooks and Scripts
-
-### 5.1 Hook Runtime Consistency
-
-**Issue:** Hooks should keep a consistent Node-mode dispatch surface. Continuous-learning observation now dispatches through `run-with-flags.js` and `observe-runner.js`, which delegates to the existing `observe.sh` implementation without exposing a shell-mode hook entry.
-
-**Recommendation:**
-
-- Prefer Node for new hooks when possible (cross-platform, single runtime). If shell is required, document why and keep the surface small.
-- Ensure `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` are respected in all code paths (including shell) so behavior is consistent.
-
-**Impact:** Low — maintains current design; improves if more hooks migrate to Node.
-
----
-
-## 6. Summary Table
-
-| Area | Improvement | Priority | Effort |
-|-------------------|--------------------------------------|----------|---------|
-| Doc sync | Sync AGENTS.md/README counts & table | High | Low |
-| Single source | Catalog script or manifest | High | Medium |
-| Test discovery | Glob-based test runner | High | Low |
-| Coverage | Add c8/nyc and CI coverage | Medium | Medium |
-| Hook schema in CI | Validate hooks.json via schema | Medium | Low |
-| Command map | Command → agent/skill registry | Medium | Medium |
-| Subset sync | Document/CI for .agents/.cursor | Low–Med | Low–Med |
-| Translations | Process + stale detection | Medium | Medium |
-| Hook runtime | Prefer Node; document shell use | Low | Low |
-
----
-
-## 7. Quick Wins (Immediate)
-
-1. **Update AGENTS.md:** Set agent count to 16; add chief-of-staff, loop-operator, harness-optimizer to the agent table; align skill/command counts with repo.
-2. **Test discovery:** Change `run-all.js` to discover `**/*.test.js` under `tests/` (with optional allowlist) so new tests are always run.
-3. **Wire hooks schema:** In `validate-hooks.js`, validate `hooks/hooks.json` against `schemas/hooks.schema.json` using ajv (or similar) and keep only hook-specific checks in the script.
-
-These three can be done in one or two sessions and materially improve consistency and reliability.
diff --git a/docs/ECC-2.0-SESSION-ADAPTER-DISCOVERY.md b/docs/ECC-2.0-SESSION-ADAPTER-DISCOVERY.md
deleted file mode 100644
index 68124fd13..000000000
--- a/docs/ECC-2.0-SESSION-ADAPTER-DISCOVERY.md
+++ /dev/null
@@ -1,322 +0,0 @@
-# ECC 2.0 Session Adapter Discovery
-
-## Purpose
-
-This document turns the March 11 ECC 2.0 control-plane direction into a
-concrete adapter and snapshot design grounded in the orchestration code that
-already exists in this repo.
-
-## Current Implemented Substrate
-
-The repo already has a real first-pass orchestration substrate:
-
-- `scripts/lib/tmux-worktree-orchestrator.js`
- provisions tmux panes plus isolated git worktrees
-- `scripts/orchestrate-worktrees.js`
- is the current session launcher
-- `scripts/lib/orchestration-session.js`
- collects machine-readable session snapshots
-- `scripts/orchestration-status.js`
- exports those snapshots from a session name or plan file
-- `commands/sessions.md`
- already exposes adjacent session-history concepts from Claude's local store
-- `scripts/lib/session-adapters/canonical-session.js`
- defines the canonical `ecc.session.v1` normalization layer
-- `scripts/lib/session-adapters/dmux-tmux.js`
- wraps the current orchestration snapshot collector as adapter `dmux-tmux`
-- `scripts/lib/session-adapters/claude-history.js`
- normalizes Claude local session history as a second adapter
-- `scripts/lib/session-adapters/registry.js`
- selects adapters from explicit targets and target types
-- `scripts/session-inspect.js`
- emits canonical read-only session snapshots through the adapter registry
-
-In practice, ECC can already answer:
-
-- what workers exist in a tmux-orchestrated session
-- what pane each worker is attached to
-- what task, status, and handoff files exist for each worker
-- whether the session is active and how many panes/workers exist
-- what the most recent Claude local session looked like in the same canonical
- snapshot shape as orchestration sessions
-
-That is enough to prove the substrate. It is not yet enough to qualify as a
-general ECC 2.0 control plane.
-
-## What The Current Snapshot Actually Models
-
-The current snapshot model coming out of `scripts/lib/orchestration-session.js`
-has these effective fields:
-
-```json
-{
- "sessionName": "workflow-visual-proof",
- "coordinationDir": ".../.claude/orchestration/workflow-visual-proof",
- "repoRoot": "...",
- "targetType": "plan",
- "sessionActive": true,
- "paneCount": 2,
- "workerCount": 2,
- "workerStates": {
- "running": 1,
- "completed": 1
- },
- "panes": [
- {
- "paneId": "%95",
- "windowIndex": 1,
- "paneIndex": 0,
- "title": "seed-check",
- "currentCommand": "codex",
- "currentPath": "/tmp/worktree",
- "active": false,
- "dead": false,
- "pid": 1234
- }
- ],
- "workers": [
- {
- "workerSlug": "seed-check",
- "workerDir": ".../seed-check",
- "status": {
- "state": "running",
- "updated": "...",
- "branch": "...",
- "worktree": "...",
- "taskFile": "...",
- "handoffFile": "..."
- },
- "task": {
- "objective": "...",
- "seedPaths": ["scripts/orchestrate-worktrees.js"]
- },
- "handoff": {
- "summary": [],
- "validation": [],
- "remainingRisks": []
- },
- "files": {
- "status": ".../status.md",
- "task": ".../task.md",
- "handoff": ".../handoff.md"
- },
- "pane": {
- "paneId": "%95",
- "title": "seed-check"
- }
- }
- ]
-}
-```
-
-This is already a useful operator payload. The main limitation is that it is
-implicitly tied to one execution style:
-
-- tmux pane identity
-- worker slug equals pane title
-- markdown coordination files
-- plan-file or session-name lookup rules
-
-## Gap Between ECC 1.x And ECC 2.0
-
-ECC 1.x currently has two different "session" surfaces:
-
-1. Claude local session history
-2. Orchestration runtime/session snapshots
-
-Those surfaces are adjacent but not unified.
-
-The missing ECC 2.0 layer is a harness-neutral session adapter boundary that
-can normalize:
-
-- tmux-orchestrated workers
-- plain Claude sessions
-- Codex worktree sessions
-- OpenCode sessions
-- future GitHub/App or remote-control sessions
-
-Without that adapter layer, any future operator UI would be forced to read
-tmux-specific details and coordination markdown directly.
-
-## Adapter Boundary
-
-ECC 2.0 should introduce a canonical session adapter contract.
-
-Suggested minimal interface:
-
-```ts
-type SessionAdapter = {
- id: string;
- canOpen(target: SessionTarget): boolean;
- open(target: SessionTarget): Promise;
-};
-
-type AdapterHandle = {
- getSnapshot(): Promise;
- streamEvents?(onEvent: (event: SessionEvent) => void): Promise<() => void>;
- runAction?(action: SessionAction): Promise;
-};
-```
-
-### Canonical Snapshot Shape
-
-Suggested first-pass canonical payload:
-
-```json
-{
- "schemaVersion": "ecc.session.v1",
- "adapterId": "dmux-tmux",
- "session": {
- "id": "workflow-visual-proof",
- "kind": "orchestrated",
- "state": "active",
- "repoRoot": "...",
- "sourceTarget": {
- "type": "plan",
- "value": ".claude/plan/workflow-visual-proof.json"
- }
- },
- "workers": [
- {
- "id": "seed-check",
- "label": "seed-check",
- "state": "running",
- "branch": "...",
- "worktree": "...",
- "runtime": {
- "kind": "tmux-pane",
- "command": "codex",
- "pid": 1234,
- "active": false,
- "dead": false
- },
- "intent": {
- "objective": "...",
- "seedPaths": ["scripts/orchestrate-worktrees.js"]
- },
- "outputs": {
- "summary": [],
- "validation": [],
- "remainingRisks": []
- },
- "artifacts": {
- "statusFile": "...",
- "taskFile": "...",
- "handoffFile": "..."
- }
- }
- ],
- "aggregates": {
- "workerCount": 2,
- "states": {
- "running": 1,
- "completed": 1
- }
- }
-}
-```
-
-This preserves the useful signal already present while removing tmux-specific
-details from the control-plane contract.
-
-## First Adapters To Support
-
-### 1. `dmux-tmux`
-
-Wrap the logic already living in
-`scripts/lib/orchestration-session.js`.
-
-This is the easiest first adapter because the substrate is already real.
-
-### 2. `claude-history`
-
-Normalize the data that
-`commands/sessions.md`
-and the existing session-manager utilities already expose:
-
-- session id / alias
-- branch
-- worktree
-- project path
-- recency / file size / item counts
-
-This provides a non-orchestrated baseline for ECC 2.0.
-
-### 3. `codex-worktree`
-
-Use the same canonical shape, but back it with Codex-native execution metadata
-instead of tmux assumptions where available.
-
-### 4. `opencode`
-
-Use the same adapter boundary once OpenCode session metadata is stable enough to
-normalize.
-
-## What Should Stay Out Of The Adapter Layer
-
-The adapter layer should not own:
-
-- business logic for merge sequencing
-- operator UI layout
-- pricing or monetization decisions
-- install profile selection
-- tmux lifecycle orchestration itself
-
-Its job is narrower:
-
-- detect session targets
-- load normalized snapshots
-- optionally stream runtime events
-- optionally expose safe actions
-
-## Current File Layout
-
-The adapter layer now lives in:
-
-```text
-scripts/lib/session-adapters/
- canonical-session.js
- dmux-tmux.js
- claude-history.js
- registry.js
-scripts/session-inspect.js
-tests/lib/session-adapters.test.js
-tests/scripts/session-inspect.test.js
-```
-
-The current orchestration snapshot parser is now being consumed as an adapter
-implementation rather than remaining the only product contract.
-
-## Immediate Next Steps
-
-1. Add a third adapter, likely `codex-worktree`, so the abstraction moves
- beyond tmux plus Claude-history.
-2. Decide whether canonical snapshots need separate `state` and `health`
- fields before UI work starts.
-3. Decide whether event streaming belongs in v1 or stays out until after the
- snapshot layer proves itself.
-4. Build operator-facing panels only on top of the adapter registry, not by
- reading orchestration internals directly.
-
-## Open Questions
-
-1. Should worker identity be keyed by worker slug, branch, or stable UUID?
-2. Do we need separate `state` and `health` fields at the canonical layer?
-3. Should event streaming be part of v1, or should ECC 2.0 ship snapshot-only
- first?
-4. How much path information should be redacted before snapshots leave the local
- machine?
-5. Should the adapter registry live inside this repo long-term, or move into the
- eventual ECC 2.0 control-plane app once the interface stabilizes?
-
-## Recommendation
-
-Treat the current tmux/worktree implementation as adapter `0`, not as the final
-product surface.
-
-The shortest path to ECC 2.0 is:
-
-1. preserve the current orchestration substrate
-2. wrap it in a canonical session adapter contract
-3. add one non-tmux adapter
-4. only then start building operator panels on top
diff --git a/docs/HERMES-OPENCLAW-MIGRATION.md b/docs/HERMES-OPENCLAW-MIGRATION.md
index 8391398c8..4984a9cbd 100644
--- a/docs/HERMES-OPENCLAW-MIGRATION.md
+++ b/docs/HERMES-OPENCLAW-MIGRATION.md
@@ -46,7 +46,7 @@ That means the shortest safe path is:
Use the current workspace split consistently:
- live code work happens in cloned repos under `~/GitHub`
-- repo-specific active execution context lives in repo-level `WORKING-CONTEXT.md`
+- repo-specific direction lives in the repo's planning docs under `docs/`, shipped change history in `CHANGELOG.md`
- broader non-code context can live in KB/archive layers
- durable cross-machine truth should prefer GitHub, Linear, and the knowledge base
@@ -105,7 +105,7 @@ Source examples:
Translate into:
- `knowledge-ops`
-- repo `WORKING-CONTEXT.md`
+- repo planning docs under `docs/` and `CHANGELOG.md`
- GitHub / Linear / KB-backed durable context
- future deep memory work under `#1049`
diff --git a/docs/MEGA-PLAN-REPO-PROMPTS-2026-03-12.md b/docs/MEGA-PLAN-REPO-PROMPTS-2026-03-12.md
deleted file mode 100644
index 4830deb5c..000000000
--- a/docs/MEGA-PLAN-REPO-PROMPTS-2026-03-12.md
+++ /dev/null
@@ -1,286 +0,0 @@
-# Mega Plan Repo Prompt List — March 12, 2026
-
-## Purpose
-
-Use these prompts to split the remaining March 11 mega-plan work by repo.
-They are written for parallel agents and assume the March 12 orchestration and
-Windows CI lane is already merged via `#417`.
-
-## Current Snapshot
-
-- `everything-claude-code` has finished the orchestration, Codex baseline, and
- Windows CI recovery lane.
-- The next open ECC Phase 1 items are:
- - review `#399`
- - convert recurring discussion pressure into tracked issues
- - define selective-install architecture
- - write the ECC 2.0 discovery doc
-- `agentshield`, `ECC-website`, and `skill-creator-app` all have dirty
- `main` worktrees and should not be edited directly on `main`.
-- `applications/` is not a standalone git repo. It lives inside the parent
- workspace repo at ``.
-
-## Repo: `everything-claude-code`
-
-### Prompt A — PR `#399` Review and Merge Readiness
-
-```text
-Work in: /everything-claude-code
-
-Goal:
-Review PR #399 ("fix(observe): 5-layer automated session guard to prevent
-self-loop observations") against the actual loop problem described in issue
-#398 and the March 11 mega plan. Do not assume the old failing CI on the PR is
-still meaningful, because the Windows baseline was repaired later in #417.
-
-Tasks:
-1. Read issue #398 and PR #399 in full.
-2. Inspect the observe hook implementation and tests locally.
-3. Determine whether the PR really prevents observer self-observation,
- automated-session observation, and runaway recursive loops.
-4. Identify any missing env-based bypass, idle gating, or session exclusion
- behavior.
-5. Produce a merge recommendation with findings ordered by severity.
-
-Constraints:
-- Do not merge automatically.
-- Do not rewrite unrelated hook behavior.
-- If you make code changes, keep them tightly scoped to observe behavior and
- tests.
-
-Deliverables:
-- review summary
-- exact findings with file references
-- recommended merge / rework decision
-- test commands run
-```
-
-### Prompt B — Roadmap Issues Extraction
-
-```text
-Work in: /everything-claude-code
-
-Goal:
-Convert recurring discussion pressure from the mega plan into concrete GitHub
-issues. Focus on high-signal roadmap items that unblock ECC 1.x and ECC 2.0.
-
-Create issue drafts or a ready-to-post issue bundle for:
-1. selective install profiles
-2. uninstall / doctor / repair lifecycle
-3. generated skill placement and provenance policy
-4. governance past the tool call
-5. ECC 2.0 discovery doc / adapter contracts
-
-Tasks:
-1. Read the March 11 mega plan and March 12 handoff.
-2. Deduplicate against already-open issues.
-3. Draft issue titles, problem statements, scope, non-goals, acceptance
- criteria, and file/system areas affected.
-
-Constraints:
-- Do not create filler issues.
-- Prefer 4-6 high-value issues over a large backlog dump.
-- Keep each issue scoped so it could plausibly land in one focused PR series.
-
-Deliverables:
-- issue shortlist
-- ready-to-post issue bodies
-- duplication notes against existing issues
-```
-
-### Prompt C — ECC 2.0 Discovery and Adapter Spec
-
-```text
-Work in: /everything-claude-code
-
-Goal:
-Turn the existing ECC 2.0 vision into a first concrete discovery doc focused on
-adapter contracts, session/task state, token accounting, and security/policy
-events.
-
-Tasks:
-1. Use the current orchestration/session snapshot code as the baseline.
-2. Define a normalized adapter contract for Claude Code, Codex, OpenCode, and
- later Cursor / GitHub App integration.
-3. Define the initial SQLite-backed data model for sessions, tasks, worktrees,
- events, findings, and approvals.
-4. Define what stays in ECC 1.x versus what belongs in ECC 2.0.
-5. Call out unresolved product decisions separately from implementation
- requirements.
-
-Constraints:
-- Treat the current tmux/worktree/session snapshot substrate as the starting
- point, not a blank slate.
-- Keep the doc implementation-oriented.
-
-Deliverables:
-- discovery doc
-- adapter contract sketch
-- event model sketch
-- unresolved questions list
-```
-
-## Repo: `agentshield`
-
-### Prompt — False Positive Audit and Regression Plan
-
-```text
-Work in: /agentshield
-
-Goal:
-Advance the AgentShield Phase 2 workstream from the mega plan: reduce false
-positives, especially where declarative deny rules, block hooks, docs examples,
-or config snippets are misclassified as executable risk.
-
-Important repo state:
-- branch is currently main
-- dirty files exist in CLAUDE.md and README.md
-- classify or park existing edits before broader changes
-
-Tasks:
-1. Inspect the current false-positive behavior around:
- - .claude hook configs
- - AGENTS.md / CLAUDE.md
- - .cursor rules
- - .opencode plugin configs
- - sample deny-list patterns
-2. Separate parser behavior for declarative patterns vs executable commands.
-3. Propose regression coverage additions and the exact fixture set needed.
-4. If safe after branch setup, implement the first pass of the classifier fix.
-
-Constraints:
-- do not work directly on dirty main
-- keep fixes parser/classifier-scoped
-- document any remaining ambiguity explicitly
-
-Deliverables:
-- branch recommendation
-- false-positive taxonomy
-- proposed or landed regression tests
-- remaining edge cases
-```
-
-## Repo: `ECC-website`
-
-### Prompt — Landing Rewrite and Product Framing
-
-```text
-Work in: /ECC-website
-
-Goal:
-Execute the website lane from the mega plan by rewriting the landing/product
-framing away from "config repo" and toward "open agent harness system" plus
-future control-plane direction.
-
-Important repo state:
-- branch is currently main
-- dirty files exist in favicon assets and multiple page/component files
-- branch before meaningful work and preserve existing edits unless explicitly
- classified as stale
-
-Tasks:
-1. Classify the dirty main worktree state.
-2. Rewrite the landing page narrative around:
- - open agent harness system
- - runtime guardrails
- - cross-harness parity
- - operator visibility and security
-3. Define or update the next key pages:
- - /skills
- - /security
- - /platforms
- - /system or /dashboard
-4. Keep the page visually intentional and product-forward, not generic SaaS.
-
-Constraints:
-- do not silently overwrite existing dirty work
-- preserve existing design system where it is coherent
-- distinguish ECC 1.x toolkit from ECC 2.0 control plane clearly
-
-Deliverables:
-- branch recommendation
-- landing-page rewrite diff or content spec
-- follow-up page map
-- deployment readiness notes
-```
-
-## Repo: `skill-creator-app`
-
-### Prompt — Skill Import Pipeline and Product Fit
-
-```text
-Work in: /skill-creator-app
-
-Goal:
-Align skill-creator-app with the mega-plan external skill sourcing and audited
-import pipeline workstream.
-
-Important repo state:
-- branch is currently main
-- dirty files exist in README.md and src/lib/github.ts
-- classify or park existing changes before broader work
-
-Tasks:
-1. Assess whether the app should support:
- - inventorying external skills
- - provenance tagging
- - dependency/risk audit fields
- - ECC convention adaptation workflows
-2. Review the existing GitHub integration surface in src/lib/github.ts.
-3. Produce a concrete product/technical scope for an audited import pipeline.
-4. If safe after branching, land the smallest enabling changes for metadata
- capture or GitHub ingestion.
-
-Constraints:
-- do not turn this into a generic prompt-builder
-- keep the focus on audited skill ingestion and ECC-compatible output
-
-Deliverables:
-- product-fit summary
-- recommended scope for v1
-- data fields / workflow steps for the import pipeline
-- code changes if they are small and clearly justified
-```
-
-## Repo: `ECC` Workspace (`applications/`, `knowledge/`, `tasks/`)
-
-### Prompt — Example Apps and Workflow Reliability Proofs
-
-```text
-Work in:
-
-Goal:
-Use the parent ECC workspace to support the mega-plan hosted/workflow lanes.
-This is not a standalone applications repo; it is the umbrella workspace that
-contains applications/, knowledge/, tasks/, and related planning assets.
-
-Tasks:
-1. Inventory what in applications/ is real product code vs placeholder.
-2. Identify where example repos or demo apps should live for:
- - GitHub App workflow proofs
- - ECC 2.0 prototype spikes
- - example install / setup reliability checks
-3. Propose a clean workspace structure so product code, research, and planning
- stop bleeding into each other.
-4. Recommend which proof-of-concept should be built first.
-
-Constraints:
-- do not move large directories blindly
-- distinguish repo structure recommendations from immediate code changes
-- keep recommendations compatible with the current multi-repo ECC setup
-
-Deliverables:
-- workspace inventory
-- proposed structure
-- first demo/app recommendation
-- follow-up branch/worktree plan
-```
-
-## Local Continuation
-
-The current worktree should stay on ECC-native Phase 1 work that does not touch
-the existing dirty skill-file changes here. The best next local tasks are:
-
-1. selective-install architecture
-2. ECC 2.0 discovery doc
-3. PR `#399` review
diff --git a/docs/PHASE1-ISSUE-BUNDLE-2026-03-12.md b/docs/PHASE1-ISSUE-BUNDLE-2026-03-12.md
deleted file mode 100644
index d1594a3af..000000000
--- a/docs/PHASE1-ISSUE-BUNDLE-2026-03-12.md
+++ /dev/null
@@ -1,272 +0,0 @@
-# Phase 1 Issue Bundle — March 12, 2026
-
-## Status
-
-These issue drafts were prepared from the March 11 mega plan plus the March 12
-handoff. I attempted to open them directly in GitHub, but issue creation was
-blocked by missing GitHub authentication in the MCP session.
-
-## GitHub Status
-
-These drafts were later posted via `gh`:
-
-- `#423` Implement manifest-driven selective install profiles for ECC
-- `#421` Add ECC install-state plus uninstall / doctor / repair lifecycle
-- `#424` Define canonical session adapter contract for ECC 2.0 control plane
-- `#422` Define generated skill placement and provenance policy
-- `#425` Define governance and visibility past the tool call
-
-The bodies below are preserved as the local source bundle used to create the
-issues.
-
-## Issue 1
-
-### Title
-
-Implement manifest-driven selective install profiles for ECC
-
-### Labels
-
-- `enhancement`
-
-### Body
-
-```md
-## Problem
-
-ECC still installs primarily by target and language. The repo now has first-pass
-selective-install manifests and a non-mutating plan resolver, but the installer
-itself does not yet consume those profiles.
-
-Current groundwork already landed in-repo:
-
-- `manifests/install-modules.json`
-- `manifests/install-profiles.json`
-- `scripts/ci/validate-install-manifests.js`
-- `scripts/lib/install-manifests.js`
-- `scripts/install-plan.js`
-
-That means the missing step is no longer design discovery. The missing step is
-execution: wire profile/module resolution into the actual install flow while
-preserving backward compatibility.
-
-## Scope
-
-Implement manifest-driven install execution for current ECC targets:
-
-- `claude`
-- `cursor`
-- `antigravity`
-
-Add first-pass support for:
-
-- `ecc-install --profile `
-- `ecc-install --modules `
-- target-aware filtering based on module target support
-- backward-compatible legacy language installs during rollout
-
-## Non-Goals
-
-- Full uninstall/doctor/repair lifecycle in the same issue
-- Codex/OpenCode install targets in the first pass if that blocks rollout
-- Reorganizing the repository into separate published packages
-
-## Acceptance Criteria
-
-- `install.sh` can resolve and install a named profile
-- `install.sh` can resolve explicit module IDs
-- Unsupported modules for a target are skipped or rejected deterministically
-- Legacy language-based install mode still works
-- Tests cover profile resolution and installer behavior
-- Docs explain the new preferred profile/module install path
-```
-
-## Issue 2
-
-### Title
-
-Add ECC install-state plus uninstall / doctor / repair lifecycle
-
-### Labels
-
-- `enhancement`
-
-### Body
-
-```md
-## Problem
-
-ECC has no canonical installed-state record. That makes uninstall, repair, and
-post-install inspection nondeterministic.
-
-Today the repo can classify installable content, but it still cannot reliably
-answer:
-
-- what profile/modules were installed
-- what target they were installed into
-- what paths ECC owns
-- how to remove or repair only ECC-managed files
-
-Without install-state, lifecycle commands are guesswork.
-
-## Scope
-
-Introduce a durable install-state contract and the first lifecycle commands:
-
-- `ecc list-installed`
-- `ecc uninstall`
-- `ecc doctor`
-- `ecc repair`
-
-Suggested state locations:
-
-- Claude: `~/.claude/ecc/install-state.json`
-- Cursor: `./.cursor/ecc-install-state.json`
-- Antigravity: `./.agent/ecc-install-state.json`
-
-The state file should capture at minimum:
-
-- installed version
-- timestamp
-- target
-- profile
-- resolved modules
-- copied/managed paths
-- source repo version or package version
-
-## Non-Goals
-
-- Rebuilding the installer architecture from scratch
-- Full remote/cloud control-plane functionality
-- Target support expansion beyond the current local installers unless it falls
- out naturally
-
-## Acceptance Criteria
-
-- Successful installs write install-state deterministically
-- `list-installed` reports target/profile/modules/version cleanly
-- `doctor` reports missing or drifted managed paths
-- `repair` restores missing managed files from recorded install-state
-- `uninstall` removes only ECC-managed files and leaves unrelated local files
- alone
-- Tests cover install-state creation and lifecycle behavior
-```
-
-## Issue 3
-
-### Title
-
-Define canonical session adapter contract for ECC 2.0 control plane
-
-### Labels
-
-- `enhancement`
-
-### Body
-
-```md
-## Problem
-
-ECC now has real orchestration/session substrate, but it is still
-implementation-specific.
-
-Current state:
-
-- tmux/worktree orchestration exists
-- machine-readable session snapshots exist
-- Claude local session-history commands exist
-
-What does not exist yet is a harness-neutral adapter boundary that can normalize
-session/task state across:
-
-- tmux-orchestrated workers
-- plain Claude sessions
-- Codex worktrees
-- OpenCode sessions
-- later remote or GitHub-integrated operator surfaces
-
-Without that adapter contract, any future ECC 2.0 operator shell will be forced
-to read tmux-specific and markdown-coordination details directly.
-
-## Scope
-
-Define and implement the first-pass canonical session adapter layer.
-
-Suggested deliverables:
-
-- adapter registry
-- canonical session snapshot schema
-- `dmux-tmux` adapter backed by current orchestration code
-- `claude-history` adapter backed by current session history utilities
-- read-only inspection CLI for canonical session snapshots
-
-## Non-Goals
-
-- Full ECC 2.0 UI in the same issue
-- Monetization/GitHub App implementation
-- Remote multi-user control plane
-
-## Acceptance Criteria
-
-- There is a documented canonical snapshot contract
-- Current tmux orchestration snapshot code is wrapped as an adapter rather than
- the top-level product contract
-- A second non-tmux adapter exists to prove the abstraction is real
-- Tests cover adapter selection and normalized snapshot output
-- The design clearly separates adapter concerns from orchestration and UI
- concerns
-```
-
-## Issue 4
-
-### Title
-
-Define generated skill placement and provenance policy
-
-### Labels
-
-- `enhancement`
-
-### Body
-
-```md
-## Problem
-
-ECC now has a large and growing skill surface, but generated/imported/learned
-skills do not yet have a clear long-term placement and provenance policy.
-
-This creates several problems:
-
-- unclear separation between curated skills and generated/learned skills
-- validator noise around directories that may or may not exist locally
-- weak provenance for imported or machine-generated skill content
-- uncertainty about where future automated learning outputs should live
-
-As ECC grows, the repo needs explicit rules for where generated skill artifacts
-belong and how they are identified.
-
-## Scope
-
-Define a repo-wide policy for:
-
-- curated vs generated vs imported skill placement
-- provenance metadata requirements
-- validator behavior for optional/generated skill directories
-- whether generated skills are shipped, ignored, or materialized during
- install/build steps
-
-## Non-Goals
-
-- Building a full external skill marketplace
-- Rewriting all existing skill content in one pass
-- Solving every content-quality issue in the same issue
-
-## Acceptance Criteria
-
-- A documented placement policy exists for generated/imported skills
-- Provenance requirements are explicit
-- Validators no longer produce ambiguous behavior around optional/generated
- skill locations
-- The policy clearly states what is publishable vs local-only
-- Follow-on implementation work is split into concrete, bounded PR-sized steps
-```
diff --git a/docs/PR-399-REVIEW-2026-03-12.md b/docs/PR-399-REVIEW-2026-03-12.md
deleted file mode 100644
index 98a2ef238..000000000
--- a/docs/PR-399-REVIEW-2026-03-12.md
+++ /dev/null
@@ -1,59 +0,0 @@
-# PR 399 Review — March 12, 2026
-
-## Scope
-
-Reviewed `#399`:
-
-- title: `fix(observe): 5-layer automated session guard to prevent self-loop observations`
-- head: `e7df0e588ceecfcd1072ef616034ccd33bb0f251`
-- files changed:
- - `skills/continuous-learning-v2/hooks/observe.sh`
- - `skills/continuous-learning-v2/agents/observer-loop.sh`
-
-## Findings
-
-### Medium
-
-1. `skills/continuous-learning-v2/hooks/observe.sh`
-
-The new `CLAUDE_CODE_ENTRYPOINT` guard uses a finite allowlist of known
-non-`cli` values (`sdk-ts`, `sdk-py`, `sdk-cli`, `mcp`, `remote`).
-
-That leaves a forward-compatibility hole: any future non-`cli` entrypoint value
-will fall through and be treated as interactive. That reintroduces the exact
-class of automated-session observation the PR is trying to prevent.
-
-The safer rule is:
-
-- allow only `cli`
-- treat every other explicit entrypoint as automated
-- keep the default fallback as `cli` when the variable is unset
-
-Suggested shape:
-
-```bash
-case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
- cli) ;;
- *) exit 0 ;;
-esac
-```
-
-## Merge Recommendation
-
-`Needs one follow-up change before merge.`
-
-The PR direction is correct:
-
-- it closes the ECC self-observation loop in `observer-loop.sh`
-- it adds multiple guard layers in the right area of `observe.sh`
-- it already addressed the cheaper-first ordering and skip-path trimming issues
-
-But the entrypoint guard should be generalized before merge so the automation
-filter does not silently age out when Claude Code introduces additional
-non-interactive entrypoints.
-
-## Residual Risk
-
-- There is still no dedicated regression test coverage around the new shell
- guard behavior, so the final merge should include at least one executable
- verification pass for the entrypoint and skip-path cases.
diff --git a/docs/PR-QUEUE-TRIAGE-2026-03-13.md b/docs/PR-QUEUE-TRIAGE-2026-03-13.md
deleted file mode 100644
index 892ff579f..000000000
--- a/docs/PR-QUEUE-TRIAGE-2026-03-13.md
+++ /dev/null
@@ -1,355 +0,0 @@
-# PR Review And Queue Triage — March 13, 2026
-
-## Snapshot
-
-This document records a live GitHub triage snapshot for the
-`everything-claude-code` pull-request queue as of `2026-03-13T08:33:31Z`.
-
-Sources used:
-
-- `gh pr view`
-- `gh pr checks`
-- `gh pr diff --name-only`
-- targeted local verification against the merged `#399` head
-
-Stale threshold used for this pass:
-
-- `last updated before 2026-02-11` (`>30` days before March 13, 2026)
-
-## PR `#399` Retrospective Review
-
-PR:
-
-- `#399` — `fix(observe): 5-layer automated session guard to prevent self-loop observations`
-- state: `MERGED`
-- merged at: `2026-03-13T06:40:03Z`
-- merge commit: `c52a28ace9e7e84c00309fc7b629955dfc46ecf9`
-
-Files changed:
-
-- `skills/continuous-learning-v2/hooks/observe.sh`
-- `skills/continuous-learning-v2/agents/observer-loop.sh`
-
-Validation performed against merged head `546628182200c16cc222b97673ddd79e942eacce`:
-
-- `bash -n` on both changed shell scripts
-- `node tests/hooks/hooks.test.js` (`204` passed, `0` failed)
-- targeted hook invocations for:
- - interactive CLI session
- - `CLAUDE_CODE_ENTRYPOINT=mcp`
- - `ECC_HOOK_PROFILE=minimal`
- - `ECC_SKIP_OBSERVE=1`
- - `agent_id` payload
- - trimmed `ECC_OBSERVE_SKIP_PATHS`
-
-Behavioral result:
-
-- the core self-loop fix works
-- automated-session guard branches suppress observation writes as intended
-- the final `non-cli => exit` entrypoint logic is the correct fail-closed shape
-
-Remaining findings:
-
-1. Medium: skipped automated sessions still create homunculus project state
- before the new guards exit.
- `observe.sh` resolves `cwd` and sources project detection before reaching the
- automated-session guard block, so `detect-project.sh` still creates
- `projects//...` directories and updates `projects.json` for sessions that
- later exit early.
-2. Low: the new guard matrix shipped without direct regression coverage.
- The hook test suite still validates adjacent behavior, but it does not
- directly assert the new `CLAUDE_CODE_ENTRYPOINT`, `ECC_HOOK_PROFILE`,
- `ECC_SKIP_OBSERVE`, `agent_id`, or trimmed skip-path branches.
-
-Verdict:
-
-- `#399` is technically correct for its primary goal and was safe to merge as
- the urgent loop-stop fix.
-- It still warrants a follow-up issue or patch to move automated-session guards
- ahead of project-registration side effects and to add explicit guard-path
- tests.
-
-## Open PR Inventory
-
-There are currently `4` open PRs.
-
-### Queue Table
-
-| PR | Title | Draft | Mergeable | Merge State | Updated | Stale | Current Verdict |
-| --- | --- | --- | --- | --- | --- | --- | --- |
-| `#292` | `chore(config): governance and config foundation (PR #272 split 1/6)` | `false` | `MERGEABLE` | `UNSTABLE` | `2026-03-13T07:26:55Z` | `No` | `Best current merge candidate` |
-| `#298` | `feat(agents,skills,rules): add Rust, Java, mobile, DevOps, and performance content` | `false` | `CONFLICTING` | `DIRTY` | `2026-03-11T04:29:07Z` | `No` | `Needs changes before review can finish` |
-| `#336` | `Customisation for Codex CLI - Features from Claude Code and OpenCode` | `true` | `MERGEABLE` | `UNSTABLE` | `2026-03-13T07:26:12Z` | `No` | `Needs manual review and draft exit` |
-| `#420` | `feat: add laravel skills` | `true` | `MERGEABLE` | `UNSTABLE` | `2026-03-12T22:57:36Z` | `No` | `Low-risk draft, review after draft exit` |
-
-No currently open PR is stale by the `>30 days since last update` rule.
-
-## Per-PR Assessment
-
-### `#292` — Governance / Config Foundation
-
-Live state:
-
-- open
-- non-draft
-- `MERGEABLE`
-- merge state `UNSTABLE`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
-
-Scope:
-
-- `.env.example`
-- `.github/ISSUE_TEMPLATE/copilot-task.md`
-- `.github/PULL_REQUEST_TEMPLATE.md`
-- `.gitignore`
-- `.markdownlint.json`
-- `.tool-versions`
-- `VERSION`
-
-Assessment:
-
-- This is the cleanest merge candidate in the current queue.
-- The branch was already refreshed onto current `main`.
-- The currently visible bot feedback is minor/nit-level rather than obviously
- merge-blocking.
-- The main caution is that only external bot checks are visible right now; no
- GitHub Actions matrix run appears in the current PR checks output.
-
-Current recommendation:
-
-- `Mergeable after one final owner pass.`
-- If you want a conservative path, do one quick human review of the remaining
- `.env.example`, PR-template, and `.tool-versions` nitpicks before merge.
-
-### `#298` — Large Multi-Domain Content Expansion
-
-Live state:
-
-- open
-- non-draft
-- `CONFLICTING`
-- merge state `DIRTY`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
- - `cubic · AI code reviewer` passed
-
-Scope:
-
-- `35` files
-- large documentation and skill/rule expansion across Java, Rust, mobile,
- DevOps, performance, data, and MLOps
-
-Assessment:
-
-- This PR is not ready for merge.
-- It conflicts with current `main`, so it is not even mergeable at the branch
- level yet.
-- cubic identified `34` issues across `35` files in the current review.
- Those findings are substantive and technical, not just style cleanup, and
- they cover broken or misleading examples across several new skills.
-- Even without the conflict, the scope is large enough that it needs a deliberate
- content-fix pass rather than a quick merge decision.
-
-Current recommendation:
-
-- `Needs changes.`
-- Rebase or restack first, then resolve the substantive example-quality issues.
-- If momentum matters, split by domain rather than carrying one very large PR.
-
-### `#336` — Codex CLI Customization
-
-Live state:
-
-- open
-- draft
-- `MERGEABLE`
-- merge state `UNSTABLE`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
-
-Scope:
-
-- `scripts/codex-git-hooks/pre-commit`
-- `scripts/codex-git-hooks/pre-push`
-- `scripts/codex/check-codex-global-state.sh`
-- `scripts/codex/install-global-git-hooks.sh`
-- `scripts/sync-ecc-to-codex.sh`
-
-Assessment:
-
-- This PR is no longer conflicting, but it is still draft-only and has not had
- a meaningful first-party review pass.
-- It modifies user-global Codex setup behavior and git-hook installation, so the
- operational blast radius is higher than a docs-only PR.
-- The visible checks are only external bots; there is no full GitHub Actions run
- shown in the current check set.
-- Because the branch comes from a contributor fork `main`, it also deserves an
- extra sanity pass on what exactly is being proposed before changing status.
-
-Current recommendation:
-
-- `Needs changes before merge readiness`, where the required changes are process
- and review oriented rather than an already-proven code defect:
- - finish manual review
- - run or confirm validation on the global-state scripts
- - take it out of draft only after that review is complete
-
-### `#420` — Laravel Skills
-
-Live state:
-
-- open
-- draft
-- `MERGEABLE`
-- merge state `UNSTABLE`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
-
-Scope:
-
-- `README.md`
-- `examples/laravel-api-CLAUDE.md`
-- `rules/php/patterns.md`
-- `rules/php/security.md`
-- `rules/php/testing.md`
-- `skills/configure-ecc/SKILL.md`
-- `skills/laravel-patterns/SKILL.md`
-- `skills/laravel-security/SKILL.md`
-- `skills/laravel-tdd/SKILL.md`
-- `skills/laravel-verification/SKILL.md`
-
-Assessment:
-
-- This is content-heavy and operationally lower risk than `#336`.
-- It is still draft and has not had a substantive human review pass yet.
-- The visible checks are external bots only.
-- Nothing in the live PR state suggests a merge blocker yet, but it is not ready
- to be merged simply because it is still draft and under-reviewed.
-
-Current recommendation:
-
-- `Review next after the highest-priority non-draft work.`
-- Likely a good review candidate once the author is ready to exit draft.
-
-## Mergeability Buckets
-
-### Mergeable Now Or After A Final Owner Pass
-
-- `#292`
-
-### Needs Changes Before Merge
-
-- `#298`
-- `#336`
-
-### Draft / Needs Review Before Any Merge Decision
-
-- `#420`
-
-### Stale `>30 Days`
-
-- none
-
-## Recommended Order
-
-1. `#292`
- This is the cleanest live merge candidate.
-2. `#420`
- Low runtime risk, but wait for draft exit and a real review pass.
-3. `#336`
- Review carefully because it changes global Codex sync and hook behavior.
-4. `#298`
- Rebase and fix the substantive content issues before spending more review time
- on it.
-
-## Bottom Line
-
-- `#399`: safe bugfix merge with one follow-up cleanup still warranted
-- `#292`: highest-priority merge candidate in the current open queue
-- `#298`: not mergeable; conflicts plus substantive content defects
-- `#336`: no longer conflicting, but not ready while still draft and lightly
- validated
-- `#420`: draft, low-risk content lane, review after the non-draft queue
-
-## Live Refresh
-
-Refreshed at `2026-03-13T22:11:40Z`.
-
-### Main Branch
-
-- `origin/main` is green right now, including the Windows test matrix.
-- Mainline CI repair is not the current bottleneck.
-
-### Updated Queue Read
-
-#### `#292` — Governance / Config Foundation
-
-- open
-- non-draft
-- `MERGEABLE`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
-- highest-signal remaining work is not CI repair; it is the small correctness
- pass on `.env.example` and PR-template alignment before merge
-
-Current recommendation:
-
-- `Next actionable PR.`
-- Either patch the remaining doc/config correctness issues, or do one final
- owner pass and merge if you accept the current tradeoffs.
-
-#### `#420` — Laravel Skills
-
-- open
-- draft
-- `MERGEABLE`
-- visible checks:
- - `CodeRabbit` skipped because the PR is draft
- - `GitGuardian Security Checks` passed
-- no substantive human review is visible yet
-
-Current recommendation:
-
-- `Review after the non-draft queue.`
-- Low implementation risk, but not merge-ready while still draft and
- under-reviewed.
-
-#### `#336` — Codex CLI Customization
-
-- open
-- draft
-- `MERGEABLE`
-- visible checks:
- - `CodeRabbit` passed
- - `GitGuardian Security Checks` passed
-- still needs a deliberate manual review because it touches global Codex sync
- and git-hook installation behavior
-
-Current recommendation:
-
-- `Manual-review lane, not immediate merge lane.`
-
-#### `#298` — Large Content Expansion
-
-- open
-- non-draft
-- `CONFLICTING`
-- still the hardest remaining PR in the queue
-
-Current recommendation:
-
-- `Last priority among current open PRs.`
-- Rebase first, then handle the substantive content/example corrections.
-
-### Current Order
-
-1. `#292`
-2. `#420`
-3. `#336`
-4. `#298`
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 000000000..38c18e1a6
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,152 @@
+# ECC Roadmap
+
+Status: maintainer planning draft, updated 2026-09-09 against the integrated
+source candidate based on release 2.2.1. Source inclusion is not a release or live
+verification claim. Dates are targets, not commitments; bracketed numbers remain
+planning choices.
+
+The two older planning docs stay as evidence and history:
+`docs/ECC-2.0-GA-ROADMAP.md` (2.0 milestones and control-plane deltas) and
+`docs/ECC-PRO-SECURITY-ROADMAP.md` (AgentShield and Pro conversion). This file
+is the short, current view.
+
+## Vision
+
+ECC is the operating layer between a developer and whatever coding agent they
+run. Shared skills, rules, and agent guidance provide portable core workflows
+across Claude Code, Codex, OpenCode, Cursor, Gemini, and other harnesses.
+Hooks, installation paths, and feature coverage vary by host; consult the
+[support status matrix](../README.md#platform-support) for current limits.
+The bar for everything that ships: simpler to read, faster to run, and
+traceable after the fact, for agents and humans alike.
+
+Three things follow from that.
+
+1. **The repo is the product.** Curated skills, hooks, and rules are the
+ surface people install. Anything that is not installed, tested, or read by
+ someone should not be in the tree.
+2. **Evidence over assertion.** A harness change earns trust through a gate
+ receipt, a capsule, and a reproducible verdict, not through a paragraph
+ saying it works. The offline eval framework provides the recording and review primitives;
+ isolated candidate execution remains future work.
+3. **Operator patterns travel.** Approval loops, channel discipline,
+ agreement generation, and e-sign placement were built for one desk. As
+ generic skills they are useful to anyone running agents next to
+ counterparties, customers, or money.
+
+## Where we are
+
+- The 2.2.1 source baseline includes guided manifest-driven setup, install-state
+ ownership, repair and uninstall. Its release workflow requires exact-head
+ validation; this roadmap is not release-signature evidence.
+- Catalog in this source snapshot: 68 agents, 289 skills, 94 legacy commands. The
+ count is a liability as much as an asset. Overlapping and unreferenced
+ skills exist.
+- The README now has one primary install section, with per-harness details
+ and release history linked to `CHANGELOG.md`. Further shortening is a target,
+ not a completed claim.
+- Eval source now includes capsule journals, replay matching and offline
+ receipt inspection, plus a protocol example. Candidate execution and staged
+ gate runs are disabled: no actual OS containment exists. Offline validation
+ and a receipt signature do not establish safe execution or promotion authority.
+- The README describes AgentShield scanning and the hosted ECC Pro surface.
+ Further conversion and scan-history improvements below are proposals, not
+ evidence of missing paid functionality or verified adoption.
+
+## Plan
+
+### Track A: condense
+
+Cut what nobody reads or installs. Merge what overlaps. One README that reads
+top to bottom in one pass. Exit criteria: no zero-reference tracked doc
+outside `docs/releases/`, no deprecated skill still shipped by default,
+README under [1,200] lines with one install path per harness.
+
+### Track B: evidence
+
+Implement and independently test an OS executor before enabling the gate:
+contain child processes, filesystem and network access, scrub inherited
+capabilities, enforce resource limits, and bind replay and result provenance.
+Keep execution disabled until those boundaries are proven. Then wire the
+`harness-optimizer` agent and `/harness-audit` to emit gate receipts. Add
+capsule recording to the hooks that already log session activity. Then the
+next two plan slices: offline retrospective grouping over capsules (no new
+rollouts) and forced-compaction tests that prove pinned constraints survive.
+
+### Track C: operator skills
+
+The four desk-pattern skills are present in this candidate: operator approval
+loop, counterparty channel discipline, master agreement drafting with bounded
+schedule append, and e-sign field placement guidance. Validate each with its
+actual consumer and collect outside feedback before adding more. Written send
+and audience contracts do not claim transport enforcement; generated agreements
+remain drafts and DOCX conversion does not establish execution readiness.
+
+### Track D: distribution and revenue
+
+Keep the release path boring: tag on main, CI green at the exact head, packed
+artifact tested on three platforms. Improve the AgentShield-to-Pro conversion path, evaluating hosted scan history
+and a PR-comment autofix loop against what the hosted product already supports. Details and
+scoring live in the security roadmap.
+
+## Next 90 days
+
+Window: 2026-09-02 to 2026-12-01.
+
+### September
+
+- Review and release the composed 2026-09-02 program: offline eval frameworks,
+ desk-pattern skills, condensation and this roadmap. The source candidate
+ incorporates them; merge and release remain separate maintainer decisions.
+- README linear pass merged. Release notes move to `CHANGELOG.md` only.
+- Delete list from the condensation survey executed, with catalog counts,
+ manifests, and locale mirrors updated in the same PR.
+- Decide the fate of `continuous-learning` v1 (deprecated since April): remove
+ in [2.3.0] with a migration note, or keep as an archive outside the default
+ install.
+
+### October
+
+- `harness-optimizer` and `/harness-audit` produce gate receipts. A skill,
+ hook, or agent change in this repo can cite a receipt in its PR.
+- Capsule recording behind an opt-in hook flag, journaling tool calls and
+ session boundaries with the default-deny payload allowlist.
+- First taskset beyond the example: [20 to 60] tasks over one real skill
+ family, with a held-out split and a reward-hack fixture.
+- Skill catalog review: every skill has a test, a command, an agent, or a
+ README mention, or it is marked for removal in [2.4.0].
+
+### November
+
+- 2.3.0: condensation, eval frameworks, and operator skills in one release
+ with the packed-artifact gate.
+- Retrospective grouping over recorded capsules for one task family, report
+ only, no promotion.
+- Forced-compaction invariance test in CI for the pinned-state pattern.
+- AgentShield Pro conversion CTA and hosted scan history behind a flag.
+
+### Decision points
+
+- 2026-09-30: is the README under the line target with no test regressions?
+ If not, cut scope on Track A rather than slipping the release.
+- 2026-10-31: does a real taskset produce a stable verdict across three runs?
+ If variance is high, hold Track B at receipts and do not start retrospective
+ grouping.
+- 2026-11-30: did any outside user adopt a desk-pattern skill? If none, stop
+ adding operator skills and fold the four into a single guide.
+
+## Not on this roadmap
+
+- Online reinforcement learning or weight updates from capsule data.
+- Production transparency-log witnessing, GPU attestation, or key management
+ inside the ECC package.
+- Automatic merge or release driven by a gate verdict. The gate stops changes.
+ A person promotes them.
+- Any desk, payment, provider, or counterparty integration. Those belong to
+ the systems that own them, not to a portable plugin.
+
+## How to edit this file
+
+Change the bracketed numbers first. Move items between months freely. When a
+line ships, delete it here and record it in `CHANGELOG.md`. Keep the file
+under [200] lines.
diff --git a/docs/SELECTIVE-INSTALL-DESIGN.md b/docs/SELECTIVE-INSTALL-DESIGN.md
deleted file mode 100644
index 817210ce8..000000000
--- a/docs/SELECTIVE-INSTALL-DESIGN.md
+++ /dev/null
@@ -1,489 +0,0 @@
-# ECC Selective Install Design
-
-## Purpose
-
-This document defines the user-facing selective-install design for ECC.
-
-It complements
-`docs/SELECTIVE-INSTALL-ARCHITECTURE.md`, which focuses on internal runtime
-architecture and code boundaries.
-
-This document answers the product and operator questions first:
-
-- how users choose ECC components
-- what the CLI should feel like
-- what config file should exist
-- how installation should behave across harness targets
-- how the design maps onto the current ECC codebase without requiring a rewrite
-
-## Problem
-
-Today ECC still feels like a large payload installer even though the repo now
-has first-pass manifest and lifecycle support.
-
-Users need a simpler mental model:
-
-- install the baseline
-- add the language packs they actually use
-- add the framework configs they actually want
-- add optional capability packs like security, research, or orchestration
-
-The selective-install system should make ECC feel composable instead of
-all-or-nothing.
-
-In the current substrate, user-facing components are still an alias layer over
-coarser internal install modules. That means include/exclude is already useful
-at the module-selection level, but some file-level boundaries remain imperfect
-until the underlying module graph is split more finely.
-
-## Goals
-
-1. Let users install a small default ECC footprint quickly.
-2. Let users compose installs from reusable component families:
- - core rules
- - language packs
- - framework packs
- - capability packs
- - target/platform configs
-3. Keep one consistent UX across Claude, Cursor, Antigravity, Codex, and
- OpenCode.
-4. Keep installs inspectable, repairable, and uninstallable.
-5. Preserve backward compatibility with the current `ecc-install typescript`
- style during rollout.
-
-## Non-Goals
-
-- packaging ECC into multiple npm packages in the first phase
-- building a remote marketplace
-- full control-plane UI in the same phase
-- solving every skill-classification problem before selective install ships
-
-## User Experience Principles
-
-### 1. Start Small
-
-A user should be able to get a useful ECC install with one command:
-
-```bash
-ecc install --target claude --profile core
-```
-
-The default experience should not assume the user wants every skill family and
-every framework.
-
-### 2. Build Up By Intent
-
-The user should think in terms of:
-
-- "I want the developer baseline"
-- "I need TypeScript and Python"
-- "I want Next.js and Django"
-- "I want the security pack"
-
-The user should not have to know raw internal repo paths.
-
-### 3. Preview Before Mutation
-
-Every install path should support dry-run planning:
-
-```bash
-ecc install --target cursor --profile developer --with lang:typescript --with framework:nextjs --dry-run
-```
-
-The plan should clearly show:
-
-- selected components
-- skipped components
-- target root
-- managed paths
-- expected install-state location
-
-### 4. Local Configuration Should Be First-Class
-
-Teams should be able to commit a project-level install config and use:
-
-```bash
-ecc install --config ecc-install.json
-```
-
-That allows deterministic installs across contributors and CI.
-
-## Component Model
-
-The current manifest already uses install modules and profiles. The user-facing
-design should keep that internal structure, but present it as four main
-component families.
-
-Near-term implementation note: some user-facing component IDs still resolve to
-shared internal modules, especially in the language/framework layer. The
-catalog improves UX immediately while preserving a clean path toward finer
-module granularity in later phases.
-
-### 1. Baseline
-
-These are the default ECC building blocks:
-
-- core rules
-- baseline agents
-- core commands
-- runtime hooks
-- platform configs
-- workflow quality primitives
-
-Examples of current internal modules:
-
-- `rules-core`
-- `agents-core`
-- `commands-core`
-- `hooks-runtime`
-- `platform-configs`
-- `workflow-quality`
-
-### 2. Language Packs
-
-Language packs group rules, guidance, and workflows for a language ecosystem.
-
-Examples:
-
-- `lang:typescript`
-- `lang:python`
-- `lang:go`
-- `lang:java`
-- `lang:rust`
-
-Each language pack should resolve to one or more internal modules plus
-target-specific assets.
-
-### 3. Framework Packs
-
-Framework packs sit above language packs and pull in framework-specific rules,
-skills, and optional setup.
-
-Examples:
-
-- `framework:react`
-- `framework:nextjs`
-- `framework:django`
-- `framework:springboot`
-- `framework:laravel`
-
-Framework packs should depend on the correct language pack or baseline
-primitives where appropriate.
-
-### 4. Capability Packs
-
-Capability packs are cross-cutting ECC feature bundles.
-
-Examples:
-
-- `capability:security`
-- `capability:research`
-- `capability:orchestration`
-- `capability:media`
-- `capability:content`
-
-These should map onto the current module families already being introduced in
-the manifests.
-
-## Profiles
-
-Profiles remain the fastest on-ramp.
-
-Recommended user-facing profiles:
-
-- `core`
- minimal baseline, safe default for most users trying ECC
-- `developer`
- best default for active software engineering work
-- `security`
- baseline plus security-heavy guidance
-- `research`
- baseline plus research/content/investigation tools
-- `full`
- everything classified and currently supported
-
-Profiles should be composable with additional `--with` and `--without` flags.
-
-Example:
-
-```bash
-ecc install --target claude --profile developer --with lang:typescript --with framework:nextjs --without capability:orchestration
-```
-
-## Proposed CLI Design
-
-### Primary Commands
-
-```bash
-ecc install
-ecc plan
-ecc list-installed
-ecc doctor
-ecc repair
-ecc uninstall
-ecc catalog
-```
-
-### Install CLI
-
-Recommended shape:
-
-```bash
-ecc install [--target ] [--profile ] [--with ]... [--without ]... [--config ] [--dry-run] [--json]
-```
-
-Examples:
-
-```bash
-ecc install --target claude --profile core
-ecc install --target cursor --profile developer --with lang:typescript --with framework:nextjs
-ecc install --target antigravity --with capability:security --with lang:python
-ecc install --config ecc-install.json
-```
-
-### Plan CLI
-
-Recommended shape:
-
-```bash
-ecc plan [same selection flags as install]
-```
-
-Purpose:
-
-- produce a preview without mutation
-- act as the canonical debugging surface for selective install
-
-### Catalog CLI
-
-Recommended shape:
-
-```bash
-ecc catalog profiles
-ecc catalog components
-ecc catalog components --family language
-ecc catalog show framework:nextjs
-```
-
-Purpose:
-
-- let users discover valid component names without reading docs
-- keep config authoring approachable
-
-### Compatibility CLI
-
-These legacy flows should still work during migration:
-
-```bash
-ecc-install typescript
-ecc-install --target cursor typescript
-ecc typescript
-```
-
-Internally these should normalize into the new request model and write
-install-state the same way as modern installs.
-
-## Proposed Config File
-
-### Filename
-
-Recommended default:
-
-- `ecc-install.json`
-
-Optional future support:
-
-- `.ecc/install.json`
-
-### Config Shape
-
-```json
-{
- "$schema": "./schemas/ecc-install-config.schema.json",
- "version": 1,
- "target": "cursor",
- "profile": "developer",
- "include": [
- "lang:typescript",
- "lang:python",
- "framework:nextjs",
- "capability:security"
- ],
- "exclude": [
- "capability:media"
- ],
- "options": {
- "hooksProfile": "standard",
- "mcpCatalog": "baseline",
- "includeExamples": false
- }
-}
-```
-
-### Field Semantics
-
-- `target`
- selected harness target such as `claude`, `cursor`, or `antigravity`
-- `profile`
- baseline profile to start from
-- `include`
- additional components to add
-- `exclude`
- components to subtract from the profile result
-- `options`
- target/runtime tuning flags that do not change component identity
-
-### Precedence Rules
-
-1. CLI arguments override config file values.
-2. config file overrides profile defaults.
-3. profile defaults override internal module defaults.
-
-This keeps the behavior predictable and easy to explain.
-
-## Modular Installation Flow
-
-The user-facing flow should be:
-
-1. load config file if provided or auto-detected
-2. merge CLI intent on top of config intent
-3. normalize the request into a canonical selection
-4. expand profile into baseline components
-5. add `include` components
-6. subtract `exclude` components
-7. resolve dependencies and target compatibility
-8. render a plan
-9. apply operations if not in dry-run mode
-10. write install-state
-
-The important UX property is that the exact same flow powers:
-
-- `install`
-- `plan`
-- `repair`
-- `uninstall`
-
-The commands differ in action, not in how ECC understands the selected install.
-
-## Target Behavior
-
-Selective install should preserve the same conceptual component graph across all
-targets, while letting target adapters decide how content lands.
-
-### Claude
-
-Best fit for:
-
-- home-scoped ECC baseline
-- commands, agents, rules, hooks, platform config, orchestration
-
-### Cursor
-
-Best fit for:
-
-- project-scoped installs
-- rules plus project-local automation and config
-
-### Antigravity
-
-Best fit for:
-
-- project-scoped agent/rule/workflow installs
-
-### Codex / OpenCode
-
-Should remain additive targets rather than special forks of the installer.
-
-The selective-install design should make these just new adapters plus new
-target-specific mapping rules, not new installer architectures.
-
-## Technical Feasibility
-
-This design is feasible because the repo already has:
-
-- install module and profile manifests
-- target adapters with install-state paths
-- plan inspection
-- install-state recording
-- lifecycle commands
-- a unified `ecc` CLI surface
-
-The missing work is not conceptual invention. The missing work is productizing
-the current substrate into a cleaner user-facing component model.
-
-### Feasible In Phase 1
-
-- profile + include/exclude selection
-- `ecc-install.json` config file parsing
-- catalog/discovery command
-- alias mapping from user-facing component IDs to internal module sets
-- dry-run and JSON planning
-
-### Feasible In Phase 2
-
-- richer target adapter semantics
-- merge-aware operations for config-like assets
-- stronger repair/uninstall behavior for non-copy operations
-
-### Later
-
-- reduced publish surface
-- generated slim bundles
-- remote component fetch
-
-## Mapping To Current ECC Manifests
-
-The current manifests do not yet expose a true user-facing `lang:*` /
-`framework:*` / `capability:*` taxonomy. That should be introduced as a
-presentation layer on top of the existing modules, not as a second installer
-engine.
-
-Recommended approach:
-
-- keep `install-modules.json` as the internal resolution catalog
-- add a user-facing component catalog that maps friendly component IDs to one or
- more internal modules
-- let profiles reference either internal modules or user-facing component IDs
- during the migration window
-
-That avoids breaking the current selective-install substrate while improving UX.
-
-## Suggested Rollout
-
-### Phase 1: Design And Discovery
-
-- finalize the user-facing component taxonomy
-- add the config schema
-- add CLI design and precedence rules
-
-### Phase 2: User-Facing Resolution Layer
-
-- implement component aliases
-- implement config-file parsing
-- implement `include` / `exclude`
-- implement `catalog`
-
-### Phase 3: Stronger Target Semantics
-
-- move more logic into target-owned planning
-- support merge/generate operations cleanly
-- improve repair/uninstall fidelity
-
-### Phase 4: Packaging Optimization
-
-- narrow published surface
-- evaluate generated bundles
-
-## Recommendation
-
-The next implementation move should not be "rewrite the installer."
-
-It should be:
-
-1. keep the current manifest/runtime substrate
-2. add a user-facing component catalog and config file
-3. add `include` / `exclude` selection and catalog discovery
-4. let the existing planner and lifecycle stack consume that model
-
-That is the shortest path from the current ECC codebase to a real selective
-install experience that feels like ECC 2.0 instead of a large legacy installer.
diff --git a/docs/architecture/cross-harness.md b/docs/architecture/cross-harness.md
index ec8d21a09..768414b72 100644
--- a/docs/architecture/cross-harness.md
+++ b/docs/architecture/cross-harness.md
@@ -59,6 +59,9 @@ Adapters should stay thin. The shared behavior belongs in `skills/`, `rules/`, `
## Shared Memory Contract
+The session snapshot side of this contract (`ecc.session.v1`) is specified in
+[session-adapter-contract.md](session-adapter-contract.md).
+
ECC Memory Vault is the common knowledge-transfer surface for Claude, Codex,
Hermes, Cursor, OpenCode, and other agents. It stores portable
`ecc.memory.v1` Markdown documents in three scopes:
diff --git a/docs/architecture/eval-harness-frameworks.md b/docs/architecture/eval-harness-frameworks.md
new file mode 100644
index 000000000..9696a13d5
--- /dev/null
+++ b/docs/architecture/eval-harness-frameworks.md
@@ -0,0 +1,330 @@
+# Eval Harness Frameworks
+
+Local capsule, inspection, fixture replay, and receipt building blocks.
+Candidate execution and promotion are unavailable.
+They live in `scripts/lib/eval-harness/`, ship with a CLI at
+`scripts/eval-harness.js`, and have an end-to-end example under
+`examples/eval-harness/`. The example runs locally, offline, and inside temporary
+directories. It does not merge, deploy, publish, or spend.
+
+```sh
+node scripts/eval-harness.js example
+```
+
+## Why these five
+
+The harness engineering plan v2 (August 2026) describes a twelve-layer stack.
+The part that belongs in the portable ECC package is the contract surface any
+harness can install and exercise: record what happened, prove it was not
+altered, gate a proposed change behind an external checker, replay tool calls
+without re-firing effects, and hand a verifier something it can check without
+trusting the producer. The execution gate remains disabled pending a verified OS containment backend.
+The other modules expose local utilities, not a trust decision about code.
+
+| Framework | Module | Plan epic | What it gives you today |
+| --- | --- | --- | --- |
+| Envelope | `envelope.js`, `schemas/capsule-envelope.schema.json` | 01 telemetry and capsule contract | `capsule-envelope/v1`, stable identifiers, effect classes SE0 to SE4, default-deny payload allowlist, secret canaries |
+| Capsule | `capsule.js` | 02 local execution capsule | Append-only NDJSON journal, five lineages, sha256 predecessor links, `verify` that fails at the exact entry, byte-stable projection, minimal export bundle |
+| Gate | `gate.js`, `gate-child.js` | 03 verification gate | Static source digests and syntactic warnings; all execution entrypoints refuse |
+| Replay | `replay.js`, `effect-fence.js` | 04 replay-safe branching | Declared determinism and effect class per tool, content-addressed fixtures, `tool.fixture_missing` fail-closed replay, retired child preload refuses execution |
+| Receipt | `receipt.js` | 07 verifiable receipts | Offline receipt over capsule root, entry count, artifact digest, and gate receipt; detached signature interface; verification names the failing check |
+
+Epics 05 (offline self-improvement) and 06 (causal triage and compaction
+invariance) are not implemented. They consume the records these five produce.
+
+## Effect classes
+
+Every journal entry, tool declaration, and variant manifest carries one class.
+
+| Class | Meaning | Where it is allowed |
+| --- | --- | --- |
+| SE0 | Read-only evaluation or schema validation | Everywhere |
+| SE1 | Reversible local writes inside the capsule or work root | Journal, gate metadata |
+| SE2 | Process or filesystem mutation, no live network writes | Candidate execution unavailable |
+| SE3 | Append-only remote evidence publication | Never in replay; trusted record-mode caller controls authorization; refused in replay |
+| SE4 | Economic, counterparty, payment, provider, or secret-handling effects | Never in replay; record mode requires the trusted caller to forbid it |
+
+Effect classes are declarations, not OS permissions. Static inspection reports
+effect-class expansion but cannot enforce a declaration. The replayer refuses
+SE3 and above in replay mode regardless of fixtures; record mode invokes the
+caller-supplied implementation up to its configured maximum. Only register
+trusted implementations. No JavaScript tool wrapper isolates arbitrary code.
+
+## Capsule journal
+
+A capsule is a directory with `capsule.json`, `journal.ndjson`, and an optional
+`projection.json`. Each line of the journal is one canonical-JSON envelope. The
+first entry links to sixty-four zeros; every later entry links to the previous
+`entry_hash`.
+
+```js
+const { capsule } = require('./scripts/lib/eval-harness');
+const c = capsule.Capsule.create('.ecc/capsules/run-42', { task_family: 'slugify' });
+c.append('plan', 'inspection.start', { task_id: 't01' });
+c.append('attempt', 'gate.unavailable', { status: 'blocked', reason: 'gate.isolation_required' });
+capsule.verify('.ecc/capsules/run-42'); // { ok, code, failed_at, root_hash }
+```
+
+`verify` returns `ok: false` with a stable code and the exact failing index for
+a changed byte (`capsule.invalid_entry`), a dropped or swapped entry
+(`capsule.reordered` or `capsule.broken_link`), and a partial trailing write
+(`capsule.truncated_tail`). The journal digest covers the original bytes;
+invalid UTF-8 is rejected as `capsule.non_canonical`. `project` derives stable
+content from the verified journal snapshot and validated metadata. `exportBundle`
+copies the three capsule files and nothing from the workspace.
+
+Metadata is validated before creation writes and when opening, verifying or
+projecting a capsule. IDs use the envelope ID pattern; harness/task family must
+be nonempty, and created_at must use the canonical ISO timestamp produced by
+Date.toISOString(). Missing, unreadable or malformed metadata returns
+`capsule.metadata_invalid`; invalid UTF-8 is also rejected. Every journal entry must match metadata schema,
+run_id, capsule_id, harness_version and task_family, or verification returns
+`capsule.metadata_mismatch` at that entry. Empty journals have no historical
+identity binding; their projection and receipt bind the metadata values.
+created_at is shape-checked but is not authenticated by journal entries.
+
+Envelope v1 enforces the scalar payload types declared in
+`schemas/capsule-envelope.schema.json`. String fields require strings; number
+fields require finite numbers, and integer fields require integers. Only
+`exit_code` accepts null. No extra nonnegative restrictions are imposed on these
+payload numbers. Omitted append payloads still default to an empty object.
+Explicit null, arrays, primitives, exotic objects, accessors, symbol keys and
+non-enumerable properties are rejected. Plain data objects with either the normal
+or null prototype are accepted. Validation inspects descriptors before reading
+values; it does not isolate proxies or arbitrary caller JavaScript.
+
+Retained fields are validated before canary scanning or hashing. Undefined,
+non-finite numbers, functions, symbols, BigInt and nested/cyclic objects are
+refused instead of coerced, dropped from serialized bytes or recursively scanned.
+`redactPayload` adds an `errors` array to its existing result; callers must check
+it alongside `dropped` and `findings`. Append reports `capsule.payload_invalid`
+without writing a journal entry; the existing finally path releases its owned
+lock. Strict unknown payload keys still report `capsule.payload_denied`.
+`strict: false` permits dropping unknown keys, but never invalid retained values.
+Custom allowlists can narrow v1 fields only, and cannot widen the persisted schema.
+
+Envelope validation also requires its own schema-defined fields and rejects
+unknown top-level fields even when the supplied hash has been recomputed. Invalid
+stored records return `capsule.invalid_entry` at their journal index. This tightens
+acceptance of malformed v1 data: existing nonconforming callers/journals need
+explicit correction; no automatic migration or healing is performed. Valid v1
+bytes and hashes remain unchanged. Generic key preservation and remaining
+non-JSON limitations are described below; neither supplies OS containment.
+
+The generic canonicalizer preserves every selected own enumerable JSON key as an
+own data property, including `__proto__`, `constructor` and `prototype`. It does
+not invoke an inherited setter while constructing the canonical object. Results
+retain their ordinary object prototype. Envelope schema rejection is separate:
+an own `__proto__` key is valid generic JSON data but remains an unknown envelope
+field. Receipt schema acceptance is unchanged; hashing a field is not permission
+from a higher-level schema.
+
+Traversal, key sorting, array handling, undefined omission, JSON.stringify and
+UTF-8 hashing retain their prior policy, including JavaScript's ordering of
+numeric-looking keys. Schema-valid v1 journal/projection bytes and unaffected
+receipt/fixture bytes stay identical. Regression vectors were captured from the
+pre-fix implementation, including unsigned and synthetic string-signed receipts.
+Verification does not rewrite those stored artifacts.
+
+The earlier canonicalizer omitted own `__proto__` keys, creating hash aliases.
+Corrected inputs retaining that key intentionally produce different hashes. An
+artifact retaining it with a legacy digest fails existing hash checks; a fixture
+lookup does not fall back to the old aliased key. Existing key-free stored bytes
+remain readable as those bytes, but cannot authenticate richer original inputs
+whose keys were lost. Recovery requires explicit re-recording from a trusted
+source or receipt rebuilding/re-signing; there is no automatic rekey, migration,
+rewrite, dual-hash acceptance or recovery of already discarded information.
+
+This correction does not define a stricter generic policy for undefined,
+functions/symbols, non-finite numbers, sparse arrays, class/toJSON/getter behavior,
+cycles, resource limits or hostile proxies. Their prior behavior remains; no
+claim of unambiguous hashing for every JavaScript value is made. The envelope's
+stricter scalar validation remains a separate layer.
+
+Append operations serialize cooperating writers using an exclusive local
+`.append.lock` file. Acquisition uses `wx` and fails immediately with
+`capsule.busy` when the path exists, regardless of age or contents. There is no
+waiting, retry, PID/age heuristic, or automatic stale unlocking. Under ownership,
+each append reloads and verifies the complete journal and metadata, then derives
+its sequence and predecessor hash from that snapshot. Preopened handles never
+use cached sequence/hash values as authoritative state. Full validation costs
+O(journal size) per append; this implementation is intended for small local
+journals.
+
+The writer handles short writes until the complete UTF-8 entry has been written,
+then fsyncs the journal. The append lock is released in finally on success,
+validation refusal, or ordinary I/O exceptions. A zero-progress write returns
+`capsule.write_failed`. Release checks the open lock descriptor's device/inode
+against the path before unlinking; a detected missing/replaced lock returns
+`capsule.lock_lost` and a replacement is preserved. This is cooperative ownership
+checking, not atomic protection against an actor replacing paths between syscalls.
+The local filesystem must support exclusive file creation and stable identities.
+
+A process crash can leave `.append.lock` behind. Acquisition/cleanup I/O failures
+can also leave a lock that was not safely released. Further appends stay busy;
+only an operator who has stopped all writers and inspected the capsule should
+perform recovery. The library never guesses ownership, removes an old lock,
+truncates a tail, or repairs journal bytes automatically.
+
+A write failure may leave a partial entry; later appends verify the journal and
+refuse the invalid tail, preserving evidence. A full entry may already exist when
+fsync, close or lock release throws. Such a failure is an ambiguous acknowledgement,
+not proof of rollback: inspect disk before retrying, or a logical event could be
+recorded twice. No transaction, exactly-once retry, parent-directory fsync, or
+power-loss durability guarantee is added here.
+
+Create, read/verify, projection, receipt production and export are not serialized
+by the append lock. Use quiescent capsules for consistent receipts/exports; there
+is no concurrent export guarantee or hostile-filesystem containment. The append
+repair does not change the disabled candidate execution boundary.
+
+What the chain does not claim: it does not stop an operator from replacing the
+whole log. That is the job of a witnessed transparency log, which is a later,
+opt-in layer outside this package.
+
+## Verification gate: unavailable
+
+**Supported candidate execution backends: none, on any OS.** `runGate` and
+`runVariant` throw `gate.isolation_required` unconditionally, before reading
+configuration, copying files, loading candidate modules, or creating receipts.
+`gate run` exits 1 before reading its config or creating a capsule. Direct
+`gate-child.js` invocation and the retired `effect-fence.js` preload also refuse
+before loading requests or candidate code. Trust flags and caller-supplied
+executor objects cannot enable execution. There is no promotion path.
+
+The former directory copy and JavaScript interception did not isolate host
+reads, alternate builtin loaders, or filesystem descriptors and promises.
+Keeping answers in a parent process did not hide the taskset on disk. The
+interception code and staged execution implementation have been removed.
+Node's [permission model](https://nodejs.org/api/permissions.html) and
+[`vm` module](https://nodejs.org/api/vm.html) are not substitutes for isolation
+of malicious code.
+
+A future executor must have a separately reviewed OS containment implementation
+and adversarial evidence on each supported OS. At minimum it must:
+
+- Expose only immutable, digested variant files and task inputs in an ephemeral
+ filesystem. Host tasksets, answers, credentials, configuration, sockets, and
+ other workspaces must be inaccessible, including via links and inherited FDs.
+- Enforce network, process, filesystem, and resource restrictions outside the
+ candidate runtime, with an unprivileged identity and a bounded lifetime.
+- Keep the checker, output/protocol validation, audit channel, and receipt
+ creation outside candidate control. Verify the actual runtime policy using
+ independent canaries before any candidate starts; refuse unavailable backends.
+- Reject failed, timed-out, signalled, incomplete, or malformed baseline runs
+ before evaluating candidate improvements. Require a complete unique result
+ for each task. Container availability or a caller's `verified: true` assertion
+ alone is not policy verification.
+
+Static APIs remain available for trusted, quiescent local source trees:
+`loadTaskset`, `loadVariant`, `digestDir`, and `scanTripwires`. Variant names are
+single components of 1–64 ASCII letters, digits, underscores or hyphens, starting
+with a letter or digit. Entries must be relative regular files included in the
+digest; absolute, parent-traversing, symlinked, and excluded entries are rejected.
+`.git` and `node_modules` remain excluded. Inspection does not resist concurrent
+host filesystem mutation and is not a sandbox or an execution attestation.
+Task IDs must be unique. Syntactic warnings are incomplete by design: zero hits
+prove neither safety nor correctness.
+
+`parseChildResult` and `baselineFailure(run, tasks)` are pure validation helpers
+for bounded protocol and baseline integrity regression checks. No executor calls
+them in this release. Their tests are not evidence of an operational gate or a
+verified OS backend. Existing manifest/config fixtures are preserved as data.
+
+## Replay-safe tool calls
+
+```js
+const { replay } = require('./scripts/lib/eval-harness');
+const store = new replay.FixtureStore('.ecc/fixtures');
+const tools = {
+ read_inventory: { effect_class: 'SE0', determinism: 'deterministic', impl: liveRead },
+ place_order: { effect_class: 'SE4', determinism: 'nondeterministic', impl: livePlace },
+};
+const r = replay.createReplayer(tools, { mode: 'replay', store, maxEffectClass: 'SE2' });
+r.call('read_inventory', { sku: 'gpu-8x' }); // served from fixture or tool.fixture_missing
+r.call('place_order', { sku: 'gpu-8x' }); // tool.effect_forbidden, always
+```
+
+Fixtures are keyed by the canonical hash of `(tool, args)` and store both an
+argument hash and a response hash, so a stale or edited fixture fails with
+`tool.fixture_mismatch`. Record mode executes caller-supplied trusted functions;
+replay uses fixtures. These wrappers do not constrain arbitrary effects inside
+an implementation. The legacy `EFFECT_FENCE_PRELOAD` export remains for import
+compatibility, but loading that file always throws `gate.isolation_required`.
+It no longer attempts JavaScript interception.
+
+## Offline receipts
+
+```sh
+node scripts/eval-harness.js receipt build .ecc/capsules/run-42 \
+ --artifact skills/my-skill/SKILL.md --out run-42.receipt.json
+node scripts/eval-harness.js receipt verify run-42.receipt.json exported-bundle/ \
+ --artifact skills/my-skill/SKILL.md
+```
+
+A receipt names the capsule root, entry count, journal digest, projection
+hash, artifact digest, and optional gate receipt digest, plus its own hash.
+`buildReceipt` now persists `projection.json` using the verified journal snapshot
+before returning the receipt. This is a producer write and can fail on a read-only
+capsule; copy a read-only source to a writable local directory before building.
+An explicit invalid artifact_digest throws `receipt.schema_invalid` before the
+projection write. Other construction failures continue to throw.
+
+`verifyReceipt` is read-only. It never regenerates or heals a missing projection.
+The supplied projection must parse and match the complete deterministic projection
+from the validated metadata/journal snapshot; its computed hash must match both
+its stored projection_hash and the receipt. Missing, unreadable, corrupt or
+substituted projections return `check: 'projection'`; invalid UTF-8 is rejected. Receipt identity mismatches
+and invalid capsule metadata return `check: 'metadata'`.
+
+Schema validation rejects negative, fractional, string or unsafe entry counts,
+invalid identity/schema values and malformed required digests before journal
+indexing. Optional artifact/gate digest fields must be SHA-256 values or null.
+Otherwise valid receipts retain signature, journal integrity, truncation,
+capsule-root and stale-checkpoint checks before projection/artifact comparisons.
+Missing or unreadable artifact files return `check: 'artifact'` rather than
+throwing. Every verification failure has `{ok: false, check, reason}` for these
+validated file/content cases.
+
+Existing v1 exported bundles retain their format. Older source directories whose
+receipts were built without a saved projection must explicitly run `capsule
+project` or rebuild the receipt before verification; verification itself never
+writes a replacement. The CLI validates --artifact, --gate and --out before file
+reads or producer writes: missing values, values that are another flag, and
+repeated flags exit with usage code 2. Disabled gate commands still refuse before
+configuration/capsule I/O.
+
+Signing remains a detached interface: pass a signer when building and a verifier
+when verifying. No key generation, transport or rotation happens in this package.
+A signature proves who vouched for the bytes, not that the run was correct.
+Optional gate-receipt hashing remains for compatibility with existing artifacts;
+accepting externally supplied bytes proves neither containment nor promotion.
+
+This slice addresses receipt/projection validation and metadata identity binding.
+The OS executor is still unavailable. Cooperative append serialization is
+described above; concurrent export/create and broader envelope/review findings
+remain separate. Package/count evidence is a separate ignore-scripts test scope
+and does not validate normal prepack or clear a release.
+
+## Where it plugs in
+
+- `skills/eval-harness/SKILL.md` describes eval-driven development. These
+ frameworks are the mechanical layer under its report format.
+- The `harness-optimizer` agent and `/harness-audit` command must report the gate
+ unavailable until a reviewed OS backend exists. They cannot emit new gate
+ receipts using this implementation.
+- The Rust `ecc2/src/harness_eval.rs` bounded evaluation loop is a separate,
+ earlier experiment. The Node frameworks are the portable surface.
+
+## Tests
+
+```sh
+node tests/lib/eval-harness/envelope.test.js
+node tests/lib/eval-harness/capsule.test.js
+node tests/lib/eval-harness/gate.test.js
+node tests/lib/eval-harness/security.test.js
+node tests/lib/eval-harness/replay.test.js
+node tests/lib/eval-harness/receipt.test.js
+node tests/lib/eval-harness/cli.test.js
+node examples/eval-harness/run-example.js
+```
diff --git a/docs/SESSION-ADAPTER-CONTRACT.md b/docs/architecture/session-adapter-contract.md
similarity index 100%
rename from docs/SESSION-ADAPTER-CONTRACT.md
rename to docs/architecture/session-adapter-contract.md
diff --git a/docs/fixes/HOOK-FIX-20260421-ADDENDUM.md b/docs/fixes/HOOK-FIX-20260421-ADDENDUM.md
deleted file mode 100644
index 331710357..000000000
--- a/docs/fixes/HOOK-FIX-20260421-ADDENDUM.md
+++ /dev/null
@@ -1,109 +0,0 @@
-# HOOK-FIX-20260421 Addendum — v2.1.116 argv 重複バグ
-
-朝セッションで commit 527c18b として修正済み。夜セッションで追加検証と、
-朝fix でカバーしきれない Claude Code 固有のバグを特定したので補遺を記録する。
-
-## 朝fixの形式
-
-```json
-"command": "C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh pre"
-```
-
-`.sh` ファイルを直接 command にする形式。Git Bash が shebang 経由で実行する前提。
-
-## 夜 追加検証で判明したこと
-
-Node.js の `child_process.spawn` で `.sh` ファイルを直接実行すると Windows では
-**EFTYPE** で失敗する:
-
-```js
-spawn('C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh',
- ['post'], {stdio:['pipe','pipe','pipe']});
-// → Error: spawn EFTYPE (errno -4028)
-```
-
-`shell:true` を付ければ cmd.exe 経由で実行できるが、Claude Code 側の実装
-依存のリスクが残る。
-
-## 夜 適用した追加 fix
-
-第1トークンを `bash`(PATH 解決)に変えた明示的な呼び出しに更新:
-
-```json
-{
- "hooks": {
- "PreToolUse": [{
- "matcher": "*",
- "hooks": [{
- "type": "command",
- "command": "bash \"C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh\" pre"
- }]
- }],
- "PostToolUse": [{
- "matcher": "*",
- "hooks": [{
- "type": "command",
- "command": "bash \"C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh\" post"
- }]
- }]
- }
-}
-```
-
-この形式は `~/.claude/hooks/hooks.json` 内の ECC 正規 observer 登録と
-同じパターンで、現実にエラーなく動作している実績あり。
-
-### Node spawn 検証
-
-```js
-spawn('bash "C:/Users/sugig/.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post',
- [], {shell:true});
-// exit=0 → observations.jsonl に正常追記
-```
-
-## Claude Code v2.1.116 の argv 重複バグ(詳細)
-
-朝fix docの「Defect 2」として `bash.exe: bash.exe: cannot execute binary file` を
-記録しているが、その根本メカニズムが特定できたので記す。
-
-### 再現
-
-```bash
-"C:\Program Files\Git\bin\bash.exe" "C:\Program Files\Git\bin\bash.exe"
-# stderr: "C:\Program Files\Git\bin\bash.exe: C:\Program Files\Git\bin\bash.exe: cannot execute binary file"
-# exit: 126
-```
-
-bash は argv[1] を script とみなし読み込もうとする。argv[1] が bash.exe 自身なら
-ELF/PE バイナリ検出で失敗 → exit 126。エラー文言は完全一致。
-
-### Claude Code 側の挙動
-
-hook command が `"C:\Program Files\Git\bin\bash.exe" "C:\Users\...\wrapper.sh"`
-のとき、v2.1.116 は**第1トークン(= bash.exe フルパス)を argv[0] と argv[1] の
-両方に渡す**と推定される。結果 bash は argv[1] = bash.exe を script として
-読み込もうとして 126 で落ちる。
-
-### 回避策
-
-第1トークンを bash.exe のフルパス+スペース付きパスにしないこと:
-1. `OK:` `bash` (PATH 解決の単一トークン)— 夜fix / hooks.json パターン
-2. `OK:` `.sh` 直接パス(Claude Code の .sh ハンドリングに依存)— 朝fix
-3. `BAD:` `"C:\Program Files\Git\bin\bash.exe" ""` — 1トークン目が quoted で空白込み
-
-## 結論
-
-朝fix(直接 .sh 指定)と夜fix(明示的 bash prefix)のどちらも argv 重複バグを
-踏まないが、**夜fixの方が Claude Code の実装依存が少ない**ため推奨。
-
-ただし朝fix commit 527c18b は既に docs/fixes/ に入っているため、この Addendum を
-追記することで両論併記とする。次回 CLI 再起動時に夜fix の方が実運用に残る。
-
-## 関連
-
-- 朝 fix commit: 527c18b
-- 朝 fix doc: docs/fixes/HOOK-FIX-20260421.md
-- 朝 apply script: docs/fixes/apply-hook-fix.sh
-- 夜 fix 記録(ローカル): C:\Users\sugig\Documents\Claude\Projects\ECC作成\hook-fix-report-20260421.md
-- 夜 fix 適用ファイル: C:\Users\sugig\.claude\settings.local.json
-- 夜 backup: C:\Users\sugig\.claude\settings.local.json.bak-hook-fix-20260421
diff --git a/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md b/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md
deleted file mode 100644
index 0572f85f6..000000000
--- a/docs/fixes/INSTALL-HOOK-WRAPPER-FIX-20260422.md
+++ /dev/null
@@ -1,66 +0,0 @@
-# install_hook_wrapper.ps1 argv-dup bug workaround (2026-04-22)
-
-## Summary
-
-`docs/fixes/install_hook_wrapper.ps1` is the PowerShell helper that copies
-`observe-wrapper.sh` into `~/.claude/skills/continuous-learning/hooks/` and
-rewrites `~/.claude/settings.local.json` so the observer hook points at it.
-
-The previous version produced a hook command of the form:
-
-```
-"C:\Program Files\Git\bin\bash.exe" "C:\Users\...\observe-wrapper.sh"
-```
-
-Under Claude Code v2.1.116 the first argv token is duplicated. When that token
-is a quoted Windows executable path, `bash.exe` is re-invoked with itself as
-its `$0`, which fails with `cannot execute binary file` (exit 126). PR #1524
-documents the root cause; this script is a companion that keeps the installer
-in sync with the fixed `settings.local.json` layout.
-
-## What the fix does
-
-- First token is now the PATH-resolved `bash` (no quoted `.exe` path), so the
- argv-dup bug no longer passes a binary as a script.
-- The wrapper path is normalized to forward slashes before it is embedded in
- the hook command, avoiding MSYS backslash handling surprises.
-- `PreToolUse` and `PostToolUse` receive distinct commands with explicit
- `pre` / `post` positional arguments, matching the shape the wrapper expects.
-- The settings file is written with LF line endings so downstream JSON parsers
- never see mixed CRLF/LF output from `ConvertTo-Json`.
-
-## Resulting command shape
-
-```
-bash "C:/Users//.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre
-bash "C:/Users//.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post
-```
-
-## Usage
-
-```powershell
-# Place observe-wrapper.sh next to this script, then:
-pwsh -File docs/fixes/install_hook_wrapper.ps1
-```
-
-The script backs up `settings.local.json` to
-`settings.local.json.bak-` before writing.
-
-## PowerShell 5.1 compatibility
-
-`ConvertFrom-Json -AsHashtable` is PowerShell 7+ only. The script tries
-`-AsHashtable` first and falls back to a manual `PSCustomObject` →
-`Hashtable` conversion on Windows PowerShell 5.1. Both hook buckets
-(`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays are
-materialized as `System.Collections.ArrayList` before serialization, so
-PS 5.1's `ConvertTo-Json` cannot collapse single-element arrays into
-bare objects. Verified by running `powershell -NoProfile -File
-docs/fixes/install_hook_wrapper.ps1` on a Windows 11 machine with only
-Windows PowerShell 5.1 installed (no `pwsh`).
-
-## Related
-
-- PR #1524 — settings.local.json shape fix (same argv-dup root cause)
-- PR #1511 — skip `AppInstallerPythonRedirector.exe` in observer python resolution
-- PR #1539 — locale-independent `detect-project.sh`
-- PR #1542 — `patch_settings_cl_v2_simple.ps1` companion fix
diff --git a/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md b/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md
deleted file mode 100644
index 4a3e8cdc7..000000000
--- a/docs/fixes/PATCH-SETTINGS-SIMPLE-FIX-20260422.md
+++ /dev/null
@@ -1,78 +0,0 @@
-# patch_settings_cl_v2_simple.ps1 argv-dup bug workaround (2026-04-22)
-
-## Summary
-
-`docs/fixes/patch_settings_cl_v2_simple.ps1` is the minimal PowerShell
-helper that patches `~/.claude/settings.local.json` so the observer hook
-points at `observe-wrapper.sh`. It is the "simple" counterpart of
-`docs/fixes/install_hook_wrapper.ps1` (PR #1540): it never copies the
-wrapper script, it only rewrites the settings file.
-
-The previous version of this helper registered the raw `observe.sh` path
-as the hook command, shared a single command string across `PreToolUse`
-and `PostToolUse`, and relied on `ConvertTo-Json` defaults that can emit
-CRLF line endings. Under Claude Code v2.1.116 the first argv token is
-duplicated, so the wrapper needs to be invoked with a specific shape and
-the two hook phases need distinct entries.
-
-## What the fix does
-
-- First token is the PATH-resolved `bash` (no quoted `.exe` path), so the
- argv-dup bug no longer passes a binary as a script. Matches PR #1524 and
- PR #1540.
-- The wrapper path is normalized to forward slashes before it is embedded
- in the hook command, avoiding MSYS backslash handling surprises.
-- `PreToolUse` and `PostToolUse` receive distinct commands with explicit
- `pre` / `post` positional arguments.
-- The settings file is written UTF-8 (no BOM) with CRLF normalized to LF
- so downstream JSON parsers never see mixed line endings.
-- Existing hooks (including legacy `observe.sh` entries and unrelated
- third-party hooks) are preserved — the script only appends the new
- wrapper entries when they are not already registered.
-- Idempotent on re-runs: a second invocation recognizes the canonical
- command strings and logs `[SKIP]` instead of duplicating entries.
-
-## Resulting command shape
-
-```
-bash "C:/Users//.claude/skills/continuous-learning/hooks/observe-wrapper.sh" pre
-bash "C:/Users//.claude/skills/continuous-learning/hooks/observe-wrapper.sh" post
-```
-
-## Usage
-
-```powershell
-pwsh -File docs/fixes/patch_settings_cl_v2_simple.ps1
-# Windows PowerShell 5.1 is also supported:
-powershell -NoProfile -ExecutionPolicy Bypass -File docs/fixes/patch_settings_cl_v2_simple.ps1
-```
-
-The script backs up the existing settings file to
-`settings.local.json.bak-` before writing.
-
-## PowerShell 5.1 compatibility
-
-`ConvertFrom-Json -AsHashtable` is PowerShell 7+ only. The script tries
-`-AsHashtable` first and falls back to a manual `PSCustomObject` →
-`Hashtable` conversion on Windows PowerShell 5.1. Both hook buckets
-(`PreToolUse`, `PostToolUse`) and their inner `hooks` arrays are
-materialized as `System.Collections.ArrayList` before serialization, so
-PS 5.1's `ConvertTo-Json` cannot collapse single-element arrays into bare
-objects.
-
-## Verified cases (dry-run)
-
-1. Fresh install — no existing settings → creates canonical file.
-2. Idempotent re-run — existing canonical file → `[SKIP]` both phases,
- file contents unchanged apart from the pre-write backup.
-3. Legacy `observe.sh` present → preserves the legacy entries and
- appends the new `observe-wrapper.sh` entries alongside them.
-
-All three cases produce LF-only output and match the shape registered by
-PR #1524's manual fix to `settings.local.json`.
-
-## Related
-
-- PR #1524 — settings.local.json shape fix (same argv-dup root cause)
-- PR #1539 — locale-independent `detect-project.sh`
-- PR #1540 — `install_hook_wrapper.ps1` argv-dup fix (companion script)
diff --git a/docs/ja-JP/skills/motion-ui/SKILL.md b/docs/ja-JP/skills/motion-ui/SKILL.md
deleted file mode 100644
index f0c00fd66..000000000
--- a/docs/ja-JP/skills/motion-ui/SKILL.md
+++ /dev/null
@@ -1,11 +0,0 @@
----
-name: motion-ui
-description: 日本語翻訳:このファイルは motion-ui 用の日本語翻訳が必要です
-origin: ECC
----
-
-# motion-ui - 日本語翻訳進行中
-
-このファイルの翻訳は実装中です。英語版は元のスキルファイルを参照してください。
-
-詳細は:`D:/tmp/everything-claude-code/skills/motion-ui/SKILL.md`
diff --git a/docs/releases/1.10.0/discussion-announcement.md b/docs/releases/1.10.0/discussion-announcement.md
deleted file mode 100644
index 9d4b5a6f3..000000000
--- a/docs/releases/1.10.0/discussion-announcement.md
+++ /dev/null
@@ -1,55 +0,0 @@
-# ECC v1.10.0 is live
-
-ECC just crossed **140K stars**, and the public release surface had drifted too far from the actual repo.
-
-So v1.10.0 is a hard sync release:
-
-- **38 agents**
-- **156 skills**
-- **72 commands**
-- plugin/install metadata corrected
-- top-line docs and release surfaces brought back in line
-
-This release also folds in the operator/media lane that has been growing around the core harness system:
-
-- `brand-voice`
-- `social-graph-ranker`
-- `connections-optimizer`
-- `customer-billing-ops`
-- `google-workspace-ops`
-- `project-flow-ops`
-- `workspace-surface-audit`
-- `manim-video`
-- `remotion-video-creation`
-
-And on the 2.0 side:
-
-ECC 2.0 is now **real as an alpha control-plane surface** in-tree under `ecc2/`.
-
-It builds today and exposes:
-
-- `dashboard`
-- `start`
-- `sessions`
-- `status`
-- `stop`
-- `resume`
-- `daemon`
-
-That does **not** mean the full ECC 2.0 roadmap is done.
-
-It means the control-plane alpha is here, usable, and moving out of the “just a vision” category.
-
-The shortest honest framing right now:
-
-- ECC 1.x is the battle-tested harness/workflow layer shipping broadly today
-- ECC 2.0 is the alpha control-plane growing on top of it
-
-If you have been waiting for:
-
-- cleaner install surfaces
-- stronger cross-harness parity
-- operator workflows instead of just coding primitives
-- a real control-plane direction instead of scattered notes
-
-this is the release that makes the repo feel coherent again.
diff --git a/docs/releases/1.8.0/x-quote-eval-skills.md b/docs/releases/1.8.0/x-quote-eval-skills.md
deleted file mode 100644
index 028a72bb0..000000000
--- a/docs/releases/1.8.0/x-quote-eval-skills.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# X Quote Draft - Eval Skills Post
-
-Strong eval skills are now built deeper into ECC.
-
-v1.8.0 expands eval-harness patterns, pass@k guidance, and release-level verification loops so teams can measure reliability, not guess it.
diff --git a/docs/releases/1.8.0/x-quote-plankton-deslop.md b/docs/releases/1.8.0/x-quote-plankton-deslop.md
deleted file mode 100644
index 8ea7093e1..000000000
--- a/docs/releases/1.8.0/x-quote-plankton-deslop.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# X Quote Draft - Plankton / De-slop Workflow
-
-The quality gate model matters.
-
-In v1.8.0 we pushed harder on write-time quality enforcement, deterministic checks, and cleaner loop recovery so agents converge faster with less noise.
diff --git a/docs/releases/2.1.0/assets/ecc-plan-canvas-demo.webm b/docs/releases/2.1.0/assets/ecc-plan-canvas-demo.webm
deleted file mode 100644
index 3017e32a6148292c4f72dacba71898d7ed28ab97..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 286856
zcmcGz^OG+;@Gdw$W81dpjBVSt?U^&SZQHhO+qP|f?)%-lTf6rU*sjhGPjxDJI_ab<
z9crn0{|FwlPjW}ty2o8{6a
z+ZCl;6^tfXVWwOiQ1HJIx=O9}e`sB{EBZehvdWOTa&;gujcixUe-Kx>>px=3Fai=7`lCC2LKVbWCyw0nS=)bg@&l1pt#A1qFk@902X7
z)(3*;cLst6X9j?%)`zP$1c0k{1c3a{8VLA1JLens`KSI8%rZM2M;JIdKYeY#Wxrd$N%ubeeqDh6FE0SYtNYL|;1wXg)%OfA0+ju}{m5@)
zZ0R5S%76F00w#XzP5~8wL7yG~62S7O&&hA?H}37%{yS|Skn%&|koUM}1c(CU0wTZt
zUI{J%HNWCtetGx4@-yFg_jyUz1OtFN0K((1{XO8KukXQc$RQyHdu#R~+s=xE2EKK)qjtUcSv~HZFT49H8qyRl
z{r^S2Hn^uS0PuUN*0)TsSjOf)UtV4=uT?A9aVJaz(H1%Qi)eWd)(d{dfwyngVWFnD
zRo=JO_T2N+Ih*~PM4Sr|O*HmP7bK+>9ClVDVeVaZ-sTl?ULp<_kILYV-rF+_HuUM(
zcR(i7QW(umD@U$w_fcMi9{cR9t+W4$VB!=#5Qn8w4ddFTd{)};TK{C2Yus%rdyjRp*A&xw|>nrN;pbQ
ztk&deNIF>1lI8U&UHcnuyu2n@RKJ5Xbn27N4vi)8L~M}7Mv+00P?Z_Ih|Bdj(8D6L$Dri{
zs;oeL=4lyub1)f)WF>B*Q+H%Zl7|=%QglWgX5Lr6z|IUJ!oM!cJ(AoM
zKc|J?q{?A>cv{ha(>m0c5-pf~E#?mNcbz#06L-_iefq%VXt
zvFoKCr(6P>O(~D+U@2`Pz=$0qT_~MM89WDD*gl)YZlKqXb3;h9^90+;D2d4#f-1q4
zNn~s=-#0s1&AS!ZJ)AX9!n6!7z-sI?E6_(haVqxJ^1CYhnxNLeYG2jlqE^IzDO9Is
z9IL0#s2OgxP9o_E1G8UtWhs^|GL5*_xvCjQK6s22@mHsh#}QAurY2vLQE*A%mh-ZG
zL}63kAs12yjHN0NYGV&8rvVQ?THKIc$pH
zj4=$diyLOIm^8&=)IqdgFH5+2JA8#9S&3Qd--<&hU1|PlHs6Ho(jgDRe`7zYgsqlG
z(-Q1PM*qe<6ypD6Q8#0^LsWF`cjDi$q86OywjPXwgNBW>yyB~4SU}M<$@zccoK{y?{M84Ue`n-o4emxrgRKBWa7I4Ui(sZEXH298S%tN9Ea
zye~QEEg+b|`%VngM2-Al?2Mve6pJY&hAhb)E~bKcoCW&d#buJix;}-G?2ALP_GIk3
zQOEZx`1(kpp42@Cfp_BA+d0?62izK9saKO1V|;yTlZ~Rd?P1-h7{uW(u*jdd2sbWY
z{n_z1d4=n=9nuKCR-7+|@yVms6NAgyw&d?WV5!RSAsB$XH7XBPLFtgc}HZI8poo!vu-=A1(lB1m?Iw)
zzOG=mPBKkz;Xu?|=)a3jgTUSMelN2vu6muwgLONK=$-pfz06hQ={&=~gN_(S9%HL+
zzqmd2?Q?o#C)iY)1tyFteR8o&2a~(Uw$FycT`l~_xvAqT?)|!PLSv%jlVPw)yGe49
z=;Z4kn#u=+`I7|{078_S#6Qja>BXgKHds`uTCbkB7qbyG(Ny8={7~$7iGhIm}{x4V54GOs&MH0Ksaer9Csa(6HBWoI<`+x1KGg(-TYY
z45x-LuNFlU4~xjnZmxP!+m!9pBlY&n%&X}Vo=_6bFqWL7y89wA;K>2|Qd(GWo0JKE
zJm~ZJfU3ITX3+(Q{QI`39T&u`N}d5UHI~ppGy+~LvdYv(KHS>y(