diff --git a/.claude/agents/debugger.md b/.claude/agents/debugger.md index d7867633..060e3942 100644 --- a/.claude/agents/debugger.md +++ b/.claude/agents/debugger.md @@ -133,7 +133,7 @@ When diagnosing data-related bugs (unexpected values, failed joins, wrong coded When debugging Stage 8 analysis failures: if the error traceback involves a specific library, load the corresponding skill via the Skill tool (see § Language-Conditional Skill Loading for the full mapping). Each library skill's `gotchas.md` reference file documents common failure modes: **Python libraries:** -- **pyfixest:** Formula syntax errors, singleton fixed effect warnings, SE specification issues, v0.40.0 breaking changes +- **pyfixest:** Formula syntax errors, singleton fixed effect warnings, SE specification issues, cumulative 0.40→0.60 breaking changes - **statsmodels:** Convergence failures, perfect separation in logit, singular matrix in GLS, missing formula API import - **linearmodels:** Entity effects specification, absorbed variable errors, GMM weight matrix issues - **geopandas:** CRS mismatch errors, invalid geometry, spatial join row explosion, Shapely 2.x migration issues diff --git a/.claude/hooks/context-reporter.sh b/.claude/hooks/context-reporter.sh index 98e5a27e..25fd1afb 100755 --- a/.claude/hooks/context-reporter.sh +++ b/.claude/hooks/context-reporter.sh @@ -24,8 +24,10 @@ # # Performance: # Uses transcript byte-size + mtime signatures to reuse model caches without -# rescanning unchanged files. Changed transcripts are resolved from `tail -50`, -# which also bounds usage parsing to the end of the JSONL. +# rescanning unchanged files. When a transcript has changed, the model and +# usage are recovered from a full scan of the JSONL (last non-synthetic model, +# last positive usage sum) so a long run of trailing zero-token shim +# placeholders cannot hide the real values. # # Subagent support: # settings.json PreToolUse hooks also fire for tool calls made BY subagents. @@ -46,6 +48,17 @@ # orchestrator's utilization into the subagent's context, causing subagents # to falsely throttle or refuse work at HIGH/CRITICAL. # +# Parent-model isolation: +# The parent/session model is derived ONLY from a genuinely-parent source — +# the parent model cache (/tmp/claude-model-) refreshed from the +# parent's MAIN transcript. It is NEVER inferred by re-parsing a path that is +# itself a subagent transcript. When Claude Code passes the subagent's own +# transcript directly (basename == agent-.jsonl) there is no parent +# transcript to scan, so the parent model comes from the cache alone (or is +# left empty). A measured subagent is then mapped by ITS OWN model/window and +# never assumed to share the session model (see Convention 10 in the +# hardening spec). +# # Threshold tier (see calculate()): # The severity thresholds are keyed on the model identity of the agent being # measured — main-session measurements use the session model; subagent @@ -86,6 +99,17 @@ is_canonical_positive_decimal() { [[ "$value" == "$max_value" || "$value" < "$max_value" ]] } +# Bounded identifier allowlist (Convention 2). Leading alnum, then +# alnum/dot/underscore/hyphen, max 128 chars. Rejects slashes, traversal (..), +# control chars, leading dash, empty, and >128. Every session_id / agent_id must +# pass this BEFORE it is interpolated into any transcript or /tmp cache path, so +# an attacker-shaped id can neither escape the /tmp namespace nor the subagent +# transcript subtree. A present-but-invalid id is rejected exactly like an +# absent one (key-presence contract). +is_safe_id() { + [[ "${1:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] +} + # Return a signature that changes when a transcript is appended or rewritten. # GNU stat supplies byte size plus nanosecond-resolution mtime in one probe. transcript_signature() { @@ -98,9 +122,13 @@ transcript_signature() { # paths consumed by audit-log.sh and statusline components. The neighboring # .transcript-signature sidecar is the commit marker: unchanged signatures reuse # a nonempty bare cache without parsing; changed signatures refresh from the last -# nonempty, non-synthetic model. Both files are replaced through same-directory -# temporary siblings, with the signature moved last so interrupted writes force -# a safe refresh on the next invocation. +# nonempty, non-synthetic model via a FULL scan of the JSONL (Convention 4 — +# a long tail of zero-token/synthetic placeholders cannot hide the real model). +# Both files are replaced through same-directory temporary siblings, with the +# signature moved last so interrupted writes force a safe refresh on the next +# invocation. When called with an empty transcript (e.g. the parent transcript +# is unavailable in the direct-subagent branch) it returns the cached value +# without scanning or writing. resolve_model_cache() { local transcript="${1:-}" local cache="${2:-}" @@ -119,16 +147,20 @@ resolve_model_cache() { fi if [[ -n "$transcript" && -f "$transcript" ]]; then - model=$(tail -n 50 "$transcript" 2>/dev/null | jq -rs ' + model=$(jq -rs ' [.[] | (.message.model // empty) | select(. != "" and . != "")] | last // empty - ' 2>/dev/null) || model="" + ' < "$transcript" 2>/dev/null) || model="" fi [[ -z "$model" ]] && model="$cached_model" if [[ -n "$model" && -n "$signature" ]]; then - if printf '%s' "$model" > "$model_tmp" 2>/dev/null && - printf '%s' "$signature" > "$signature_tmp" 2>/dev/null && + # Convention 6: wrap the redirection so an open() failure on the temp + # target cannot leak "No such file"/"Permission denied" to the display + # stream (redirections apply left-to-right, so a bare `> f 2>/dev/null` + # would still print the open() diagnostic before 2> takes effect). + if { printf '%s' "$model" > "$model_tmp"; } 2>/dev/null && + { printf '%s' "$signature" > "$signature_tmp"; } 2>/dev/null && mv "$model_tmp" "$cache" 2>/dev/null && mv "$signature_tmp" "$signature_cache" 2>/dev/null; then : @@ -146,47 +178,92 @@ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "default"' 2>/dev/null) || SE TRANSCRIPT_PATH=$(echo "$INPUT" | jq -r '.transcript_path // empty' 2>/dev/null) || TRANSCRIPT_PATH="" AGENT_ID=$(echo "$INPUT" | jq -r '.agent_id // empty' 2>/dev/null) || AGENT_ID="" +# Convention 2: allowlist ids BEFORE any path/cache interpolation. Fail open +# (emit nothing, exit 0) on a rejected id — do not construct or read/write any +# path built from it. SESSION_ID is always used (every /tmp cache is keyed on +# it); AGENT_ID is only checked when present (its absence marks the main +# session). A present-but-invalid id is rejected like an absent one. +is_safe_id "$SESSION_ID" || exit 0 +if [[ -n "$AGENT_ID" ]]; then + is_safe_id "$AGENT_ID" || exit 0 +fi + +# Production caches live in /tmp. The override is a deterministic-test seam so +# Bats can exercise the gate/model/window cache reads and writes inside project +# scratch without touching live session state. The default is /tmp so +# production behavior is byte-identical; the name follows the per-script +# DAAF_