fix(observe): 5-layer automated session guard to prevent self-loop observations (#399)

* fix(observe): add 5-layer automated session guard to prevent self-loop observations

observe.sh currently fires for ALL hook events including automated/programmatic
sessions: the ECC observer's own Haiku analysis runs, claude-mem observer
sessions, CI pipelines, and any other tool that spawns `claude --print`.

This causes an infinite feedback loop where automated sessions generate
observations that trigger more automated analysis, burning Haiku tokens with
no human activity.

Add a 5-layer guard block after the `disabled` check:

Layer 1: agent_id payload field — only present in subagent hooks; skip any
         subagent-scoped session (always automated by definition).

Layer 2: CLAUDE_CODE_ENTRYPOINT env var — Claude Code sets this to sdk-ts,
         sdk-py, sdk-cli, mcp, or remote for programmatic/SDK invocations.
         Skip if any non-cli entrypoint is detected. This is universal: catches
         any tool using the Anthropic SDK without requiring tool cooperation.

Layer 3: ECC_HOOK_PROFILE=minimal — existing ECC mechanism; respect it here
         to suppress non-essential hooks in observer contexts.

Layer 4: ECC_SKIP_OBSERVE=1 — cooperative env var any external tool can set
         before spawning automated sessions (explicit opt-out contract).

Layer 5: CWD path exclusions — skip sessions whose working directory matches
         known observer-session path patterns. Configurable via
         ECC_OBSERVE_SKIP_PATHS (comma-separated substrings, default:
         "observer-sessions,.claude-mem").

Also fix observer-loop.sh to set ECC_SKIP_OBSERVE=1 and ECC_HOOK_PROFILE=minimal
before spawning the Haiku analysis subprocess, making the observer loop
self-aware and closing the ECC→ECC self-observation loop without needing
external coordination.

Fixes: observe.sh fires unconditionally on automated sessions (#398)

* fix(observe): address review feedback — reorder guards cheapest-first, fix empty pattern bug

Two issues flagged by Copilot and CodeRabbit in PR #399:

1. Layer ordering: the agent_id check spawns a Python subprocess but ran
   before the cheap env-var checks (CLAUDE_CODE_ENTRYPOINT, ECC_HOOK_PROFILE,
   ECC_SKIP_OBSERVE). Reorder to put all env-var checks first (Layers 1-3),
   then the subprocess-requiring agent_id check (Layer 4). Automated sessions
   that set env vars — the common case — now exit without spawning Python.

2. Empty pattern bug in Layer 5: if ECC_OBSERVE_SKIP_PATHS contains a trailing
   comma or spaces after commas (e.g. "path1, path2" or "path1,"), _pattern
   becomes empty or whitespace-only, and the glob *""* matches every CWD,
   silently disabling all observations. Fix: trim leading/trailing whitespace
   from each pattern and skip empty patterns with `continue`.

* fix: fail closed for non-cli entrypoints

---------

Co-authored-by: Affaan Mustafa <affaan@dcube.ai>
This commit is contained in:
ispaydeu
2026-03-13 02:40:03 -04:00
committed by GitHub
parent 83f6d5679c
commit c52a28ace9
2 changed files with 50 additions and 1 deletions

View File

@@ -91,7 +91,8 @@ PROMPT
max_turns=10 max_turns=10
fi fi
claude --model haiku --max-turns "$max_turns" --print < "$prompt_file" >> "$LOG_FILE" 2>&1 & # Prevent observe.sh from recording this automated Haiku session as observations
ECC_SKIP_OBSERVE=1 ECC_HOOK_PROFILE=minimal claude --model haiku --max-turns "$max_turns" --print < "$prompt_file" >> "$LOG_FILE" 2>&1 &
claude_pid=$! claude_pid=$!
( (

View File

@@ -98,6 +98,54 @@ if [ -f "$CONFIG_DIR/disabled" ]; then
exit 0 exit 0
fi fi
# ─────────────────────────────────────────────
# Automated session guards
# Prevents observe.sh from firing on non-human sessions to avoid:
# - 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
# ─────────────────────────────────────────────
# Env-var checks first (cheapest — no subprocess spawning):
# Layer 1: CLAUDE_CODE_ENTRYPOINT — set by Claude Code itself to indicate how
# it was invoked. Only interactive terminal sessions should continue; treat any
# explicit non-cli entrypoint as automated so future entrypoint types fail closed
# without requiring updates here.
case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
cli) ;;
*) exit 0 ;;
esac
# Layer 2: Respect ECC_HOOK_PROFILE=minimal — suppresses non-essential hooks
[ "${ECC_HOOK_PROFILE:-standard}" = "minimal" ] && exit 0
# Layer 3: Cooperative skip env var — tools like claude-mem can set this
# (export ECC_SKIP_OBSERVE=1) before spawning their automated sessions
[ "${ECC_SKIP_OBSERVE:-0}" = "1" ] && exit 0
# Layer 4: Skip subagent sessions — agent_id is only present when a hook fires
# inside a subagent (automated by definition, never a human interactive session).
# Placed after env-var checks to avoid a Python subprocess on sessions that
# already exit via Layers 1-3.
_ECC_AGENT_ID=$(echo "$INPUT_JSON" | "$PYTHON_CMD" -c "import json,sys; print(json.load(sys.stdin).get('agent_id',''))" 2>/dev/null || true)
[ -n "$_ECC_AGENT_ID" ] && exit 0
# Layer 5: CWD path exclusions — skip known observer-session directories.
# Add custom paths via ECC_OBSERVE_SKIP_PATHS (comma-separated substrings).
# Whitespace is trimmed from each pattern; empty patterns are skipped to
# prevent an empty-string glob from matching every path.
_ECC_SKIP_PATHS="${ECC_OBSERVE_SKIP_PATHS:-observer-sessions,.claude-mem}"
if [ -n "$STDIN_CWD" ]; then
IFS=',' read -ra _ECC_SKIP_ARRAY <<< "$_ECC_SKIP_PATHS"
for _pattern in "${_ECC_SKIP_ARRAY[@]}"; do
_pattern="${_pattern#"${_pattern%%[![:space:]]*}"}" # trim leading whitespace
_pattern="${_pattern%"${_pattern##*[![:space:]]}"}" # trim trailing whitespace
[ -z "$_pattern" ] && continue
case "$STDIN_CWD" in *"$_pattern"*) exit 0 ;; esac
done
fi
# Auto-purge observation files older than 30 days (runs once per session) # Auto-purge observation files older than 30 days (runs once per session)
PURGE_MARKER="${PROJECT_DIR}/.last-purge" PURGE_MARKER="${PROJECT_DIR}/.last-purge"
if [ ! -f "$PURGE_MARKER" ] || [ "$(find "$PURGE_MARKER" -mtime +1 2>/dev/null)" ]; then if [ ! -f "$PURGE_MARKER" ] || [ "$(find "$PURGE_MARKER" -mtime +1 2>/dev/null)" ]; then