From 5e481879ca03875c54ed38f3571c9073294edea8 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Mar 2026 01:16:45 -0700 Subject: [PATCH] fix: harden observe loop prevention --- .../continuous-learning-v2/hooks/observe.sh | 41 ++--- tests/hooks/hooks.test.js | 158 ++++++++++++++++++ 2 files changed, 180 insertions(+), 19 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 23299d40..bdd315d7 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -82,32 +82,13 @@ if [ -n "$STDIN_CWD" ] && [ -d "$STDIN_CWD" ]; then export CLAUDE_PROJECT_DIR="$STDIN_CWD" fi -# ───────────────────────────────────────────── -# Project detection -# ───────────────────────────────────────────── - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -# Source shared project detection helper -# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR -source "${SKILL_ROOT}/scripts/detect-project.sh" -PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}" - # ───────────────────────────────────────────── # Configuration # ───────────────────────────────────────────── CONFIG_DIR="${HOME}/.claude/homunculus" -OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl" MAX_FILE_SIZE_MB=10 -SENTINEL_FILE="${CLV2_OBSERVER_SENTINEL_FILE:-${PROJECT_ROOT:-$PROJECT_DIR}/.observer.lock}" - -write_guard_sentinel() { - printf '%s\n' 'observer paused: confirmation or permission prompt detected; rerun start-observer.sh --reset after reviewing observer.log' > "$SENTINEL_FILE" -} - # Skip if disabled globally if [ -f "$CONFIG_DIR/disabled" ]; then exit 0 @@ -119,6 +100,8 @@ fi # - ECC observing its own Haiku observer sessions (self-loop) # - ECC observing other tools' automated sessions (e.g. claude-mem) # - All-night Haiku usage with no human activity +# Run these before project detection so skipped sessions cannot mutate +# project-scoped observer state. # ───────────────────────────────────────────── # Env-var checks first (cheapest — no subprocess spawning): @@ -161,6 +144,26 @@ if [ -n "$STDIN_CWD" ]; then done fi +# ───────────────────────────────────────────── +# Project detection +# ───────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Source shared project detection helper +# This sets: PROJECT_ID, PROJECT_NAME, PROJECT_ROOT, PROJECT_DIR +source "${SKILL_ROOT}/scripts/detect-project.sh" +PYTHON_CMD="${CLV2_PYTHON_CMD:-$PYTHON_CMD}" + +OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl" + +SENTINEL_FILE="${CLV2_OBSERVER_SENTINEL_FILE:-${PROJECT_ROOT:-$PROJECT_DIR}/.observer.lock}" + +write_guard_sentinel() { + printf '%s\n' 'observer paused: confirmation or permission prompt detected; rerun start-observer.sh --reset after reviewing observer.log' > "$SENTINEL_FILE" +} + # Skip if a previous run already aborted due to confirmation/permission prompt. # This is the circuit-breaker — stops retrying after a non-interactive failure. if [ -f "$SENTINEL_FILE" ]; then diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index bd4697c2..7c5d090f 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -136,6 +136,27 @@ function pathsReferToSameLocation(leftPath, rightPath) { } } +function createObservePayload(projectDir, overrides = {}) { + return JSON.stringify({ + tool_name: 'Bash', + tool_input: { command: 'echo hello' }, + tool_response: 'ok', + session_id: 'session-123', + cwd: projectDir, + ...overrides + }); +} + +function listObservationFiles(homeDir) { + const projectsDir = path.join(homeDir, '.claude', 'homunculus', 'projects'); + if (!fs.existsSync(projectsDir)) return []; + + return fs + .readdirSync(projectsDir) + .map(projectId => path.join(projectsDir, projectId, 'observations.jsonl')) + .filter(observationsPath => fs.existsSync(observationsPath)); +} + function createCommandShim(binDir, baseName, logFile) { fs.mkdirSync(binDir, { recursive: true }); @@ -2414,6 +2435,143 @@ async function runTests() { } })) passed++; else failed++; + if (await asyncTest('observe.sh skips non-cli entrypoints without writing observations', async () => { + const homeDir = createTestDir(); + const projectDir = createTestDir(); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + + try { + const result = await runShellScript(observePath, ['post'], createObservePayload(projectDir, { session_id: 'session-non-cli' }), { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir, + CLAUDE_CODE_ENTRYPOINT: 'sdk' + }, projectDir); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully for non-cli entrypoints, stderr: ${result.stderr}`); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'homunculus', 'projects')), 'non-cli entrypoints should exit before project detection runs'); + assert.deepStrictEqual(listObservationFiles(homeDir), [], 'non-cli entrypoints should not write observations'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectDir); + } + })) passed++; else failed++; + + if (await asyncTest('observe.sh skips ECC_SKIP_OBSERVE sessions without writing observations', async () => { + const homeDir = createTestDir(); + const projectDir = createTestDir(); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + + try { + const result = await runShellScript(observePath, ['post'], createObservePayload(projectDir, { session_id: 'session-skip-env' }), { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir, + ECC_SKIP_OBSERVE: '1' + }, projectDir); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully when ECC_SKIP_OBSERVE=1, stderr: ${result.stderr}`); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'homunculus', 'projects')), 'ECC_SKIP_OBSERVE should exit before project detection runs'); + assert.deepStrictEqual(listObservationFiles(homeDir), [], 'ECC_SKIP_OBSERVE should suppress observation writes'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectDir); + } + })) passed++; else failed++; + + if (await asyncTest('observe.sh skips subagent payloads with agent_id without writing observations', async () => { + const homeDir = createTestDir(); + const projectDir = createTestDir(); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + + try { + const result = await runShellScript( + observePath, + ['post'], + createObservePayload(projectDir, { session_id: 'session-agent', agent_id: 'agent-123' }), + { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir + }, + projectDir + ); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully for subagent sessions, stderr: ${result.stderr}`); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'homunculus', 'projects')), 'subagent sessions should exit before project detection runs'); + assert.deepStrictEqual(listObservationFiles(homeDir), [], 'subagent sessions should not write observations'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectDir); + } + })) passed++; else failed++; + + if (await asyncTest('observe.sh skips default observer-session paths without writing observations', async () => { + const homeDir = createTestDir(); + const projectRoot = createTestDir(); + const projectDir = path.join(projectRoot, 'observer-sessions-worker'); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + fs.mkdirSync(projectDir, { recursive: true }); + + try { + const result = await runShellScript(observePath, ['post'], createObservePayload(projectDir, { session_id: 'session-default-skip-path' }), { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir + }, projectDir); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully for default skip paths, stderr: ${result.stderr}`); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'homunculus', 'projects')), 'default skip paths should exit before project detection runs'); + assert.deepStrictEqual(listObservationFiles(homeDir), [], 'default skip paths should suppress observation writes'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectRoot); + } + })) passed++; else failed++; + + if (await asyncTest('observe.sh trims custom skip-path patterns before matching', async () => { + const homeDir = createTestDir(); + const projectRoot = createTestDir(); + const projectDir = path.join(projectRoot, 'custom-observer-session'); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + fs.mkdirSync(projectDir, { recursive: true }); + + try { + const result = await runShellScript(observePath, ['post'], createObservePayload(projectDir, { session_id: 'session-custom-skip-path' }), { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir, + ECC_OBSERVE_SKIP_PATHS: ' custom-observer-session , , ' + }, projectDir); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully for custom skip paths, stderr: ${result.stderr}`); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'homunculus', 'projects')), 'custom skip paths should exit before project detection runs'); + assert.deepStrictEqual(listObservationFiles(homeDir), [], 'trimmed custom skip paths should suppress observation writes'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectRoot); + } + })) passed++; else failed++; + + if (await asyncTest('observe.sh ignores empty skip-path entries so normal paths still record observations', async () => { + const homeDir = createTestDir(); + const projectDir = createTestDir(); + const observePath = path.join(__dirname, '..', '..', 'skills', 'continuous-learning-v2', 'hooks', 'observe.sh'); + + try { + const result = await runShellScript(observePath, ['post'], createObservePayload(projectDir, { session_id: 'session-empty-skip-paths' }), { + HOME: homeDir, + CLAUDE_PROJECT_DIR: projectDir, + ECC_OBSERVE_SKIP_PATHS: ' , , ' + }, projectDir); + + assert.strictEqual(result.code, 0, `observe.sh should exit successfully when skip-path entries are empty, stderr: ${result.stderr}`); + const observationFiles = listObservationFiles(homeDir); + assert.strictEqual(observationFiles.length, 1, 'empty skip-path entries should not suppress normal observations'); + + const observations = fs.readFileSync(observationFiles[0], 'utf8').trim().split('\n').filter(Boolean); + assert.ok(observations.length > 0, 'normal sessions should still append observations when skip-path entries are empty'); + } finally { + cleanupTestDir(homeDir); + cleanupTestDir(projectDir); + } + })) passed++; else failed++; + if (await asyncTest('matches .tsx extension for type checking', async () => { const testDir = createTestDir(); const testFile = path.join(testDir, 'component.tsx');