From 8079d354d1e3c192b91cf772ba9e0d32372ee176 Mon Sep 17 00:00:00 2001 From: swarnika-cmd Date: Thu, 12 Mar 2026 06:46:42 +0530 Subject: [PATCH] fix: observer fails closed on confirmation/permission prompts (issue #400) --- .../continuous-learning-v2/hooks/observe.sh | 78 ++++++++++++------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/skills/continuous-learning-v2/hooks/observe.sh b/skills/continuous-learning-v2/hooks/observe.sh index 33ec6f04..8617113f 100755 --- a/skills/continuous-learning-v2/hooks/observe.sh +++ b/skills/continuous-learning-v2/hooks/observe.sh @@ -33,6 +33,18 @@ resolve_python_cmd() { return 0 fi + # FIX: Windows Git Bash — check known Python install paths directly + # because `command -v python` triggers the Microsoft Store alias instead + for win_py in \ + "/c/Users/$USER/AppData/Local/Programs/Python/Python311/python" \ + "/c/Users/$USER/AppData/Local/Programs/Python/Python312/python" \ + "/c/Users/$USER/AppData/Local/Programs/Python/Python310/python"; do + if [ -x "$win_py" ]; then + printf '%s\n' "$win_py" + return 0 + fi + done + if command -v python3 >/dev/null 2>&1; then printf '%s\n' python3 return 0 @@ -93,11 +105,22 @@ CONFIG_DIR="${HOME}/.claude/homunculus" OBSERVATIONS_FILE="${PROJECT_DIR}/observations.jsonl" MAX_FILE_SIZE_MB=10 -# Skip if disabled +# FIX: SENTINEL_FILE must be defined AFTER PROJECT_DIR is set by detect-project.sh +# Previously it was defined at the top before PROJECT_DIR existed, making it empty/broken +SENTINEL_FILE="${PROJECT_DIR}/.observer.lock" + +# Skip if disabled globally if [ -f "$CONFIG_DIR/disabled" ]; then exit 0 fi +# FIX: 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 + echo "[observe] Skipping: previous run aborted due to confirmation/permission prompt. Remove ${SENTINEL_FILE} to re-enable." >&2 + exit 0 +fi + # Auto-purge observation files older than 30 days (runs once per session) PURGE_MARKER="${PROJECT_DIR}/.last-purge" if [ ! -f "$PURGE_MARKER" ] || [ "$(find "$PURGE_MARKER" -mtime +1 2>/dev/null)" ]; then @@ -190,46 +213,47 @@ if [ -f "$OBSERVATIONS_FILE" ]; then fi fi -# Build and write observation (now includes project context) -# Scrub common secret patterns from tool I/O before persisting -timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +# FIX: Detect confirmation/permission prompts in observer output and fail closed. +# A non-interactive background observer must never ask for user confirmation. +# If detected: log once, write sentinel to suppress all future retries, exit non-zero. +if echo "$PARSED" | grep -E -i -q "Can you confirm|requires permission|Awaiting|confirm I should proceed|once granted access|grant.*access"; then + echo "[observe] OBSERVER_ABORT: Confirmation or permission prompt detected in observer output. This observer run is non-actionable." >&2 + echo "[observe] Writing sentinel to suppress retries: ${SENTINEL_FILE}" >&2 + echo "$PARSED" > "$SENTINEL_FILE" + exit 2 +fi +# Build and write observation (now includes project context) +timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ") export PROJECT_ID_ENV="$PROJECT_ID" export PROJECT_NAME_ENV="$PROJECT_NAME" export TIMESTAMP="$timestamp" - echo "$PARSED" | "$PYTHON_CMD" -c ' import json, sys, os, re - parsed = json.load(sys.stdin) observation = { - "timestamp": os.environ["TIMESTAMP"], - "event": parsed["event"], - "tool": parsed["tool"], - "session": parsed["session"], - "project_id": os.environ.get("PROJECT_ID_ENV", "global"), - "project_name": os.environ.get("PROJECT_NAME_ENV", "global") + "timestamp": os.environ["TIMESTAMP"], + "event": parsed["event"], + "tool": parsed["tool"], + "session": parsed["session"], + "project_id": os.environ.get("PROJECT_ID_ENV", "global"), + "project_name": os.environ.get("PROJECT_NAME_ENV", "global") } - # Scrub secrets: match common key=value, key: value, and key"value patterns -# Includes optional auth scheme (e.g., "Bearer", "Basic") before token _SECRET_RE = re.compile( - r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" - r"""(["'"'"'\s:=]+)""" - r"([A-Za-z]+\s+)?" - r"([A-Za-z0-9_\-/.+=]{8,})" + r"(?i)(api[_-]?key|token|secret|password|authorization|credentials?|auth)" + r"""(["'"'"'\s:=]+)""" + r"([A-Za-z]+\s+)?" + r"([A-Za-z0-9_\-/.+=]{8,})" ) - def scrub(val): - if val is None: - return None - return _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", str(val)) - + if val is None: + return None + return _SECRET_RE.sub(lambda m: m.group(1) + m.group(2) + (m.group(3) or "") + "[REDACTED]", str(val)) if parsed["input"]: - observation["input"] = scrub(parsed["input"]) + observation["input"] = scrub(parsed["input"]) if parsed["output"] is not None: - observation["output"] = scrub(parsed["output"]) - + observation["output"] = scrub(parsed["output"]) print(json.dumps(observation)) ' >> "$OBSERVATIONS_FILE" @@ -243,4 +267,4 @@ for pid_file in "${PROJECT_DIR}/.observer.pid" "${CONFIG_DIR}/.observer.pid"; do fi done -exit 0 +exit 0 \ No newline at end of file