From 7349d1ed6e9426bc54d9bc4c49d41d148f6a3dde Mon Sep 17 00:00:00 2001 From: ProtocolWarden <32967198+ProtocolWarden@users.noreply.github.com> Date: Sat, 1 Aug 2026 16:02:55 -0400 Subject: [PATCH] fix(guard): resolve cognition paths against the session dir, not the anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContextGuard joined capsule/checkpoint/handoff paths onto CL_ANCHOR, while config.yaml documents them as relative to .context/sessions//. CL_SESSION_ID appeared nowhere in either hook, so all three directories resolved to paths that do not exist and every guard silently found nothing: - lease expiry never fired (the handoff was never located) - worker forbidden_paths never loaded - require_capsule: true was a landmine, not a feature — it would have blocked every tool call with "No active capsule found" Both hooks now resolve against COGNITION_ROOT, and the path defaults drop their `.context/` prefix to match the config convention. When CL_SESSION_ID is unset, pre_tool_use falls back to the anchor's .context/ and warns rather than blocking, so existing consumers degrade instead of hard-failing. Verified by fixture rather than inspection: an anchor carrying an expired lease passes the previous hook (exit 0) and blocks on this one (exit 2). Also fix block(): it hand-built its JSON, so a reason containing a Windows path emitted an unescaped backslash and the payload failed to parse — the operator saw a blocked call with no reason at all. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 16 +++++++ adapters/claude/hooks/pre_tool_use.sh | 66 ++++++++++++++++++++------- adapters/claude/hooks/stop.sh | 36 ++++++++++----- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5590b32..6fc9adc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **ContextGuard resolved cognition paths against the anchor root instead of + the session directory**, so every guard read a path that does not exist. + `config.yaml` documents `capsule_path` / `checkpoint_path` / `handoff_path` + as relative to `.context/sessions//`, but both Claude adapters + joined them onto `CL_ANCHOR` directly — `CL_SESSION_ID` appeared nowhere in + either hook. Lease expiry never fired, worker `forbidden_paths` never loaded, + and `require_capsule: true` would have blocked every tool call with "No active + capsule found" rather than enforcing anything. Both hooks now resolve against + `COGNITION_ROOT`, and the path defaults match the config convention. +- **`block()` emitted invalid JSON for any reason containing a Windows path.** + The payload was hand-built, so backslashes went through unescaped and `\U` + made it unparseable — a blocked call reached the operator with no reason + attached. Now serialized with `json.dumps`. + ### Added - Opportunistic auto-GC in `cl session start` (throttled to once per 24h diff --git a/adapters/claude/hooks/pre_tool_use.sh b/adapters/claude/hooks/pre_tool_use.sh index e202c4c..d183fb3 100755 --- a/adapters/claude/hooks/pre_tool_use.sh +++ b/adapters/claude/hooks/pre_tool_use.sh @@ -19,6 +19,20 @@ fi REPO_ROOT="${CL_ANCHOR}" CONFIG_FILE="${REPO_ROOT}/.context/config.yaml" +# --- Cognition root: session-scoped, not anchor-scoped --- +# config.yaml documents capsule/checkpoint/handoff paths as relative to +# .context/sessions//. Resolving them against the anchor root +# instead pointed every guard at a directory that does not exist, so the +# capsule, lease, and forbidden-path checks all silently found nothing. +if [[ -n "${CL_SESSION_ID:-}" ]]; then + COGNITION_ROOT="${REPO_ROOT}/.context/sessions/${CL_SESSION_ID}" +else + # No session id: fall back to the anchor's .context/ so a mis-set env warns + # loudly rather than blocking every tool call. + COGNITION_ROOT="${REPO_ROOT}/.context" + echo "ContextGuard warning: CL_SESSION_ID is not set — cognition paths fall back to ${COGNITION_ROOT}. Run: cl session start ." >&2 +fi + # --- Session marker (created on first tool call; stop.sh uses it to detect fresh checkpoints) --- _SESSION_HASH="$(echo "$REPO_ROOT" | cksum | cut -d' ' -f1)" SESSION_MARKER="/tmp/clp_session_${_SESSION_HASH}" @@ -38,9 +52,9 @@ fi # --- Load config (with defaults) --- REQUIRE_CAPSULE=false ENFORCE_LEASE=true -CAPSULE_PATH=".context/active/" -CHECKPOINT_PATH=".context/checkpoints/" -HANDOFF_PATH=".context/handoffs/" +CAPSULE_PATH="active/" +CHECKPOINT_PATH="checkpoints/" +HANDOFF_PATH="handoffs/" if [[ -f "${CONFIG_FILE}" ]] && command -v python3 &>/dev/null; then REQUIRE_CAPSULE=$(python3 -c " @@ -70,25 +84,32 @@ try: import yaml with open('${CONFIG_FILE}') as f: c = yaml.safe_load(f) - print(c.get('guard', {}).get('capsule_path', '.context/active/')) + print(c.get('guard', {}).get('capsule_path', 'active/')) except Exception: - print('.context/active/') -" 2>/dev/null || echo ".context/active/") + print('active/') +" 2>/dev/null || echo "active/") HANDOFF_PATH=$(python3 -c " try: import yaml with open('${CONFIG_FILE}') as f: c = yaml.safe_load(f) - print(c.get('guard', {}).get('handoff_path', '.context/handoffs/')) + print(c.get('guard', {}).get('handoff_path', 'handoffs/')) except Exception: - print('.context/handoffs/') -" 2>/dev/null || echo ".context/handoffs/") + print('handoffs/') +" 2>/dev/null || echo "handoffs/") fi +# Tolerate legacy config values that still carry the `.context/` prefix: the +# base is now COGNITION_ROOT, which already includes it. Without this, a config +# pinned to the old default resolves to `/.context/.context/active/`. +CAPSULE_PATH="${CAPSULE_PATH#.context/}" +CHECKPOINT_PATH="${CHECKPOINT_PATH#.context/}" +HANDOFF_PATH="${HANDOFF_PATH#.context/}" + # --- Helper: find active capsule --- find_active_capsule() { - local capsule_dir="${REPO_ROOT}/${CAPSULE_PATH}" + local capsule_dir="${COGNITION_ROOT}/${CAPSULE_PATH}" if [[ -d "$capsule_dir" ]]; then find "$capsule_dir" -name "*.yaml" -not -name ".gitkeep" | head -1 fi @@ -112,8 +133,19 @@ except Exception: # --- Helper: block with reason --- block() { - local reason="$1" - echo "{\"decision\": \"block\", \"reason\": \"ContextGuard: ${reason}\"}" + # The reason routinely contains a filesystem path. On Windows those carry + # backslashes, which are invalid unescaped inside a JSON string — hand-built + # JSON produced an unparseable payload and the operator saw no reason at all. + local reason="ContextGuard: $1" + if command -v python3 &>/dev/null; then + CG_REASON="$reason" python3 -c " +import json, os +print(json.dumps({'decision': 'block', 'reason': os.environ['CG_REASON']})) +" + else + reason="${reason//\\/\\\\}" + echo "{\"decision\": \"block\", \"reason\": \"${reason//\"/\\\"}\"}" + fi exit 2 } @@ -148,7 +180,7 @@ fi # --- Check: lease expiry --- if [[ "$ENFORCE_LEASE" == "true" ]]; then - HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}" + HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}" if [[ -d "$HANDOFF_DIR" ]]; then ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)" if [[ -n "$ACTIVE_HANDOFF" ]]; then @@ -173,7 +205,7 @@ if [[ "$TOOL_NAME" == "Write" || "$TOOL_NAME" == "Edit" ]]; then fi if [[ -n "$TARGET_PATH" ]]; then - HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}" + HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}" if [[ -d "$HANDOFF_DIR" ]]; then ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)" if [[ -n "$ACTIVE_HANDOFF" ]]; then @@ -241,7 +273,7 @@ fi # --- Check: pre_spawn — subagent budget --- if [[ "$TOOL_NAME" == "Agent" ]]; then - HANDOFF_DIR="${REPO_ROOT}/${HANDOFF_PATH}" + HANDOFF_DIR="${COGNITION_ROOT}/${HANDOFF_PATH}" if [[ -d "$HANDOFF_DIR" ]]; then ACTIVE_HANDOFF="$(find "$HANDOFF_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1)" if [[ -n "$ACTIVE_HANDOFF" ]]; then @@ -262,7 +294,7 @@ except Exception: fi # Check context_risk.high_parallelism from latest checkpoint - CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}" + CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}" if [[ -d "$CHECKPOINT_DIR" ]]; then LATEST_CHECKPOINT="$(find "$CHECKPOINT_DIR" -name "*.yaml" -not -name ".gitkeep" | sort | tail -1)" if [[ -n "$LATEST_CHECKPOINT" ]]; then @@ -300,7 +332,7 @@ except Exception: fi # --- context_risk flags from latest checkpoint --- -CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}" +CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}" if [[ -d "$CHECKPOINT_DIR" ]]; then LATEST_CHECKPOINT="$(find "$CHECKPOINT_DIR" -name "*.yaml" -not -name ".gitkeep" | sort | tail -1)" if [[ -n "$LATEST_CHECKPOINT" ]]; then diff --git a/adapters/claude/hooks/stop.sh b/adapters/claude/hooks/stop.sh index 2347aae..28a7cb4 100755 --- a/adapters/claude/hooks/stop.sh +++ b/adapters/claude/hooks/stop.sh @@ -20,14 +20,21 @@ fi REPO_ROOT="${CL_ANCHOR}" CONFIG_FILE="${REPO_ROOT}/.context/config.yaml" +# --- Cognition root: session-scoped, not anchor-scoped (see pre_tool_use.sh) --- +if [[ -n "${CL_SESSION_ID:-}" ]]; then + COGNITION_ROOT="${REPO_ROOT}/.context/sessions/${CL_SESSION_ID}" +else + COGNITION_ROOT="${REPO_ROOT}/.context" +fi + # --- Session marker (written by pre_tool_use.sh on first tool call this session) --- _SESSION_HASH="$(echo "$REPO_ROOT" | cksum | cut -d' ' -f1)" SESSION_MARKER="/tmp/clp_session_${_SESSION_HASH}" # --- Load config --- CHECKPOINT_ON_STOP=true -CAPSULE_PATH=".context/active/" -CHECKPOINT_PATH=".context/checkpoints/" +CAPSULE_PATH="active/" +CHECKPOINT_PATH="checkpoints/" if [[ -f "${CONFIG_FILE}" ]] && command -v python3 &>/dev/null; then CHECKPOINT_ON_STOP=$(python3 -c " @@ -45,22 +52,27 @@ try: import yaml with open('${CONFIG_FILE}') as f: c = yaml.safe_load(f) - print(c.get('guard', {}).get('capsule_path', '.context/active/')) + print(c.get('guard', {}).get('capsule_path', 'active/')) except Exception: - print('.context/active/') -" 2>/dev/null || echo ".context/active/") + print('active/') +" 2>/dev/null || echo "active/") CHECKPOINT_PATH=$(python3 -c " try: import yaml with open('${CONFIG_FILE}') as f: c = yaml.safe_load(f) - print(c.get('guard', {}).get('checkpoint_path', '.context/checkpoints/')) + print(c.get('guard', {}).get('checkpoint_path', 'checkpoints/')) except Exception: - print('.context/checkpoints/') -" 2>/dev/null || echo ".context/checkpoints/") + print('checkpoints/') +" 2>/dev/null || echo "checkpoints/") fi +# Tolerate legacy config values that still carry the `.context/` prefix (see +# pre_tool_use.sh) — COGNITION_ROOT already includes it. +CAPSULE_PATH="${CAPSULE_PATH#.context/}" +CHECKPOINT_PATH="${CHECKPOINT_PATH#.context/}" + # --- Helper: warn --- warn() { echo "ContextGuard warning: $1" >&2 @@ -71,7 +83,7 @@ warn() { # timestamp reference. find -newer detects only checkpoints written after session start. # Falls back to existence check if the marker is absent (session without tool calls). -CHECKPOINT_DIR="${REPO_ROOT}/${CHECKPOINT_PATH}" +CHECKPOINT_DIR="${COGNITION_ROOT}/${CHECKPOINT_PATH}" CHECKPOINT_FOUND=false if [[ -d "$CHECKPOINT_DIR" ]]; then @@ -91,7 +103,7 @@ fi if [[ "$CHECKPOINT_FOUND" == "false" ]]; then if [[ "$CHECKPOINT_ON_STOP" == "true" ]]; then echo "ContextGuard: Session ending without a LoopCheckpoint. Write a checkpoint before terminating." >&2 - echo " Create: .context/checkpoints/.yaml" >&2 + echo " Create: ${CHECKPOINT_DIR}/.yaml" >&2 echo " Template: .context/templates/loop_checkpoint.template.yaml" >&2 # Non-fatal warn — Claude Code Stop hooks can't hard-block session end in all cases # but surfacing this prominently is the enforcement mechanism @@ -101,7 +113,7 @@ if [[ "$CHECKPOINT_FOUND" == "false" ]]; then fi # --- Check: active capsule not updated --- -CAPSULE_DIR="${REPO_ROOT}/${CAPSULE_PATH}" +CAPSULE_DIR="${COGNITION_ROOT}/${CAPSULE_PATH}" if [[ -d "$CAPSULE_DIR" ]]; then ACTIVE_CAPSULE=$(find "$CAPSULE_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1) if [[ -n "$ACTIVE_CAPSULE" ]] && command -v python3 &>/dev/null; then @@ -139,7 +151,7 @@ except Exception: fi if [[ "$INJECT_ENABLED" == "true" ]]; then - CAPSULE_DIR="${REPO_ROOT}/${CAPSULE_PATH}" + CAPSULE_DIR="${COGNITION_ROOT}/${CAPSULE_PATH}" if [[ -d "$CAPSULE_DIR" ]] && command -v python3 &>/dev/null; then ACTIVE_CAPSULE=$(find "$CAPSULE_DIR" -name "*.yaml" -not -name ".gitkeep" | head -1) if [[ -n "$ACTIVE_CAPSULE" ]]; then