From 1d0aa5ac2ab1182f1f78c0e7028a9866498651a0 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Tue, 24 Mar 2026 23:08:27 -0400 Subject: [PATCH] fix: fold session manager blockers into one candidate --- commands/resume-session.md | 19 +- commands/save-session.md | 8 +- commands/sessions.md | 6 +- hooks/hooks.json | 2 +- manifests/install-modules.json | 3 +- scripts/codex/install-global-git-hooks.sh | 20 +- scripts/hooks/config-protection.js | 59 +++--- scripts/hooks/governance-capture.js | 78 ++++++-- scripts/hooks/mcp-health-check.js | 39 +++- scripts/hooks/run-with-flags.js | 52 ++++- scripts/hooks/session-start.js | 51 ++++- scripts/lib/resolve-ecc-root.js | 21 +- scripts/lib/session-manager.d.ts | 3 +- scripts/lib/session-manager.js | 168 ++++++++-------- scripts/lib/utils.d.ts | 17 +- scripts/lib/utils.js | 58 +++++- scripts/sync-ecc-to-codex.sh | 20 +- .../agents/observer-loop.sh | 10 +- tests/hooks/config-protection.test.js | 101 ++++++++++ tests/hooks/governance-capture.test.js | 66 +++++++ tests/hooks/hooks.test.js | 185 +++++++++++------- tests/hooks/mcp-health-check.test.js | 32 +++ tests/hooks/observer-memory.test.js | 18 ++ tests/integration/hooks.test.js | 14 +- tests/lib/resolve-ecc-root.test.js | 72 ++++++- tests/lib/session-manager.test.js | 19 +- tests/lib/utils.test.js | 134 ++++++++++--- tests/scripts/codex-hooks.test.js | 84 ++++++++ tests/scripts/install-apply.test.js | 3 + tests/scripts/sync-ecc-to-codex.test.js | 52 +++++ 30 files changed, 1126 insertions(+), 288 deletions(-) create mode 100644 tests/hooks/config-protection.test.js create mode 100644 tests/scripts/codex-hooks.test.js create mode 100644 tests/scripts/sync-ecc-to-codex.test.js diff --git a/commands/resume-session.md b/commands/resume-session.md index 5f84cf61..40e581f0 100644 --- a/commands/resume-session.md +++ b/commands/resume-session.md @@ -1,5 +1,5 @@ --- -description: Load the most recent session file from ~/.claude/sessions/ and resume work with full context from where the last session ended. +description: Load the most recent session file from ~/.claude/session-data/ and resume work with full context from where the last session ended. --- # Resume Session Command @@ -17,10 +17,10 @@ This command is the counterpart to `/save-session`. ## Usage ``` -/resume-session # loads most recent file in ~/.claude/sessions/ +/resume-session # loads most recent file in ~/.claude/session-data/ /resume-session 2024-01-15 # loads most recent session for that date -/resume-session ~/.claude/sessions/2024-01-15-session.tmp # loads a specific legacy-format file -/resume-session ~/.claude/sessions/2024-01-15-abc123de-session.tmp # loads a current short-id session file +/resume-session ~/.claude/session-data/2024-01-15-abc123de-session.tmp # loads a current short-id session file +/resume-session ~/.claude/sessions/2024-01-15-session.tmp # loads a specific legacy-format file ``` ## Process @@ -29,19 +29,20 @@ This command is the counterpart to `/save-session`. If no argument provided: -1. Check `~/.claude/sessions/` +1. Check `~/.claude/session-data/` 2. Pick the most recently modified `*-session.tmp` file 3. If the folder does not exist or has no matching files, tell the user: ``` - No session files found in ~/.claude/sessions/ + No session files found in ~/.claude/session-data/ Run /save-session at the end of a session to create one. ``` Then stop. If an argument is provided: -- If it looks like a date (`YYYY-MM-DD`), search `~/.claude/sessions/` for files matching - `YYYY-MM-DD-session.tmp` (legacy format) or `YYYY-MM-DD--session.tmp` (current format) +- If it looks like a date (`YYYY-MM-DD`), search `~/.claude/session-data/` first, then the legacy + `~/.claude/sessions/`, for files matching `YYYY-MM-DD-session.tmp` (legacy format) or + `YYYY-MM-DD--session.tmp` (current format) and load the most recently modified variant for that date - If it looks like a file path, read that file directly - If not found, report clearly and stop @@ -114,7 +115,7 @@ Report: "Session file found but appears empty or unreadable. You may need to cre ## Example Output ``` -SESSION LOADED: /Users/you/.claude/sessions/2024-01-15-abc123de-session.tmp +SESSION LOADED: /Users/you/.claude/session-data/2024-01-15-abc123de-session.tmp ════════════════════════════════════════════════ PROJECT: my-app — JWT Authentication diff --git a/commands/save-session.md b/commands/save-session.md index 676d74cd..d67a4e60 100644 --- a/commands/save-session.md +++ b/commands/save-session.md @@ -1,5 +1,5 @@ --- -description: Save current session state to a dated file in ~/.claude/sessions/ so work can be resumed in a future session with full context. +description: Save current session state to a dated file in ~/.claude/session-data/ so work can be resumed in a future session with full context. --- # Save Session Command @@ -29,12 +29,12 @@ Before writing the file, collect: Create the canonical sessions folder in the user's Claude home directory: ```bash -mkdir -p ~/.claude/sessions +mkdir -p ~/.claude/session-data ``` ### Step 3: Write the session file -Create `~/.claude/sessions/YYYY-MM-DD--session.tmp`, using today's actual date and a short-id that satisfies the rules enforced by `SESSION_FILENAME_REGEX` in `session-manager.js`: +Create `~/.claude/session-data/YYYY-MM-DD--session.tmp`, using today's actual date and a short-id that satisfies the rules enforced by `SESSION_FILENAME_REGEX` in `session-manager.js`: - Allowed characters: lowercase `a-z`, digits `0-9`, hyphens `-` - Minimum length: 8 characters @@ -271,5 +271,5 @@ Then test with Postman — the response should include a `Set-Cookie` header. - The "What Did NOT Work" section is the most critical — future sessions will blindly retry failed approaches without it - If the user asks to save mid-session (not just at the end), save what's known so far and mark in-progress items clearly - The file is meant to be read by Claude at the start of the next session via `/resume-session` -- Use the canonical global session store: `~/.claude/sessions/` +- Use the canonical global session store: `~/.claude/session-data/` - Prefer the short-id filename form (`YYYY-MM-DD--session.tmp`) for any new session file diff --git a/commands/sessions.md b/commands/sessions.md index 3bfb914d..cf31435b 100644 --- a/commands/sessions.md +++ b/commands/sessions.md @@ -4,7 +4,7 @@ description: Manage Claude Code session history, aliases, and session metadata. # Sessions Command -Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/sessions/`. +Manage Claude Code session history - list, load, alias, and edit sessions stored in `~/.claude/session-data/` with legacy reads from `~/.claude/sessions/`. ## Usage @@ -89,7 +89,7 @@ const size = sm.getSessionSize(session.sessionPath); const aliases = aa.getAliasesForSession(session.filename); console.log('Session: ' + session.filename); -console.log('Path: ~/.claude/sessions/' + session.filename); +console.log('Path: ' + session.sessionPath); console.log(''); console.log('Statistics:'); console.log(' Lines: ' + stats.lineCount); @@ -327,7 +327,7 @@ $ARGUMENTS: ## Notes -- Sessions are stored as markdown files in `~/.claude/sessions/` +- Sessions are stored as markdown files in `~/.claude/session-data/` with legacy reads from `~/.claude/sessions/` - Aliases are stored in `~/.claude/session-aliases.json` - Session IDs can be shortened (first 4-8 characters usually unique enough) - Use aliases for frequently referenced sessions diff --git a/hooks/hooks.json b/hooks/hooks.json index 2b38e94f..c66a9f4c 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -136,7 +136,7 @@ "hooks": [ { "type": "command", - "command": "bash -lc 'input=$(cat); for root in \"${CLAUDE_PLUGIN_ROOT:-}\" \"$HOME/.claude/plugins/everything-claude-code\" \"$HOME/.claude/plugins/everything-claude-code@everything-claude-code\" \"$HOME/.claude/plugins/marketplace/everything-claude-code\"; do if [ -n \"$root\" ] && [ -f \"$root/scripts/hooks/run-with-flags.js\" ]; then printf \"%s\" \"$input\" | node \"$root/scripts/hooks/run-with-flags.js\" \"session:start\" \"scripts/hooks/session-start.js\" \"minimal,standard,strict\"; exit $?; fi; done; for parent in \"$HOME/.claude/plugins\" \"$HOME/.claude/plugins/marketplace\"; do if [ -d \"$parent\" ]; then candidate=$(find \"$parent\" -maxdepth 2 -type f -path \"*/scripts/hooks/run-with-flags.js\" 2>/dev/null | head -n 1); if [ -n \"$candidate\" ]; then root=$(dirname \"$(dirname \"$(dirname \"$candidate\")\")\"); printf \"%s\" \"$input\" | node \"$root/scripts/hooks/run-with-flags.js\" \"session:start\" \"scripts/hooks/session-start.js\" \"minimal,standard,strict\"; exit $?; fi; fi; done; echo \"[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\" >&2; printf \"%s\" \"$input\"; exit 0'" + "command": "node -e \"const fs=require('fs');const path=require('path');const {spawnSync}=require('child_process');const raw=fs.readFileSync(0,'utf8');const root=(()=>{const envRoot=process.env.CLAUDE_PLUGIN_ROOT||'';if(envRoot.trim())return envRoot.trim();const home=require('os').homedir();const claudeDir=path.join(home,'.claude');const probe=path.join('scripts','lib','utils.js');if(fs.existsSync(path.join(claudeDir,probe)))return claudeDir;for(const candidate of [path.join(claudeDir,'plugins','everything-claude-code'),path.join(claudeDir,'plugins','everything-claude-code@everything-claude-code'),path.join(claudeDir,'plugins','marketplace','everything-claude-code')]){if(fs.existsSync(path.join(candidate,probe)))return candidate;}try{const cacheBase=path.join(claudeDir,'plugins','cache','everything-claude-code');for(const org of fs.readdirSync(cacheBase,{withFileTypes:true})){if(!org.isDirectory())continue;for(const version of fs.readdirSync(path.join(cacheBase,org.name),{withFileTypes:true})){if(!version.isDirectory())continue;const candidate=path.join(cacheBase,org.name,version.name);if(fs.existsSync(path.join(candidate,probe)))return candidate;}}}catch{}return claudeDir;})();const script=path.join(root,'scripts','hooks','run-with-flags.js');if(fs.existsSync(script)){const result=spawnSync(process.execPath,[script,'session:start','scripts/hooks/session-start.js','minimal,standard,strict'],{input:raw,encoding:'utf8',env:process.env,cwd:process.cwd(),timeout:30000});if(result.stdout)process.stdout.write(result.stdout);if(result.stderr)process.stderr.write(result.stderr);process.exit(Number.isInteger(result.status)?result.status:0);}process.stderr.write('[SessionStart] WARNING: could not resolve ECC plugin root; skipping session-start hook\\n');process.stdout.write(raw);\"" } ], "description": "Load previous context and detect package manager on new session" diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 2dbe5b50..71148d92 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -63,7 +63,8 @@ "description": "Runtime hook configs and hook script helpers.", "paths": [ "hooks", - "scripts/hooks" + "scripts/hooks", + "scripts/lib" ], "targets": [ "claude", diff --git a/scripts/codex/install-global-git-hooks.sh b/scripts/codex/install-global-git-hooks.sh index 9919d523..ea11d852 100644 --- a/scripts/codex/install-global-git-hooks.sh +++ b/scripts/codex/install-global-git-hooks.sh @@ -24,9 +24,11 @@ log() { run_or_echo() { if [[ "$MODE" == "dry-run" ]]; then - printf '[dry-run] %s\n' "$*" + printf '[dry-run]' + printf ' %q' "$@" + printf '\n' else - eval "$*" + "$@" fi } @@ -41,14 +43,14 @@ log "Global hooks destination: $DEST_DIR" if [[ -d "$DEST_DIR" ]]; then log "Backing up existing hooks directory to $BACKUP_DIR" - run_or_echo "mkdir -p \"$BACKUP_DIR\"" - run_or_echo "cp -R \"$DEST_DIR\" \"$BACKUP_DIR/hooks\"" + run_or_echo mkdir -p "$BACKUP_DIR" + run_or_echo cp -R "$DEST_DIR" "$BACKUP_DIR/hooks" fi -run_or_echo "mkdir -p \"$DEST_DIR\"" -run_or_echo "cp \"$SOURCE_DIR/pre-commit\" \"$DEST_DIR/pre-commit\"" -run_or_echo "cp \"$SOURCE_DIR/pre-push\" \"$DEST_DIR/pre-push\"" -run_or_echo "chmod +x \"$DEST_DIR/pre-commit\" \"$DEST_DIR/pre-push\"" +run_or_echo mkdir -p "$DEST_DIR" +run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit" +run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push" +run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push" if [[ "$MODE" == "apply" ]]; then prev_hooks_path="$(git config --global core.hooksPath || true)" @@ -56,7 +58,7 @@ if [[ "$MODE" == "apply" ]]; then log "Previous global hooksPath: $prev_hooks_path" fi fi -run_or_echo "git config --global core.hooksPath \"$DEST_DIR\"" +run_or_echo git config --global core.hooksPath "$DEST_DIR" log "Installed ECC global git hooks." log "Disable per repo by creating .ecc-hooks-disable in project root." diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index f5fbcf4a..8592542e 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -61,11 +61,34 @@ const PROTECTED_FILES = new Set([ '.markdownlintrc', ]); +function parseInput(inputOrRaw) { + if (typeof inputOrRaw === 'string') { + try { + return inputOrRaw.trim() ? JSON.parse(inputOrRaw) : {}; + } catch { + return {}; + } + } + + return inputOrRaw && typeof inputOrRaw === 'object' ? inputOrRaw : {}; +} + /** * Exportable run() for in-process execution via run-with-flags.js. * Avoids the ~50-100ms spawnSync overhead when available. */ -function run(input) { +function run(inputOrRaw, options = {}) { + if (options.truncated) { + return { + exitCode: 2, + stderr: + `BLOCKED: Hook input exceeded ${options.maxStdin || MAX_STDIN} bytes. ` + + 'Refusing to bypass config-protection on a truncated payload. ' + + 'Retry with a smaller edit or disable the config-protection hook temporarily.' + }; + } + + const input = parseInput(inputOrRaw); const filePath = input?.tool_input?.file_path || input?.tool_input?.file || ''; if (!filePath) return { exitCode: 0 }; @@ -75,9 +98,9 @@ function run(input) { exitCode: 2, stderr: `BLOCKED: Modifying ${basename} is not allowed. ` + - `Fix the source code to satisfy linter/formatter rules instead of ` + - `weakening the config. If this is a legitimate config change, ` + - `disable the config-protection hook temporarily.`, + 'Fix the source code to satisfy linter/formatter rules instead of ' + + 'weakening the config. If this is a legitimate config change, ' + + 'disable the config-protection hook temporarily.', }; } @@ -87,7 +110,7 @@ function run(input) { module.exports = { run }; // Stdin fallback for spawnSync execution -let truncated = false; +let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || '')); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { @@ -100,25 +123,17 @@ process.stdin.on('data', chunk => { }); process.stdin.on('end', () => { - // If stdin was truncated, the JSON is likely malformed. Fail open but - // log a warning so the issue is visible. The run() path (used by - // run-with-flags.js in-process) is not affected by this. - if (truncated) { - process.stderr.write('[config-protection] Warning: stdin exceeded 1MB, skipping check\n'); - process.stdout.write(raw); - return; + const result = run(raw, { + truncated, + maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN, + }); + + if (result.stderr) { + process.stderr.write(result.stderr + '\n'); } - try { - const input = raw.trim() ? JSON.parse(raw) : {}; - const result = run(input); - - if (result.exitCode === 2) { - process.stderr.write(result.stderr + '\n'); - process.exit(2); - } - } catch { - // Keep hook non-blocking on parse errors. + if (result.exitCode === 2) { + process.exit(2); } process.stdout.write(raw); diff --git a/scripts/hooks/governance-capture.js b/scripts/hooks/governance-capture.js index 0efec36c..b38187c2 100644 --- a/scripts/hooks/governance-capture.js +++ b/scripts/hooks/governance-capture.js @@ -10,6 +10,7 @@ * - policy_violation: Actions that violate configured policies * - security_finding: Security-relevant tool invocations * - approval_requested: Operations requiring explicit approval + * - hook_input_truncated: Hook input exceeded the safe inspection limit * * Enable: Set ECC_GOVERNANCE_CAPTURE=1 * Configure session: Set ECC_SESSION_ID for session correlation @@ -101,6 +102,37 @@ function detectSensitivePath(filePath) { return SENSITIVE_PATHS.some(pattern => pattern.test(filePath)); } +function fingerprintCommand(command) { + if (!command || typeof command !== 'string') return null; + return crypto.createHash('sha256').update(command).digest('hex').slice(0, 12); +} + +function summarizeCommand(command) { + if (!command || typeof command !== 'string') { + return { + commandName: null, + commandFingerprint: null, + }; + } + + const trimmed = command.trim(); + if (!trimmed) { + return { + commandName: null, + commandFingerprint: null, + }; + } + + return { + commandName: trimmed.split(/\s+/)[0] || null, + commandFingerprint: fingerprintCommand(trimmed), + }; +} + +function emitGovernanceEvent(event) { + process.stderr.write(`[governance] ${JSON.stringify(event)}\n`); +} + /** * Analyze a hook input payload and return governance events to capture. * @@ -146,6 +178,7 @@ function analyzeForGovernanceEvents(input, context = {}) { if (toolName === 'Bash') { const command = toolInput.command || ''; const approvalFindings = detectApprovalRequired(command); + const commandSummary = summarizeCommand(command); if (approvalFindings.length > 0) { events.push({ @@ -155,7 +188,7 @@ function analyzeForGovernanceEvents(input, context = {}) { payload: { toolName, hookPhase, - command: command.slice(0, 200), + ...commandSummary, matchedPatterns: approvalFindings.map(f => f.pattern), severity: 'high', }, @@ -188,6 +221,7 @@ function analyzeForGovernanceEvents(input, context = {}) { 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 commandSummary = summarizeCommand(command); if (hasElevated) { events.push({ @@ -197,7 +231,7 @@ function analyzeForGovernanceEvents(input, context = {}) { payload: { toolName, hookPhase, - command: command.slice(0, 200), + ...commandSummary, reason: 'elevated_privilege_command', severity: 'medium', }, @@ -216,16 +250,32 @@ function analyzeForGovernanceEvents(input, context = {}) { * @param {string} rawInput - Raw JSON string from stdin * @returns {string} The original input (pass-through) */ -function run(rawInput) { +function run(rawInput, options = {}) { // Gate on feature flag if (String(process.env.ECC_GOVERNANCE_CAPTURE || '').toLowerCase() !== '1') { return rawInput; } + const sessionId = process.env.ECC_SESSION_ID || null; + const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown'; + + if (options.truncated) { + emitGovernanceEvent({ + id: generateEventId(), + sessionId, + eventType: 'hook_input_truncated', + payload: { + hookPhase: hookPhase.startsWith('Pre') ? 'pre' : 'post', + sizeLimitBytes: options.maxStdin || MAX_STDIN, + severity: 'warning', + }, + resolvedAt: null, + resolution: null, + }); + } + try { const input = JSON.parse(rawInput); - const sessionId = process.env.ECC_SESSION_ID || null; - const hookPhase = process.env.CLAUDE_HOOK_EVENT_NAME || 'unknown'; const events = analyzeForGovernanceEvents(input, { sessionId, @@ -233,13 +283,8 @@ function run(rawInput) { }); if (events.length > 0) { - // Write events to stderr as JSON-lines for the caller to capture. - // The state store write is async and handled by a separate process - // to avoid blocking the hook pipeline. for (const event of events) { - process.stderr.write( - `[governance] ${JSON.stringify(event)}\n` - ); + emitGovernanceEvent(event); } } } catch { @@ -252,16 +297,25 @@ function run(rawInput) { // ── stdin entry point ──────────────────────────────── if (require.main === module) { let raw = ''; + let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || '')); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { const remaining = MAX_STDIN - raw.length; raw += chunk.substring(0, remaining); + if (chunk.length > remaining) { + truncated = true; + } + } else { + truncated = true; } }); process.stdin.on('end', () => { - const result = run(raw); + const result = run(raw, { + truncated, + maxStdin: Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN, + }); process.stdout.write(result); }); } diff --git a/scripts/hooks/mcp-health-check.js b/scripts/hooks/mcp-health-check.js index 22213418..80a535e2 100644 --- a/scripts/hooks/mcp-health-check.js +++ b/scripts/hooks/mcp-health-check.js @@ -99,15 +99,21 @@ function saveState(filePath, state) { function readRawStdin() { return new Promise(resolve => { let raw = ''; + let truncated = /^(1|true|yes)$/i.test(String(process.env.ECC_HOOK_INPUT_TRUNCATED || '')); process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { const remaining = MAX_STDIN - raw.length; raw += chunk.substring(0, remaining); + if (chunk.length > remaining) { + truncated = true; + } + } else { + truncated = true; } }); - process.stdin.on('end', () => resolve(raw)); - process.stdin.on('error', () => resolve(raw)); + process.stdin.on('end', () => resolve({ raw, truncated })); + process.stdin.on('error', () => resolve({ raw, truncated })); }); } @@ -155,6 +161,18 @@ function extractMcpTarget(input) { }; } +function extractMcpTargetFromRaw(raw) { + const toolNameMatch = raw.match(/"(?:tool_name|name)"\s*:\s*"([^"]+)"/); + const serverMatch = raw.match(/"(?:server|mcp_server|connector)"\s*:\s*"([^"]+)"/); + const toolMatch = raw.match(/"(?:tool|mcp_tool)"\s*:\s*"([^"]+)"/); + + return extractMcpTarget({ + tool_name: toolNameMatch ? toolNameMatch[1] : '', + server: serverMatch ? serverMatch[1] : undefined, + tool: toolMatch ? toolMatch[1] : undefined + }); +} + function resolveServerConfig(serverName) { for (const filePath of configPaths()) { const data = readJsonFile(filePath); @@ -559,9 +577,9 @@ async function handlePostToolUseFailure(rawInput, input, target, statePathValue, } async function main() { - const rawInput = await readRawStdin(); + const { raw: rawInput, truncated } = await readRawStdin(); const input = safeParse(rawInput); - const target = extractMcpTarget(input); + const target = extractMcpTarget(input) || (truncated ? extractMcpTargetFromRaw(rawInput) : null); if (!target) { process.stdout.write(rawInput); @@ -569,6 +587,19 @@ async function main() { return; } + if (truncated) { + const limit = Number(process.env.ECC_HOOK_INPUT_MAX_BYTES) || MAX_STDIN; + const logs = [ + shouldFailOpen() + ? `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; allowing ${target.tool || 'tool'} because fail-open mode is enabled` + : `[MCPHealthCheck] Hook input exceeded ${limit} bytes while checking ${target.server}; blocking ${target.tool || 'tool'} to avoid bypassing MCP health checks` + ]; + emitLogs(logs); + process.stdout.write(rawInput); + process.exit(shouldFailOpen() ? 0 : 2); + return; + } + const eventName = process.env.CLAUDE_HOOK_EVENT_NAME || 'PreToolUse'; const now = Date.now(); const statePathValue = stateFilePath(); diff --git a/scripts/hooks/run-with-flags.js b/scripts/hooks/run-with-flags.js index b665fe28..e2376eed 100755 --- a/scripts/hooks/run-with-flags.js +++ b/scripts/hooks/run-with-flags.js @@ -18,18 +18,54 @@ const MAX_STDIN = 1024 * 1024; function readStdinRaw() { return new Promise(resolve => { let raw = ''; + let truncated = false; process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { if (raw.length < MAX_STDIN) { const remaining = MAX_STDIN - raw.length; raw += chunk.substring(0, remaining); + if (chunk.length > remaining) { + truncated = true; + } + } else { + truncated = true; } }); - process.stdin.on('end', () => resolve(raw)); - process.stdin.on('error', () => resolve(raw)); + process.stdin.on('end', () => resolve({ raw, truncated })); + process.stdin.on('error', () => resolve({ raw, truncated })); }); } +function writeStderr(stderr) { + if (typeof stderr !== 'string' || stderr.length === 0) { + return; + } + + process.stderr.write(stderr.endsWith('\n') ? stderr : `${stderr}\n`); +} + +function emitHookResult(raw, output) { + if (typeof output === 'string' || Buffer.isBuffer(output)) { + process.stdout.write(String(output)); + return 0; + } + + if (output && typeof output === 'object') { + writeStderr(output.stderr); + + if (Object.prototype.hasOwnProperty.call(output, 'stdout')) { + process.stdout.write(String(output.stdout ?? '')); + } else if (!Number.isInteger(output.exitCode) || output.exitCode === 0) { + process.stdout.write(raw); + } + + return Number.isInteger(output.exitCode) ? output.exitCode : 0; + } + + process.stdout.write(raw); + return 0; +} + function getPluginRoot() { if (process.env.CLAUDE_PLUGIN_ROOT && process.env.CLAUDE_PLUGIN_ROOT.trim()) { return process.env.CLAUDE_PLUGIN_ROOT; @@ -39,7 +75,7 @@ function getPluginRoot() { async function main() { const [, , hookId, relScriptPath, profilesCsv] = process.argv; - const raw = await readStdinRaw(); + const { raw, truncated } = await readStdinRaw(); if (!hookId || !relScriptPath) { process.stdout.write(raw); @@ -89,8 +125,8 @@ async function main() { if (hookModule && typeof hookModule.run === 'function') { try { - const output = hookModule.run(raw); - if (output !== null && output !== undefined) process.stdout.write(output); + const output = hookModule.run(raw, { truncated, maxStdin: MAX_STDIN }); + process.exit(emitHookResult(raw, output)); } catch (runErr) { process.stderr.write(`[Hook] run() error for ${hookId}: ${runErr.message}\n`); process.stdout.write(raw); @@ -102,7 +138,11 @@ async function main() { const result = spawnSync('node', [scriptPath], { input: raw, encoding: 'utf8', - env: process.env, + env: { + ...process.env, + ECC_HOOK_INPUT_TRUNCATED: truncated ? '1' : '0', + ECC_HOOK_INPUT_MAX_BYTES: String(MAX_STDIN) + }, cwd: process.cwd(), timeout: 30000 }); diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index 9f949616..ac57dc9d 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -11,13 +11,13 @@ const { getSessionsDir, + getSessionSearchDirs, getLearnedSkillsDir, findFiles, ensureDir, readFile, stripAnsi, - log, - output + log } = require('../lib/utils'); const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager'); const { listAliases } = require('../lib/session-aliases'); @@ -26,13 +26,16 @@ const { detectProjectType } = require('../lib/project-detect'); async function main() { const sessionsDir = getSessionsDir(); const learnedDir = getLearnedSkillsDir(); + const additionalContextParts = []; // Ensure directories exist ensureDir(sessionsDir); ensureDir(learnedDir); // Check for recent session files (last 7 days) - const recentSessions = findFiles(sessionsDir, '*-session.tmp', { maxAge: 7 }); + const recentSessions = getSessionSearchDirs() + .flatMap(dir => findFiles(dir, '*-session.tmp', { maxAge: 7 })) + .sort((a, b) => b.mtime - a.mtime); if (recentSessions.length > 0) { const latest = recentSessions[0]; @@ -43,7 +46,7 @@ async function main() { const content = stripAnsi(readFile(latest.path)); if (content && !content.includes('[Session context goes here]')) { // Only inject if the session has actual content (not the blank template) - output(`Previous session summary:\n${content}`); + additionalContextParts.push(`Previous session summary:\n${content}`); } } @@ -84,15 +87,49 @@ async function main() { parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`); } log(`[SessionStart] Project detected — ${parts.join('; ')}`); - output(`Project type: ${JSON.stringify(projectInfo)}`); + additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`); } else { log('[SessionStart] No specific project type detected'); } - process.exit(0); + await writeSessionStartPayload(additionalContextParts.join('\n\n')); +} + +function writeSessionStartPayload(additionalContext) { + return new Promise((resolve, reject) => { + let settled = false; + const payload = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'SessionStart', + additionalContext + } + }); + + const handleError = (err) => { + if (settled) return; + settled = true; + if (err) { + log(`[SessionStart] stdout write error: ${err.message}`); + } + reject(err || new Error('stdout stream error')); + }; + + process.stdout.once('error', handleError); + process.stdout.write(payload, (err) => { + process.stdout.removeListener('error', handleError); + if (settled) return; + settled = true; + if (err) { + log(`[SessionStart] stdout write error: ${err.message}`); + reject(err); + return; + } + resolve(); + }); + }); } main().catch(err => { console.error('[SessionStart] Error:', err.message); - process.exit(0); // Don't block on errors + process.exitCode = 0; // Don't block on errors }); diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 848bcbf8..c282a263 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -10,8 +10,9 @@ const os = require('os'); * Tries, in order: * 1. CLAUDE_PLUGIN_ROOT env var (set by Claude Code for hooks, or by user) * 2. Standard install location (~/.claude/) — when scripts exist there - * 3. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/ - * 4. Fallback to ~/.claude/ (original behaviour) + * 3. Exact legacy plugin roots under ~/.claude/plugins/ + * 4. Plugin cache auto-detection — scans ~/.claude/plugins/cache/everything-claude-code/ + * 5. Fallback to ~/.claude/ (original behaviour) * * @param {object} [options] * @param {string} [options.homeDir] Override home directory (for testing) @@ -38,6 +39,20 @@ function resolveEccRoot(options = {}) { return claudeDir; } + // Exact legacy plugin install locations. These preserve backwards + // compatibility without scanning arbitrary plugin trees. + const legacyPluginRoots = [ + path.join(claudeDir, 'plugins', 'everything-claude-code'), + path.join(claudeDir, 'plugins', 'everything-claude-code@everything-claude-code'), + path.join(claudeDir, 'plugins', 'marketplace', 'everything-claude-code') + ]; + + for (const candidate of legacyPluginRoots) { + if (fs.existsSync(path.join(candidate, probe))) { + return candidate; + } + } + // Plugin cache — Claude Code stores marketplace plugins under // ~/.claude/plugins/cache//// try { @@ -81,7 +96,7 @@ function resolveEccRoot(options = {}) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b))for(var v of f.readdirSync(p.join(b,o))){var c=p.join(b,o,v);if(f.existsSync(p.join(c,q)))return c}}catch(x){}return d})()`; +const INLINE_RESOLVE = `(()=>{var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var p=require('path'),f=require('fs'),h=require('os').homedir(),d=p.join(h,'.claude'),q=p.join('scripts','lib','utils.js');if(f.existsSync(p.join(d,q)))return d;for(var l of [p.join(d,'plugins','everything-claude-code'),p.join(d,'plugins','everything-claude-code@everything-claude-code'),p.join(d,'plugins','marketplace','everything-claude-code')])if(f.existsSync(p.join(l,q)))return l;try{var b=p.join(d,'plugins','cache','everything-claude-code');for(var o of f.readdirSync(b,{withFileTypes:true})){if(!o.isDirectory())continue;for(var v of f.readdirSync(p.join(b,o.name),{withFileTypes:true})){if(!v.isDirectory())continue;var c=p.join(b,o.name,v.name);if(f.existsSync(p.join(c,q)))return c}}}catch(x){}return d})()`; module.exports = { resolveEccRoot, diff --git a/scripts/lib/session-manager.d.ts b/scripts/lib/session-manager.d.ts index 7fbbc695..5c90c424 100644 --- a/scripts/lib/session-manager.d.ts +++ b/scripts/lib/session-manager.d.ts @@ -1,6 +1,7 @@ /** * Session Manager Library for Claude Code. - * Provides CRUD operations for session files stored as markdown in ~/.claude/sessions/. + * Provides CRUD operations for session files stored as markdown in + * ~/.claude/session-data/ with legacy read compatibility for ~/.claude/sessions/. */ /** Parsed metadata from a session filename */ diff --git a/scripts/lib/session-manager.js b/scripts/lib/session-manager.js index e206af3c..e057e774 100644 --- a/scripts/lib/session-manager.js +++ b/scripts/lib/session-manager.js @@ -2,7 +2,8 @@ * Session Manager Library for Claude Code * Provides core session CRUD operations for listing, loading, and managing sessions * - * Sessions are stored as markdown files in ~/.claude/sessions/ with format: + * Sessions are stored as markdown files in ~/.claude/session-data/ with + * legacy read compatibility for ~/.claude/sessions/: * - YYYY-MM-DD-session.tmp (old format) * - YYYY-MM-DD--session.tmp (new format) */ @@ -12,6 +13,7 @@ const path = require('path'); const { getSessionsDir, + getSessionSearchDirs, readFile, log } = require('./utils'); @@ -30,6 +32,7 @@ const SESSION_FILENAME_REGEX = /^(\d{4}-\d{2}-\d{2})(?:-([a-zA-Z0-9_][a-zA-Z0-9_ * @returns {object|null} Parsed metadata or null if invalid */ function parseSessionFilename(filename) { + if (!filename || typeof filename !== 'string') return null; const match = filename.match(SESSION_FILENAME_REGEX); if (!match) return null; @@ -66,6 +69,72 @@ function getSessionPath(filename) { return path.join(getSessionsDir(), filename); } +function getSessionCandidates(options = {}) { + const { + date = null, + search = null + } = options; + + const candidates = []; + + for (const sessionsDir of getSessionSearchDirs()) { + if (!fs.existsSync(sessionsDir)) { + continue; + } + + let entries; + try { + entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; + + const filename = entry.name; + const metadata = parseSessionFilename(filename); + + if (!metadata) continue; + if (date && metadata.date !== date) continue; + if (search && !metadata.shortId.includes(search)) continue; + + const sessionPath = path.join(sessionsDir, filename); + + let stats; + try { + stats = fs.statSync(sessionPath); + } catch { + continue; + } + + candidates.push({ + ...metadata, + sessionPath, + hasContent: stats.size > 0, + size: stats.size, + modifiedTime: stats.mtime, + createdTime: stats.birthtime || stats.ctime + }); + } + } + + candidates.sort((a, b) => b.modifiedTime - a.modifiedTime); + + const deduped = []; + const seenFilenames = new Set(); + + for (const session of candidates) { + if (seenFilenames.has(session.filename)) { + continue; + } + seenFilenames.add(session.filename); + deduped.push(session); + } + + return deduped; +} + /** * Read and parse session markdown content * @param {string} sessionPath - Full path to session file @@ -228,58 +297,12 @@ function getAllSessions(options = {}) { const limitNum = Number(rawLimit); const limit = Number.isNaN(limitNum) ? 50 : Math.max(1, Math.floor(limitNum)); - const sessionsDir = getSessionsDir(); + const sessions = getSessionCandidates({ date, search }); - if (!fs.existsSync(sessionsDir)) { + if (sessions.length === 0) { return { sessions: [], total: 0, offset, limit, hasMore: false }; } - const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - const sessions = []; - - for (const entry of entries) { - // Skip non-files (only process .tmp files) - if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; - - const filename = entry.name; - const metadata = parseSessionFilename(filename); - - if (!metadata) continue; - - // Apply date filter - if (date && metadata.date !== date) { - continue; - } - - // Apply search filter (search in short ID) - if (search && !metadata.shortId.includes(search)) { - continue; - } - - const sessionPath = path.join(sessionsDir, filename); - - // Get file stats (wrapped in try-catch to handle TOCTOU race where - // file is deleted between readdirSync and statSync) - let stats; - try { - stats = fs.statSync(sessionPath); - } catch { - continue; // File was deleted between readdir and stat - } - - sessions.push({ - ...metadata, - sessionPath, - hasContent: stats.size > 0, - size: stats.size, - modifiedTime: stats.mtime, - createdTime: stats.birthtime || stats.ctime - }); - } - - // Sort by modified time (newest first) - sessions.sort((a, b) => b.modifiedTime - a.modifiedTime); - // Apply pagination const paginatedSessions = sessions.slice(offset, offset + limit); @@ -299,21 +322,16 @@ function getAllSessions(options = {}) { * @returns {object|null} Session object or null if not found */ function getSessionById(sessionId, includeContent = false) { - const sessionsDir = getSessionsDir(); + const sessions = getSessionCandidates(); - if (!fs.existsSync(sessionsDir)) { - return null; - } - - const entries = fs.readdirSync(sessionsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isFile() || !entry.name.endsWith('.tmp')) continue; - - const filename = entry.name; - const metadata = parseSessionFilename(filename); - - if (!metadata) continue; + for (const session of sessions) { + const filename = session.filename; + const metadata = { + filename: session.filename, + shortId: session.shortId, + date: session.date, + datetime: session.datetime + }; // Check if session ID matches (short ID or full filename without .tmp) const shortIdMatch = sessionId.length > 0 && metadata.shortId !== 'no-id' && metadata.shortId.startsWith(sessionId); @@ -324,30 +342,16 @@ function getSessionById(sessionId, includeContent = false) { continue; } - const sessionPath = path.join(sessionsDir, filename); - let stats; - try { - stats = fs.statSync(sessionPath); - } catch { - return null; // File was deleted between readdir and stat - } - - const session = { - ...metadata, - sessionPath, - size: stats.size, - modifiedTime: stats.mtime, - createdTime: stats.birthtime || stats.ctime - }; + const sessionRecord = { ...session }; if (includeContent) { - session.content = getSessionContent(sessionPath); - session.metadata = parseSessionMetadata(session.content); + sessionRecord.content = getSessionContent(sessionRecord.sessionPath); + sessionRecord.metadata = parseSessionMetadata(sessionRecord.content); // Pass pre-read content to avoid a redundant disk read - session.stats = getSessionStats(session.content || ''); + sessionRecord.stats = getSessionStats(sessionRecord.content || ''); } - return session; + return sessionRecord; } return null; diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 7d3cadff..55d27621 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -18,9 +18,15 @@ export function getHomeDir(): string; /** Get the Claude config directory (~/.claude) */ export function getClaudeDir(): string; -/** Get the sessions directory (~/.claude/sessions) */ +/** Get the canonical ECC sessions directory (~/.claude/session-data) */ export function getSessionsDir(): string; +/** Get the legacy Claude-managed sessions directory (~/.claude/sessions) */ +export function getLegacySessionsDir(): string; + +/** Get session directories to search, with canonical storage first and legacy fallback second */ +export function getSessionSearchDirs(): string[]; + /** Get the learned skills directory (~/.claude/skills/learned) */ export function getLearnedSkillsDir(): string; @@ -47,9 +53,16 @@ export function getDateTimeString(): string; // --- Session/Project --- +/** + * Sanitize a string for use as a session filename segment. + * Replaces invalid characters, strips leading dots, and returns null when + * nothing meaningful remains. Non-ASCII names are hashed for stability. + */ +export function sanitizeSessionId(raw: string | null | undefined): string | null; + /** * Get short session ID from CLAUDE_SESSION_ID environment variable. - * Returns last 8 characters, falls back to project name then the provided fallback. + * Returns last 8 characters, falls back to a sanitized project name then the provided fallback. */ export function getSessionIdShort(fallback?: string): string; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index a3086258..e41b244c 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -6,6 +6,7 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); +const crypto = require('crypto'); const { execSync, spawnSync } = require('child_process'); // Platform detection @@ -31,9 +32,23 @@ function getClaudeDir() { * Get the sessions directory */ function getSessionsDir() { + return path.join(getClaudeDir(), 'session-data'); +} + +/** + * Get the legacy sessions directory used by older ECC installs + */ +function getLegacySessionsDir() { return path.join(getClaudeDir(), 'sessions'); } +/** + * Get all session directories to search, in canonical-first order + */ +function getSessionSearchDirs() { + return Array.from(new Set([getSessionsDir(), getLegacySessionsDir()])); +} + /** * Get the learned skills directory */ @@ -107,16 +122,50 @@ function getProjectName() { return path.basename(process.cwd()) || null; } +/** + * Sanitize a string for use as a session filename segment. + * Replaces invalid characters with hyphens, collapses runs, strips + * leading/trailing hyphens, and removes leading dots so hidden-dir names + * like ".claude" map cleanly to "claude". + * + * Pure non-ASCII inputs get a stable 8-char hash so distinct names do not + * collapse to the same fallback session id. Mixed-script inputs retain their + * ASCII part and gain a short hash suffix for disambiguation. + */ +function sanitizeSessionId(raw) { + if (!raw || typeof raw !== 'string') return null; + + const hasNonAscii = /[^\x00-\x7F]/.test(raw); + const normalized = raw.replace(/^\.+/, ''); + const sanitized = normalized + .replace(/[^a-zA-Z0-9_-]/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); + + if (sanitized.length > 0) { + if (!hasNonAscii) return sanitized; + + const suffix = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 6); + return `${sanitized}-${suffix}`; + } + + const meaningful = normalized.replace(/[\s\p{P}]/gu, ''); + if (meaningful.length === 0) return null; + + return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8); +} + /** * Get short session ID from CLAUDE_SESSION_ID environment variable - * Returns last 8 characters, falls back to project name then 'default' + * Returns last 8 characters, falls back to a sanitized project name then 'default'. */ function getSessionIdShort(fallback = 'default') { const sessionId = process.env.CLAUDE_SESSION_ID; if (sessionId && sessionId.length > 0) { - return sessionId.slice(-8); + const sanitized = sanitizeSessionId(sessionId.slice(-8)); + if (sanitized) return sanitized; } - return getProjectName() || fallback; + return sanitizeSessionId(getProjectName()) || sanitizeSessionId(fallback) || 'default'; } /** @@ -525,6 +574,8 @@ module.exports = { getHomeDir, getClaudeDir, getSessionsDir, + getLegacySessionsDir, + getSessionSearchDirs, getLearnedSkillsDir, getTempDir, ensureDir, @@ -535,6 +586,7 @@ module.exports = { getDateTimeString, // Session/Project + sanitizeSessionId, getSessionIdShort, getGitRepoName, getProjectName, diff --git a/scripts/sync-ecc-to-codex.sh b/scripts/sync-ecc-to-codex.sh index 90db5caa..393ae4de 100644 --- a/scripts/sync-ecc-to-codex.sh +++ b/scripts/sync-ecc-to-codex.sh @@ -43,9 +43,11 @@ log() { printf '[ecc-sync] %s\n' "$*"; } run_or_echo() { if [[ "$MODE" == "dry-run" ]]; then - printf '[dry-run] %s\n' "$*" + printf '[dry-run]' + printf ' %q' "$@" + printf '\n' else - eval "$@" + "$@" fi } @@ -149,10 +151,10 @@ log "Repo root: $REPO_ROOT" log "Codex home: $CODEX_HOME" log "Creating backup folder: $BACKUP_DIR" -run_or_echo "mkdir -p \"$BACKUP_DIR\"" -run_or_echo "cp \"$CONFIG_FILE\" \"$BACKUP_DIR/config.toml\"" +run_or_echo mkdir -p "$BACKUP_DIR" +run_or_echo cp "$CONFIG_FILE" "$BACKUP_DIR/config.toml" if [[ -f "$AGENTS_FILE" ]]; then - run_or_echo "cp \"$AGENTS_FILE\" \"$BACKUP_DIR/AGENTS.md\"" + run_or_echo cp "$AGENTS_FILE" "$BACKUP_DIR/AGENTS.md" fi ECC_BEGIN_MARKER="" @@ -234,19 +236,19 @@ else fi log "Syncing ECC Codex skills" -run_or_echo "mkdir -p \"$SKILLS_DEST\"" +run_or_echo mkdir -p "$SKILLS_DEST" skills_count=0 for skill_dir in "$SKILLS_SRC"/*; do [[ -d "$skill_dir" ]] || continue skill_name="$(basename "$skill_dir")" dest="$SKILLS_DEST/$skill_name" - run_or_echo "rm -rf \"$dest\"" - run_or_echo "cp -R \"$skill_dir\" \"$dest\"" + run_or_echo rm -rf "$dest" + run_or_echo cp -R "$skill_dir" "$dest" skills_count=$((skills_count + 1)) done log "Generating prompt files from ECC commands" -run_or_echo "mkdir -p \"$PROMPTS_DEST\"" +run_or_echo mkdir -p "$PROMPTS_DEST" manifest="$PROMPTS_DEST/ecc-prompts-manifest.txt" if [[ "$MODE" == "dry-run" ]]; then printf '[dry-run] > %s\n' "$manifest" diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index 8a7f90b9..c8d02470 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -55,15 +55,18 @@ analyze_observations() { # Sample recent observations instead of loading the entire file (#521). # This prevents multi-MB payloads from being passed to the LLM. MAX_ANALYSIS_LINES="${ECC_OBSERVER_MAX_ANALYSIS_LINES:-500}" - analysis_file="$(mktemp "${TMPDIR:-/tmp}/ecc-observer-analysis.XXXXXX.jsonl")" + observer_tmp_dir="${PROJECT_DIR}/.observer-tmp" + mkdir -p "$observer_tmp_dir" + analysis_file="$(mktemp "${observer_tmp_dir}/ecc-observer-analysis.XXXXXX.jsonl")" tail -n "$MAX_ANALYSIS_LINES" "$OBSERVATIONS_FILE" > "$analysis_file" 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" - prompt_file="$(mktemp "${TMPDIR:-/tmp}/ecc-observer-prompt.XXXXXX")" + prompt_file="$(mktemp "${observer_tmp_dir}/ecc-observer-prompt.XXXXXX")" cat > "$prompt_file" <.md. +If you find 3+ occurrences of the same pattern, you MUST write an instinct file directly to ${INSTINCTS_DIR}/.md using the Write tool. +Do NOT ask for permission to write files, do NOT describe what you would write, and do NOT stop at analysis when a qualifying pattern exists. CRITICAL: Every instinct file MUST use this exact format: @@ -92,6 +95,7 @@ Rules: - Be conservative, only clear patterns with 3+ observations - Use narrow, specific triggers - Never include actual code snippets, only describe patterns +- When a qualifying pattern exists, write or update the instinct file in this run instead of asking for confirmation - If a similar instinct already exists in ${INSTINCTS_DIR}/, update it instead of creating a duplicate - The YAML frontmatter (between --- markers) with id field is MANDATORY - If a pattern seems universal (not project-specific), set scope to global instead of project diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js new file mode 100644 index 00000000..049090ff --- /dev/null +++ b/tests/hooks/config-protection.test.js @@ -0,0 +1,101 @@ +/** + * Tests for scripts/hooks/config-protection.js via run-with-flags.js + */ + +const assert = require('assert'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const runner = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'run-with-flags.js'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runHook(input, env = {}) { + const rawInput = typeof input === 'string' ? input : JSON.stringify(input); + const result = spawnSync('node', [runner, 'pre:config-protection', 'scripts/hooks/config-protection.js', 'standard,strict'], { + input: rawInput, + encoding: 'utf8', + env: { + ...process.env, + ECC_HOOK_PROFILE: 'standard', + ...env + }, + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + return { + code: result.status ?? 0, + stdout: result.stdout || '', + stderr: result.stderr || '' + }; +} + +function runTests() { + console.log('\n=== Testing config-protection ===\n'); + + let passed = 0; + let failed = 0; + + if (test('blocks protected config file edits through run-with-flags', () => { + const input = { + tool_name: 'Write', + tool_input: { + file_path: '.eslintrc.js', + content: 'module.exports = {};' + } + }; + + const result = runHook(input); + assert.strictEqual(result.code, 2, 'Expected protected config edit to be blocked'); + assert.strictEqual(result.stdout, '', 'Blocked hook should not echo raw input'); + assert.ok(result.stderr.includes('BLOCKED: Modifying .eslintrc.js is not allowed.'), `Expected block message, got: ${result.stderr}`); + })) passed++; else failed++; + + if (test('passes through safe file edits unchanged', () => { + const input = { + tool_name: 'Write', + tool_input: { + file_path: 'src/index.js', + content: 'console.log("ok");' + } + }; + + const rawInput = JSON.stringify(input); + const result = runHook(input); + assert.strictEqual(result.code, 0, 'Expected safe file edit to pass'); + assert.strictEqual(result.stdout, rawInput, 'Expected exact raw JSON passthrough'); + assert.strictEqual(result.stderr, '', 'Expected no stderr for safe edits'); + })) passed++; else failed++; + + if (test('blocks truncated protected config payloads instead of failing open', () => { + const rawInput = JSON.stringify({ + tool_name: 'Write', + tool_input: { + file_path: '.eslintrc.js', + content: 'x'.repeat(1024 * 1024 + 2048) + } + }); + + const result = runHook(rawInput); + assert.strictEqual(result.code, 2, 'Expected truncated protected payload to be blocked'); + assert.strictEqual(result.stdout, '', 'Blocked truncated payload should not echo raw input'); + assert.ok(result.stderr.includes('Hook input exceeded 1048576 bytes'), `Expected size warning, got: ${result.stderr}`); + assert.ok(result.stderr.includes('truncated payload'), `Expected truncated payload warning, got: ${result.stderr}`); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); \ No newline at end of file diff --git a/tests/hooks/governance-capture.test.js b/tests/hooks/governance-capture.test.js index d7b11e40..1618e594 100644 --- a/tests/hooks/governance-capture.test.js +++ b/tests/hooks/governance-capture.test.js @@ -156,6 +156,35 @@ async function runTests() { assert.strictEqual(approvalEvent.payload.severity, 'high'); })) passed += 1; else failed += 1; + if (await test('approval events fingerprint commands instead of storing raw command text', async () => { + const command = 'git push origin main --force'; + const events = analyzeForGovernanceEvents({ + tool_name: 'Bash', + tool_input: { command }, + }); + + const approvalEvent = events.find(e => e.eventType === 'approval_requested'); + assert.ok(approvalEvent); + assert.strictEqual(approvalEvent.payload.commandName, 'git'); + 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'); + })) passed += 1; else failed += 1; + + if (await test('security findings fingerprint elevated commands instead of storing raw command text', async () => { + const command = 'sudo chmod 600 ~/.ssh/id_rsa'; + const events = analyzeForGovernanceEvents({ + tool_name: 'Bash', + tool_input: { command }, + }, { + hookPhase: 'post', + }); + + const securityEvent = events.find(e => e.eventType === 'security_finding'); + assert.ok(securityEvent); + assert.strictEqual(securityEvent.payload.commandName, 'sudo'); + 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('analyzeForGovernanceEvents detects sensitive file access', async () => { const events = analyzeForGovernanceEvents({ tool_name: 'Edit', @@ -273,6 +302,43 @@ async function runTests() { } })) passed += 1; else failed += 1; + if (await test('run() emits hook_input_truncated event without logging raw command text', async () => { + const original = process.env.ECC_GOVERNANCE_CAPTURE; + const originalHookEvent = process.env.CLAUDE_HOOK_EVENT_NAME; + const originalWrite = process.stderr.write; + const stderr = []; + process.env.ECC_GOVERNANCE_CAPTURE = '1'; + process.env.CLAUDE_HOOK_EVENT_NAME = 'PreToolUse'; + process.stderr.write = (chunk, encoding, callback) => { + stderr.push(String(chunk)); + if (typeof encoding === 'function') encoding(); + if (typeof callback === 'function') callback(); + return true; + }; + + try { + const input = JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'rm -rf /tmp/important' } }); + const result = run(input, { truncated: true, maxStdin: 1024 }); + assert.strictEqual(result, input); + } finally { + process.stderr.write = originalWrite; + if (original !== undefined) { + process.env.ECC_GOVERNANCE_CAPTURE = original; + } else { + delete process.env.ECC_GOVERNANCE_CAPTURE; + } + if (originalHookEvent !== undefined) { + process.env.CLAUDE_HOOK_EVENT_NAME = originalHookEvent; + } else { + delete process.env.CLAUDE_HOOK_EVENT_NAME; + } + } + + const combined = stderr.join(''); + assert.ok(combined.includes('\"eventType\":\"hook_input_truncated\"'), 'Should emit truncation event'); + assert.ok(combined.includes('\"sizeLimitBytes\":1024'), 'Should record the truncation limit'); + assert.ok(!combined.includes('rm -rf /tmp/important'), 'Should not leak raw command text to governance logs'); + })) passed += 1; else failed += 1; if (await test('run() can detect multiple event types in one input', async () => { // Bash command with force push AND secret in command const events = analyzeForGovernanceEvents({ diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 3fe58f07..2837271c 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -82,6 +82,25 @@ function sleepMs(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } +function getCanonicalSessionsDir(homeDir) { + return path.join(homeDir, '.claude', 'session-data'); +} + +function getLegacySessionsDir(homeDir) { + return path.join(homeDir, '.claude', 'sessions'); +} + +function getSessionStartAdditionalContext(stdout) { + if (!stdout.trim()) { + return ''; + } + + const payload = JSON.parse(stdout); + assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart', 'Should emit SessionStart hook payload'); + assert.strictEqual(typeof payload.hookSpecificOutput?.additionalContext, 'string', 'Should include additionalContext text'); + return payload.hookSpecificOutput.additionalContext; +} + // Test helper function test(name, fn) { try { @@ -336,7 +355,7 @@ async function runTests() { if ( await asyncTest('exits 0 even with isolated empty HOME', async () => { const isoHome = path.join(os.tmpdir(), `ecc-iso-start-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -364,7 +383,7 @@ async function runTests() { if ( await asyncTest('skips template session content', async () => { const isoHome = path.join(os.tmpdir(), `ecc-tpl-start-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getLegacySessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -378,8 +397,8 @@ async function runTests() { USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); - // stdout should NOT contain the template content - assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject template session content'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject template session content'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -391,7 +410,7 @@ async function runTests() { if ( await asyncTest('injects real session content', async () => { const isoHome = path.join(os.tmpdir(), `ecc-real-start-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getLegacySessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -405,8 +424,9 @@ async function runTests() { USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content'); - assert.ok(result.stdout.includes('authentication refactor'), 'Should include session content text'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content'); + assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -418,7 +438,7 @@ async function runTests() { if ( await asyncTest('strips ANSI escape codes from injected session content', async () => { const isoHome = path.join(os.tmpdir(), `ecc-ansi-start-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getLegacySessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -434,9 +454,10 @@ async function runTests() { USERPROFILE: isoHome }); assert.strictEqual(result.code, 0); - assert.ok(result.stdout.includes('Previous session summary'), 'Should inject real session content'); - assert.ok(result.stdout.includes('Windows terminal handling'), 'Should preserve sanitized session text'); - assert.ok(!result.stdout.includes('\x1b['), 'Should not emit ANSI escape codes'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes('Previous session summary'), 'Should inject real session content'); + assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text'); + assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -450,7 +471,7 @@ async function runTests() { const isoHome = path.join(os.tmpdir(), `ecc-skills-start-${Date.now()}`); const learnedDir = path.join(isoHome, '.claude', 'skills', 'learned'); fs.mkdirSync(learnedDir, { recursive: true }); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); // Create learned skill files fs.writeFileSync(path.join(learnedDir, 'testing-patterns.md'), '# Testing'); @@ -548,7 +569,7 @@ async function runTests() { // Check if session file was created // Note: Without CLAUDE_SESSION_ID, falls back to project/worktree name (not 'default') // Use local time to match the script's getDateString() function - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; @@ -581,7 +602,7 @@ async function runTests() { // Check if session file was created with session ID // Use local time to match the script's getDateString() function - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; const sessionFile = path.join(sessionsDir, `${today}-${expectedShortId}-session.tmp`); @@ -614,7 +635,7 @@ async function runTests() { const now = new Date(); const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - const sessionFile = path.join(isoHome, '.claude', 'sessions', `${today}-${expectedShortId}-session.tmp`); + const sessionFile = path.join(getCanonicalSessionsDir(isoHome), `${today}-${expectedShortId}-session.tmp`); const content = fs.readFileSync(sessionFile, 'utf8'); assert.ok(content.includes(`**Project:** ${project}`), 'Should persist project metadata'); @@ -652,7 +673,7 @@ async function runTests() { if ( await asyncTest('creates compaction log', async () => { await runScript(path.join(scriptsDir, 'pre-compact.js')); - const logFile = path.join(os.homedir(), '.claude', 'sessions', 'compaction-log.txt'); + const logFile = path.join(getCanonicalSessionsDir(os.homedir()), 'compaction-log.txt'); assert.ok(fs.existsSync(logFile), 'Compaction log should exist'); }) ) @@ -662,7 +683,7 @@ async function runTests() { if ( await asyncTest('annotates active session file with compaction marker', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-annotate-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create an active .tmp session file @@ -688,7 +709,7 @@ async function runTests() { if ( await asyncTest('compaction log contains timestamp', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-ts-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); try { @@ -1544,7 +1565,7 @@ async function runTests() { assert.strictEqual(result.code, 0, 'Should handle backticks without crash'); // Find the session file in the temp HOME - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1579,7 +1600,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1613,7 +1634,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1648,7 +1669,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1686,7 +1707,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1723,7 +1744,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1757,7 +1778,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1800,7 +1821,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -1873,9 +1894,8 @@ async function runTests() { const isNpx = hook.command.startsWith('npx '); const isSkillScript = hook.command.includes('/skills/') && (/^(bash|sh)\s/.test(hook.command) || hook.command.startsWith('${CLAUDE_PLUGIN_ROOT}/skills/')); const isHookShellWrapper = /^(bash|sh)\s+["']?\$\{CLAUDE_PLUGIN_ROOT\}\/scripts\/hooks\/run-with-flags-shell\.sh/.test(hook.command); - const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); assert.ok( - isNode || isNpx || isSkillScript || isHookShellWrapper || isSessionStartFallback, + isNode || isNpx || isSkillScript || isHookShellWrapper, `Hook command should use node or approved shell wrapper: ${hook.command.substring(0, 100)}...` ); } @@ -1892,7 +1912,26 @@ async function runTests() { else failed++; if ( - test('script references use CLAUDE_PLUGIN_ROOT variable (except SessionStart fallback)', () => { + test('SessionStart hook uses safe inline resolver without plugin-tree scanning', () => { + const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); + const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); + const sessionStartHook = hooks.hooks.SessionStart?.[0]?.hooks?.[0]; + + assert.ok(sessionStartHook, 'Should define a SessionStart hook'); + assert.ok(sessionStartHook.command.startsWith('node -e "'), 'SessionStart should use inline node resolver'); + assert.ok(sessionStartHook.command.includes('session:start'), 'SessionStart should invoke the session:start profile'); + assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code'"), 'Should probe the exact legacy plugin root'); + assert.ok(sessionStartHook.command.includes("plugins','everything-claude-code@everything-claude-code'"), 'Should probe the namespaced legacy plugin root'); + assert.ok(sessionStartHook.command.includes("plugins','marketplace','everything-claude-code'"), 'Should probe the marketplace legacy plugin root'); + assert.ok(sessionStartHook.command.includes("plugins','cache','everything-claude-code'"), 'Should retain cache lookup fallback'); + assert.ok(!sessionStartHook.command.includes('find '), 'Should not scan arbitrary plugin paths with find'); + assert.ok(!sessionStartHook.command.includes('head -n 1'), 'Should not pick the first matching plugin path'); + }) + ) + passed++; + else failed++; + if ( + test('script references use CLAUDE_PLUGIN_ROOT variable or safe SessionStart inline resolver', () => { const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json'); const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8')); @@ -1901,8 +1940,8 @@ async function runTests() { for (const hook of entry.hooks) { if (hook.type === 'command' && hook.command.includes('scripts/hooks/')) { // Check for the literal string "${CLAUDE_PLUGIN_ROOT}" in the command - const isSessionStartFallback = hook.command.startsWith('bash -lc') && hook.command.includes('run-with-flags.js'); - const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartFallback; + const isSessionStartInlineResolver = hook.command.startsWith('node -e') && hook.command.includes('session:start') && hook.command.includes('run-with-flags.js'); + const hasPluginRoot = hook.command.includes('${CLAUDE_PLUGIN_ROOT}') || isSessionStartInlineResolver; assert.ok(hasPluginRoot, `Script paths should use CLAUDE_PLUGIN_ROOT: ${hook.command.substring(0, 80)}...`); } } @@ -2766,7 +2805,7 @@ async function runTests() { if ( await asyncTest('updates Last Updated timestamp in existing session file', async () => { const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(testDir); fs.mkdirSync(sessionsDir, { recursive: true }); // Get the expected filename @@ -2798,7 +2837,7 @@ async function runTests() { if ( await asyncTest('normalizes existing session headers with project, branch, and worktree metadata', async () => { const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(testDir); fs.mkdirSync(sessionsDir, { recursive: true }); const utils = require('../../scripts/lib/utils'); @@ -2831,7 +2870,7 @@ async function runTests() { if ( await asyncTest('replaces blank template with summary when updating existing file', async () => { const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(testDir); fs.mkdirSync(sessionsDir, { recursive: true }); const utils = require('../../scripts/lib/utils'); @@ -2869,7 +2908,7 @@ async function runTests() { if ( await asyncTest('always updates session summary content on session end', async () => { const testDir = createTestDir(); - const sessionsDir = path.join(testDir, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(testDir); fs.mkdirSync(sessionsDir, { recursive: true }); const utils = require('../../scripts/lib/utils'); @@ -2906,7 +2945,7 @@ async function runTests() { if ( await asyncTest('only annotates *-session.tmp files, not other .tmp files', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-glob-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create a session .tmp file and a non-session .tmp file @@ -2937,7 +2976,7 @@ async function runTests() { if ( await asyncTest('handles no active session files gracefully', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-nosession-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); try { @@ -2976,7 +3015,7 @@ async function runTests() { assert.strictEqual(result.code, 0); // With no user messages, extractSessionSummary returns null → blank template - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -3016,7 +3055,7 @@ async function runTests() { }); assert.strictEqual(result.code, 0); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -3192,7 +3231,7 @@ async function runTests() { if ( await asyncTest('exits 0 with empty sessions directory (no recent sessions)', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-empty-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -3201,7 +3240,8 @@ async function runTests() { }); assert.strictEqual(result.code, 0, 'Should exit 0 with no sessions'); // Should NOT inject any previous session data (stdout should be empty or minimal) - assert.ok(!result.stdout.includes('Previous session summary'), 'Should not inject when no sessions'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes('Previous session summary'), 'Should not inject when no sessions'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -3213,7 +3253,7 @@ async function runTests() { if ( await asyncTest('does not inject blank template session into context', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-blank-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -3229,7 +3269,8 @@ async function runTests() { }); assert.strictEqual(result.code, 0); // Should NOT inject blank template - assert.ok(!result.stdout.includes('Previous session summary'), 'Should skip blank template sessions'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes('Previous session summary'), 'Should skip blank template sessions'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -3825,7 +3866,7 @@ async function runTests() { if ( await asyncTest('annotates only the newest session file when multiple exist', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-multi-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create two session files with different mtimes @@ -3877,7 +3918,7 @@ async function runTests() { assert.strictEqual(result.code, 0); // Find the session file and verify newlines were collapsed - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -3903,7 +3944,7 @@ async function runTests() { if ( await asyncTest('does not inject empty session file content into context', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-empty-file-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -3919,7 +3960,8 @@ async function runTests() { }); assert.strictEqual(result.code, 0, 'Should exit 0 with empty session file'); // readFile returns '' (falsy) → the if (content && ...) guard skips injection - assert.ok(!result.stdout.includes('Previous session summary'), 'Should NOT inject empty string into context'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes('Previous session summary'), 'Should NOT inject empty string into context'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -3963,7 +4005,7 @@ async function runTests() { if ( await asyncTest('summary omits Files Modified and Tools Used when none found', async () => { const isoHome = path.join(os.tmpdir(), `ecc-notools-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const testDir = createTestDir(); @@ -4001,7 +4043,7 @@ async function runTests() { if ( await asyncTest('reports available session aliases on startup', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-alias-${Date.now()}`); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); // Pre-populate the aliases file @@ -4038,7 +4080,7 @@ async function runTests() { if ( await asyncTest('parallel compaction runs all append to log without loss', async () => { const isoHome = path.join(os.tmpdir(), `ecc-compact-par-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); try { @@ -4073,7 +4115,7 @@ async function runTests() { const isoHome = path.join(os.tmpdir(), `ecc-start-blocked-${Date.now()}`); fs.mkdirSync(path.join(isoHome, '.claude'), { recursive: true }); // Block sessions dir creation by placing a file at that path - fs.writeFileSync(path.join(isoHome, '.claude', 'sessions'), 'blocked'); + fs.writeFileSync(getCanonicalSessionsDir(isoHome), 'blocked'); try { const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', { @@ -4136,7 +4178,7 @@ async function runTests() { if ( await asyncTest('excludes session files older than 7 days', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-7day-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -4159,8 +4201,9 @@ async function runTests() { }); assert.strictEqual(result.code, 0); assert.ok(result.stderr.includes('1 recent session'), `Should find 1 recent session (6.9-day included, 8-day excluded), stderr: ${result.stderr}`); - assert.ok(result.stdout.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content'); - assert.ok(!result.stdout.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes('RECENT CONTENT HERE'), 'Should inject the 6.9-day-old session content'); + assert.ok(!additionalContext.includes('OLD CONTENT SHOULD NOT APPEAR'), 'Should NOT inject the 8-day-old session content'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -4174,7 +4217,7 @@ async function runTests() { if ( await asyncTest('injects newest session when multiple recent sessions exist', async () => { const isoHome = path.join(os.tmpdir(), `ecc-start-multi-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); @@ -4198,7 +4241,8 @@ async function runTests() { assert.strictEqual(result.code, 0); assert.ok(result.stderr.includes('2 recent session'), `Should find 2 recent sessions, stderr: ${result.stderr}`); // Should inject the NEWER session, not the older one - assert.ok(result.stdout.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(additionalContext.includes('NEWER_CONTEXT_MARKER'), 'Should inject the newest session content'); } finally { fs.rmSync(isoHome, { recursive: true, force: true }); } @@ -4305,7 +4349,7 @@ async function runTests() { return; } const isoHome = path.join(os.tmpdir(), `ecc-start-unreadable-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create a session file with real content, then make it unreadable @@ -4320,7 +4364,8 @@ async function runTests() { }); assert.strictEqual(result.code, 0, 'Should exit 0 even with unreadable session file'); // readFile returns null for unreadable files → content is null → no injection - assert.ok(!result.stdout.includes('Sensitive session content'), 'Should NOT inject content from unreadable file'); + const additionalContext = getSessionStartAdditionalContext(result.stdout); + assert.ok(!additionalContext.includes('Sensitive session content'), 'Should NOT inject content from unreadable file'); } finally { try { fs.chmodSync(sessionFile, 0o644); @@ -4366,7 +4411,7 @@ async function runTests() { return; } const isoHome = path.join(os.tmpdir(), `ecc-compact-ro-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create a session file then make it read-only @@ -4407,7 +4452,7 @@ async function runTests() { if ( await asyncTest('logs warning when existing session file lacks Last Updated field', async () => { const isoHome = path.join(os.tmpdir(), `ecc-end-nots-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); // Create transcript with a user message so a summary is produced @@ -4498,7 +4543,7 @@ async function runTests() { if ( await asyncTest('extracts user messages from role-only format (no type field)', async () => { const isoHome = path.join(os.tmpdir(), `ecc-role-only-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const testDir = createTestDir(); @@ -4534,7 +4579,7 @@ async function runTests() { if ( await asyncTest('logs "Transcript not found" for nonexistent transcript_path', async () => { const isoHome = path.join(os.tmpdir(), `ecc-notfound-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const stdinJson = JSON.stringify({ transcript_path: '/tmp/nonexistent-transcript-99999.jsonl' }); @@ -4563,7 +4608,7 @@ async function runTests() { if ( await asyncTest('extracts tool name and file path from entry.name/entry.input (not tool_name/tool_input)', async () => { const isoHome = path.join(os.tmpdir(), `ecc-r70-entryname-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const transcriptPath = path.join(isoHome, 'transcript.jsonl'); @@ -4611,7 +4656,7 @@ async function runTests() { await asyncTest('shows selection prompt when no package manager preference found (default source)', async () => { const isoHome = path.join(os.tmpdir(), `ecc-r71-ss-default-${Date.now()}`); const isoProject = path.join(isoHome, 'project'); - fs.mkdirSync(path.join(isoHome, '.claude', 'sessions'), { recursive: true }); + fs.mkdirSync(getCanonicalSessionsDir(isoHome), { recursive: true }); fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true }); fs.mkdirSync(isoProject, { recursive: true }); // No package.json, no lock files, no package-manager.json — forces default source @@ -4758,7 +4803,7 @@ async function runTests() { if ( await asyncTest('extracts user messages from entries where only message.role is user (not type or role)', async () => { const isoHome = path.join(os.tmpdir(), `ecc-msgrole-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const testDir = createTestDir(); @@ -4825,7 +4870,7 @@ async function runTests() { // session-end.js line 50-55: rawContent is checked for string, then array, else '' // When content is a number (42), neither branch matches, text = '', message is skipped. const isoHome = path.join(os.tmpdir(), `ecc-r81-numcontent-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const transcriptPath = path.join(isoHome, 'transcript.jsonl'); @@ -4874,7 +4919,7 @@ async function runTests() { if ( await asyncTest('collects tool name from entry with tool_name but non-tool_use type', async () => { const isoHome = path.join(os.tmpdir(), `ecc-r82-toolname-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const transcriptPath = path.join(isoHome, 'transcript.jsonl'); @@ -4912,7 +4957,7 @@ async function runTests() { if ( await asyncTest('preserves file when marker present but regex does not match corrupted template', async () => { const isoHome = path.join(os.tmpdir(), `ecc-r82-tmpl-${Date.now()}`); - const sessionsDir = path.join(isoHome, '.claude', 'sessions'); + const sessionsDir = getCanonicalSessionsDir(isoHome); fs.mkdirSync(sessionsDir, { recursive: true }); const today = new Date().toISOString().split('T')[0]; @@ -5072,7 +5117,7 @@ Some random content without the expected ### Context to Load section assert.strictEqual(result.code, 0, 'Should exit 0'); // Read the session file to verify tool names and file paths were extracted - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { @@ -5193,7 +5238,7 @@ Some random content without the expected ### Context to Load section }); assert.strictEqual(result.code, 0, 'Should exit 0'); - const claudeDir = path.join(testDir, '.claude', 'sessions'); + const claudeDir = getCanonicalSessionsDir(testDir); if (fs.existsSync(claudeDir)) { const files = fs.readdirSync(claudeDir).filter(f => f.endsWith('.tmp')); if (files.length > 0) { diff --git a/tests/hooks/mcp-health-check.test.js b/tests/hooks/mcp-health-check.test.js index 1d12da33..4404002a 100644 --- a/tests/hooks/mcp-health-check.test.js +++ b/tests/hooks/mcp-health-check.test.js @@ -79,6 +79,25 @@ function runHook(input, env = {}) { }; } +function runRawHook(rawInput, env = {}) { + const result = spawnSync('node', [script], { + input: rawInput, + encoding: 'utf8', + env: { + ...process.env, + ECC_HOOK_PROFILE: 'standard', + ...env + }, + timeout: 15000, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + return { + code: result.status || 0, + stdout: result.stdout || '', + stderr: result.stderr || '' + }; +} async function runTests() { console.log('\n=== Testing mcp-health-check.js ===\n'); @@ -95,6 +114,19 @@ async function runTests() { assert.strictEqual(result.stderr, '', 'Expected no stderr for non-MCP tool'); })) passed++; else failed++; + if (test('blocks truncated MCP hook input by default', () => { + const rawInput = JSON.stringify({ tool_name: 'mcp__flaky__search', tool_input: {} }); + const result = runRawHook(rawInput, { + CLAUDE_HOOK_EVENT_NAME: 'PreToolUse', + ECC_HOOK_INPUT_TRUNCATED: '1', + ECC_HOOK_INPUT_MAX_BYTES: '512' + }); + + assert.strictEqual(result.code, 2, 'Expected truncated MCP input to block by default'); + assert.strictEqual(result.stdout, rawInput, 'Expected raw input passthrough on stdout'); + assert.ok(result.stderr.includes('Hook input exceeded 512 bytes'), `Expected size warning, got: ${result.stderr}`); + assert.ok(/blocking search/i.test(result.stderr), `Expected blocking message, got: ${result.stderr}`); + })) passed++; else failed++; if (await asyncTest('marks healthy command MCP servers and allows the tool call', async () => { const tempDir = createTempDir(); const configPath = path.join(tempDir, 'claude.json'); diff --git a/tests/hooks/observer-memory.test.js b/tests/hooks/observer-memory.test.js index c441f436..47ffe61d 100644 --- a/tests/hooks/observer-memory.test.js +++ b/tests/hooks/observer-memory.test.js @@ -148,6 +148,24 @@ test('analysis temp file is created and cleaned up', () => { assert.ok(content.includes('rm -f "$prompt_file" "$analysis_file"'), 'Should clean up both prompt and analysis temp files'); }); +test('observer-loop uses project-local temp directory for analysis artifacts', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + assert.ok(content.includes('observer_tmp_dir="${PROJECT_DIR}/.observer-tmp"'), 'Should keep observer temp files inside the project'); + assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-analysis.'), 'Analysis temp file should use the project temp dir'); + assert.ok(content.includes('mktemp "${observer_tmp_dir}/ecc-observer-prompt.'), 'Prompt temp file should use the project temp dir'); +}); + +test('observer-loop prompt requires direct instinct writes without asking permission', () => { + const content = fs.readFileSync(observerLoopPath, 'utf8'); + const heredocStart = content.indexOf('cat > "$prompt_file" < 0, 'Should find prompt heredoc start'); + assert.ok(heredocEnd > heredocStart, 'Should find prompt heredoc end'); + const promptSection = content.substring(heredocStart, heredocEnd); + assert.ok(promptSection.includes('MUST write an instinct file directly'), 'Prompt should require direct file creation'); + assert.ok(promptSection.includes('Do NOT ask for permission'), 'Prompt should forbid permission-seeking'); + assert.ok(promptSection.includes('write or update the instinct file in this run'), 'Prompt should require same-run writes'); +}); test('prompt references analysis_file not full OBSERVATIONS_FILE', () => { const content = fs.readFileSync(observerLoopPath, 'utf8'); // The prompt heredoc should reference analysis_file for the Read instruction. diff --git a/tests/integration/hooks.test.js b/tests/integration/hooks.test.js index 180b9e0e..45c8895d 100644 --- a/tests/integration/hooks.test.js +++ b/tests/integration/hooks.test.js @@ -90,6 +90,14 @@ function runHookWithInput(scriptPath, input = {}, env = {}, timeoutMs = 10000) { }); } +function getSessionStartPayload(stdout) { + if (!stdout.trim()) { + return null; + } + + return JSON.parse(stdout); +} + /** * Run a hook command string exactly as declared in hooks.json. * Supports wrapped node script commands and shell wrappers. @@ -249,11 +257,15 @@ async function runTests() { // ========================================== console.log('\nHook Output Format:'); - if (await asyncTest('hooks output messages to stderr (not stdout)', async () => { + if (await asyncTest('session-start logs diagnostics to stderr and emits structured stdout when context exists', async () => { const result = await runHookWithInput(path.join(scriptsDir, 'session-start.js'), {}); // Session-start should write info to stderr assert.ok(result.stderr.length > 0, 'Should have stderr output'); assert.ok(result.stderr.includes('[SessionStart]'), 'Should have [SessionStart] prefix'); + if (result.stdout.trim()) { + const payload = getSessionStartPayload(result.stdout); + assert.strictEqual(payload.hookSpecificOutput?.hookEventName, 'SessionStart'); + } })) passed++; else failed++; if (await asyncTest('PreCompact hook logs to stderr', async () => { diff --git a/tests/lib/resolve-ecc-root.test.js b/tests/lib/resolve-ecc-root.test.js index d5c09f5e..282ee2bd 100644 --- a/tests/lib/resolve-ecc-root.test.js +++ b/tests/lib/resolve-ecc-root.test.js @@ -4,8 +4,9 @@ * Covers the ECC root resolution fallback chain: * 1. CLAUDE_PLUGIN_ROOT env var * 2. Standard install (~/.claude/) - * 3. Plugin cache auto-detection - * 4. Fallback to ~/.claude/ + * 3. Exact legacy plugin roots under ~/.claude/plugins/ + * 4. Plugin cache auto-detection + * 5. Fallback to ~/.claude/ */ const assert = require('assert'); @@ -39,6 +40,13 @@ function setupStandardInstall(homeDir) { return claudeDir; } +function setupLegacyPluginInstall(homeDir, segments) { + const legacyDir = path.join(homeDir, '.claude', 'plugins', ...segments); + const scriptDir = path.join(legacyDir, 'scripts', 'lib'); + fs.mkdirSync(scriptDir, { recursive: true }); + fs.writeFileSync(path.join(scriptDir, 'utils.js'), '// stub'); + return legacyDir; +} function setupPluginCache(homeDir, orgName, version) { const cacheDir = path.join( homeDir, '.claude', 'plugins', 'cache', @@ -103,6 +111,50 @@ function runTests() { } })) passed++; else failed++; + if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code', () => { + const homeDir = createTempDir(); + try { + const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('finds exact legacy plugin install at ~/.claude/plugins/everything-claude-code@everything-claude-code', () => { + const homeDir = createTempDir(); + try { + const expected = setupLegacyPluginInstall(homeDir, ['everything-claude-code@everything-claude-code']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('finds marketplace legacy plugin install at ~/.claude/plugins/marketplace/everything-claude-code', () => { + const homeDir = createTempDir(); + try { + const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + if (test('prefers exact legacy plugin install over plugin cache', () => { + const homeDir = createTempDir(); + try { + const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']); + setupPluginCache(homeDir, 'everything-claude-code', '1.8.0'); + const result = resolveEccRoot({ envRoot: '', homeDir }); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; // ─── Plugin Cache Auto-Detection ─── if (test('discovers plugin root from cache directory', () => { @@ -207,6 +259,22 @@ function runTests() { assert.strictEqual(result, '/inline/test/root'); })) passed++; else failed++; + if (test('INLINE_RESOLVE discovers exact legacy plugin root when env var is unset', () => { + const homeDir = createTempDir(); + try { + const expected = setupLegacyPluginInstall(homeDir, ['marketplace', 'everything-claude-code']); + const { execFileSync } = require('child_process'); + const result = execFileSync('node', [ + '-e', `console.log(${INLINE_RESOLVE})`, + ], { + env: { PATH: process.env.PATH, HOME: homeDir, USERPROFILE: homeDir }, + encoding: 'utf8', + }).trim(); + assert.strictEqual(result, expected); + } finally { + fs.rmSync(homeDir, { recursive: true, force: true }); + } + })) passed++; else failed++; if (test('INLINE_RESOLVE discovers plugin cache when env var is unset', () => { const homeDir = createTempDir(); try { diff --git a/tests/lib/session-manager.test.js b/tests/lib/session-manager.test.js index be9012ee..50fe2d66 100644 --- a/tests/lib/session-manager.test.js +++ b/tests/lib/session-manager.test.js @@ -990,7 +990,7 @@ src/main.ts assert.ok(result.endsWith(filename), `Path should end with filename, got: ${result}`); // Since HOME is overridden, sessions dir should be under tmpHome assert.ok(result.includes('.claude'), 'Path should include .claude directory'); - assert.ok(result.includes('sessions'), 'Path should include sessions directory'); + assert.ok(result.includes('session-data'), 'Path should use canonical session-data directory'); })) passed++; else failed++; // ── Round 66: getSessionById noIdMatch path (date-only string for old format) ── @@ -1629,18 +1629,13 @@ src/main.ts // best-effort } - // ── Round 98: parseSessionFilename with null input throws TypeError ── - console.log('\nRound 98: parseSessionFilename (null input — crashes at line 30):'); + // ── Round 98: parseSessionFilename with null input returns null ── + console.log('\nRound 98: parseSessionFilename (null input is safely rejected):'); - if (test('parseSessionFilename(null) throws TypeError because null has no .match()', () => { - // session-manager.js line 30: `filename.match(SESSION_FILENAME_REGEX)` - // When filename is null, null.match() throws TypeError. - // Function lacks a type guard like `if (!filename || typeof filename !== 'string')`. - assert.throws( - () => sessionManager.parseSessionFilename(null), - { name: 'TypeError' }, - 'null.match() should throw TypeError (no type guard on filename parameter)' - ); + if (test('parseSessionFilename(null) returns null instead of throwing', () => { + assert.strictEqual(sessionManager.parseSessionFilename(null), null); + assert.strictEqual(sessionManager.parseSessionFilename(undefined), null); + assert.strictEqual(sessionManager.parseSessionFilename(123), null); })) passed++; else failed++; // ── Round 99: writeSessionContent with null path returns false (error caught) ── diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index b7a26ead..d05bbc18 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -7,6 +7,7 @@ const assert = require('assert'); const path = require('path'); const fs = require('fs'); +const { spawnSync } = require('child_process'); // Import the module const utils = require('../../scripts/lib/utils'); @@ -68,7 +69,13 @@ function runTests() { const sessionsDir = utils.getSessionsDir(); const claudeDir = utils.getClaudeDir(); assert.ok(sessionsDir.startsWith(claudeDir), 'Sessions should be under Claude dir'); - assert.ok(sessionsDir.includes('sessions'), 'Should contain sessions'); + assert.ok(sessionsDir.endsWith(path.join('.claude', 'session-data')) || sessionsDir.endsWith('/.claude/session-data'), 'Should use canonical session-data directory'); + })) passed++; else failed++; + + if (test('getSessionSearchDirs includes canonical and legacy paths', () => { + const searchDirs = utils.getSessionSearchDirs(); + assert.ok(searchDirs.includes(utils.getSessionsDir()), 'Should include canonical session dir'); + assert.ok(searchDirs.includes(utils.getLegacySessionsDir()), 'Should include legacy session dir'); })) passed++; else failed++; if (test('getTempDir returns valid temp directory', () => { @@ -118,17 +125,77 @@ function runTests() { assert.ok(name && name.length > 0); })) passed++; else failed++; + // sanitizeSessionId tests + console.log('\nsanitizeSessionId:'); + + if (test('sanitizeSessionId strips leading dots', () => { + assert.strictEqual(utils.sanitizeSessionId('.claude'), 'claude'); + })) passed++; else failed++; + + if (test('sanitizeSessionId replaces dots and spaces', () => { + assert.strictEqual(utils.sanitizeSessionId('my.project'), 'my-project'); + assert.strictEqual(utils.sanitizeSessionId('my project'), 'my-project'); + })) passed++; else failed++; + + if (test('sanitizeSessionId replaces special chars and collapses runs', () => { + assert.strictEqual(utils.sanitizeSessionId('project@v2'), 'project-v2'); + assert.strictEqual(utils.sanitizeSessionId('a...b'), 'a-b'); + })) passed++; else failed++; + + if (test('sanitizeSessionId preserves valid chars', () => { + assert.strictEqual(utils.sanitizeSessionId('my-project_123'), 'my-project_123'); + })) passed++; else failed++; + + if (test('sanitizeSessionId returns null for empty or punctuation-only values', () => { + assert.strictEqual(utils.sanitizeSessionId(''), null); + assert.strictEqual(utils.sanitizeSessionId(null), null); + assert.strictEqual(utils.sanitizeSessionId(undefined), null); + assert.strictEqual(utils.sanitizeSessionId('...'), null); + assert.strictEqual(utils.sanitizeSessionId('…'), null); + })) passed++; else failed++; + + if (test('sanitizeSessionId returns stable hashes for non-ASCII values', () => { + const chinese = utils.sanitizeSessionId('我的项目'); + const cyrillic = utils.sanitizeSessionId('проект'); + const emoji = utils.sanitizeSessionId('🚀🎉'); + assert.ok(/^[a-f0-9]{8}$/.test(chinese), `Expected 8-char hash, got: ${chinese}`); + assert.ok(/^[a-f0-9]{8}$/.test(cyrillic), `Expected 8-char hash, got: ${cyrillic}`); + assert.ok(/^[a-f0-9]{8}$/.test(emoji), `Expected 8-char hash, got: ${emoji}`); + assert.notStrictEqual(chinese, cyrillic); + assert.notStrictEqual(chinese, emoji); + assert.strictEqual(utils.sanitizeSessionId('日本語プロジェクト'), utils.sanitizeSessionId('日本語プロジェクト')); + })) passed++; else failed++; + + if (test('sanitizeSessionId disambiguates mixed-script names from pure ASCII', () => { + const mixed = utils.sanitizeSessionId('我的app'); + const mixedTwo = utils.sanitizeSessionId('他的app'); + const pure = utils.sanitizeSessionId('app'); + assert.strictEqual(pure, 'app'); + assert.ok(mixed.startsWith('app-'), `Expected mixed-script prefix, got: ${mixed}`); + assert.notStrictEqual(mixed, pure); + assert.notStrictEqual(mixed, mixedTwo); + })) passed++; else failed++; + + if (test('sanitizeSessionId is idempotent', () => { + for (const input of ['.claude', 'my.project', 'project@v2', 'a...b', 'my-project_123']) { + const once = utils.sanitizeSessionId(input); + const twice = utils.sanitizeSessionId(once); + assert.strictEqual(once, twice, `Expected idempotent result for ${input}`); + } + })) passed++; else failed++; + // Session ID tests console.log('\nSession ID Functions:'); - if (test('getSessionIdShort falls back to project name', () => { + if (test('getSessionIdShort falls back to sanitized project name', () => { const original = process.env.CLAUDE_SESSION_ID; delete process.env.CLAUDE_SESSION_ID; try { const shortId = utils.getSessionIdShort(); - assert.strictEqual(shortId, utils.getProjectName()); + assert.strictEqual(shortId, utils.sanitizeSessionId(utils.getProjectName())); } finally { - if (original) process.env.CLAUDE_SESSION_ID = original; + if (original !== undefined) process.env.CLAUDE_SESSION_ID = original; + else delete process.env.CLAUDE_SESSION_ID; } })) passed++; else failed++; @@ -154,6 +221,28 @@ function runTests() { } })) passed++; else failed++; + if (test('getSessionIdShort sanitizes explicit fallback parameter', () => { + if (process.platform === 'win32') { + console.log(' (skipped — root CWD differs on Windows)'); + return true; + } + + const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js'); + const script = ` + const utils = require('${utilsPath.replace(/'/g, "\\'")}'); + process.stdout.write(utils.getSessionIdShort('my.fallback')); + `; + const result = spawnSync('node', ['-e', script], { + encoding: 'utf8', + cwd: '/', + env: { ...process.env, CLAUDE_SESSION_ID: '' }, + timeout: 10000 + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, 'my-fallback'); + })) passed++; else failed++; + // File operations tests console.log('\nFile Operations:'); @@ -1415,25 +1504,26 @@ function runTests() { // ── Round 97: getSessionIdShort with whitespace-only CLAUDE_SESSION_ID ── console.log('\nRound 97: getSessionIdShort (whitespace-only session ID):'); - if (test('getSessionIdShort returns whitespace when CLAUDE_SESSION_ID is all spaces', () => { - // utils.js line 116: if (sessionId && sessionId.length > 0) — ' ' is truthy - // and has length > 0, so it passes the check instead of falling back. - const original = process.env.CLAUDE_SESSION_ID; - try { - process.env.CLAUDE_SESSION_ID = ' '; // 10 spaces - const result = utils.getSessionIdShort('fallback'); - // slice(-8) on 10 spaces returns 8 spaces — not the expected fallback - assert.strictEqual(result, ' ', - 'Whitespace-only ID should return 8 trailing spaces (no trim check)'); - assert.strictEqual(result.trim().length, 0, - 'Result should be entirely whitespace (demonstrating the missing trim)'); - } finally { - if (original !== undefined) { - process.env.CLAUDE_SESSION_ID = original; - } else { - delete process.env.CLAUDE_SESSION_ID; - } + if (test('getSessionIdShort sanitizes whitespace-only CLAUDE_SESSION_ID to fallback', () => { + if (process.platform === 'win32') { + console.log(' (skipped — root CWD differs on Windows)'); + return true; } + + const utilsPath = path.join(__dirname, '..', '..', 'scripts', 'lib', 'utils.js'); + const script = ` + const utils = require('${utilsPath.replace(/'/g, "\\'")}'); + process.stdout.write(utils.getSessionIdShort('fallback')); + `; + const result = spawnSync('node', ['-e', script], { + encoding: 'utf8', + cwd: '/', + env: { ...process.env, CLAUDE_SESSION_ID: ' ' }, + timeout: 10000 + }); + + assert.strictEqual(result.status, 0, `Expected exit 0, got ${result.status}. stderr: ${result.stderr}`); + assert.strictEqual(result.stdout, 'fallback'); })) passed++; else failed++; // ── Round 97: countInFile with same RegExp object called twice (lastIndex reuse) ── diff --git a/tests/scripts/codex-hooks.test.js b/tests/scripts/codex-hooks.test.js new file mode 100644 index 00000000..482e7428 --- /dev/null +++ b/tests/scripts/codex-hooks.test.js @@ -0,0 +1,84 @@ +/** + * Tests for Codex shell helpers. + */ + +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 installScript = path.join(repoRoot, 'scripts', 'codex', 'install-global-git-hooks.sh'); +const installSource = fs.readFileSync(installScript, 'utf8'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function createTempDir(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function cleanup(dirPath) { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function runBash(scriptPath, args = [], env = {}, cwd = repoRoot) { + return spawnSync('bash', [scriptPath, ...args], { + cwd, + env: { + ...process.env, + ...env + }, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }); +} + +let passed = 0; +let failed = 0; + +if ( + test('install-global-git-hooks.sh does not use eval and executes argv directly', () => { + assert.ok(!installSource.includes('eval "$*"'), 'Expected installer to avoid eval'); + assert.ok(installSource.includes(' "$@"'), 'Expected installer to execute argv directly'); + assert.ok(installSource.includes(`printf ' %q' "$@"`), 'Expected dry-run logging to shell-escape argv'); + }) +) + passed++; +else failed++; + +if ( + test('install-global-git-hooks.sh handles quoted hook paths without shell injection', () => { + const homeDir = createTempDir('codex-hooks-home-'); + const weirdHooksDir = path.join(homeDir, 'git-hooks "quoted"'); + + try { + const result = runBash(installScript, [], { + HOME: homeDir, + ECC_GLOBAL_HOOKS_DIR: weirdHooksDir + }); + + assert.strictEqual(result.status, 0, result.stderr || result.stdout); + assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-commit'))); + assert.ok(fs.existsSync(path.join(weirdHooksDir, 'pre-push'))); + } finally { + cleanup(homeDir); + } + }) +) + passed++; +else failed++; + +console.log(`\nPassed: ${passed}`); +console.log(`Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 257a29ff..3ec05f3b 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -94,6 +94,7 @@ function runTests() { assert.ok(fs.existsSync(path.join(claudeRoot, 'rules', 'typescript', 'testing.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'hooks', 'session-end.js'))); + assert.ok(fs.existsSync(path.join(claudeRoot, 'scripts', 'lib', 'utils.js'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'coding-standards', 'SKILL.md'))); assert.ok(fs.existsSync(path.join(claudeRoot, 'plugin.json'))); @@ -132,6 +133,7 @@ function runTests() { assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'commands', 'plan.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks.json'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'hooks', 'session-start.js'))); + assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'scripts', 'lib', 'utils.js'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'tdd-workflow', 'SKILL.md'))); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'skills', 'coding-standards', 'SKILL.md'))); @@ -239,6 +241,7 @@ function runTests() { 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, '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'))); const state = readJson(path.join(claudeRoot, 'ecc', 'install-state.json')); diff --git a/tests/scripts/sync-ecc-to-codex.test.js b/tests/scripts/sync-ecc-to-codex.test.js new file mode 100644 index 00000000..58a4bb25 --- /dev/null +++ b/tests/scripts/sync-ecc-to-codex.test.js @@ -0,0 +1,52 @@ +/** + * Source-level tests for scripts/sync-ecc-to-codex.sh + */ + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'sync-ecc-to-codex.sh'); +const source = fs.readFileSync(scriptPath, 'utf8'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing sync-ecc-to-codex.sh ===\n'); + + let passed = 0; + let failed = 0; + + if (test('run_or_echo does not use eval', () => { + assert.ok(!source.includes('eval "$@"'), 'run_or_echo should not execute through eval'); + })) passed++; else failed++; + + if (test('run_or_echo executes argv directly', () => { + assert.ok(source.includes(' "$@"'), 'run_or_echo should execute the argv vector directly'); + })) passed++; else failed++; + + if (test('dry-run output shell-escapes argv', () => { + assert.ok(source.includes(`printf ' %q' "$@"`), 'Dry-run mode should print shell-escaped argv'); + })) passed++; else failed++; + + if (test('filesystem-changing calls use argv-form run_or_echo invocations', () => { + assert.ok(source.includes('run_or_echo mkdir -p "$BACKUP_DIR"'), 'mkdir should use argv form'); + assert.ok(source.includes('run_or_echo rm -rf "$dest"'), 'rm should use argv form'); + assert.ok(source.includes('run_or_echo cp -R "$skill_dir" "$dest"'), 'recursive copy should use argv form'); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); \ No newline at end of file