Skip to content

Claude Code Flight Recorder (Phase 1): sessions, hooks, SQLite, redaction, timelines - #3

Open
juancgarza wants to merge 5 commits into
masterfrom
jcgarzar/hooks-not-populating-timeline
Open

Claude Code Flight Recorder (Phase 1): sessions, hooks, SQLite, redaction, timelines#3
juancgarza wants to merge 5 commits into
masterfrom
jcgarzar/hooks-not-populating-timeline

Conversation

@juancgarza

Copy link
Copy Markdown
Owner

Summary

Evolves the proxy from a request inspector into a session-level flight recorder for Claude Code / Codex. It ingests Claude Code lifecycle hooks, reconstructs durable coding sessions, correlates API traffic to them, redacts secrets before persistence, and adds a session-first dashboard with normalized timelines.

What's new

  • Hook ingestionPOST /events/claude-code reconstructs sessions from SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/SessionEnd.
  • Durable storage — SQLite (modernc.org/sqlite, pure-Go, no cgo) behind a Repository interface; survives restarts.
  • Session correlation — best-effort prompt content-match + most-recent-active fallback (the API carries no session id).
  • Secret redaction — API keys, tokens, AWS keys, PEM blocks, bearer tokens scrubbed from request/response/stream/hook payloads before anything hits disk.
  • Control plane/sessions, /sessions/{id}, /sessions/{id}/timeline; optional bearer auth; localhost-only WebSocket origin + CORS for the dashboard.
  • Dashboard — Sessions | Requests views, live timelines, per-session request drill-down.
  • Installer — portable non-blocking curl hooks, idempotent jq merge that preserves existing user hooks.

Review & hardening

An adversarial multi-agent review produced 10 verified findings, all applied: deep-copy records out of the store (data race, clean under -race), redact derived prompt/summary fields, bound WebSocket writes + single-writer-per-conn, token-guard the hook endpoint, constant-time token compare, string-encode seq, correlation eligibility fixes, and degrade to a no-op repository on DB-open failure. A late CORS fix resolved the dashboard timeline not populating.

Verification

go build / vet / test / test -race / gofmt clean (Go 1.18); dashboard tsc --noEmit + next build green. Runtime-smoked end to end: hooks → session → timeline, token auth, prompt redaction, DB-failure fallback, CORS.

Not in scope (later phases)

Richer command/test/failure timeline enrichment (Phase 3) and deterministic signal detection (Phase 4) land as follow-up commits on this branch.

🤖 Generated with Claude Code

juancgarza and others added 5 commits June 30, 2026 19:20
…timelines

Pivot the proxy from request-centric to session-centric (Phase 1).

- Ingest Claude Code lifecycle hooks at POST /events/claude-code and
  reconstruct durable coding sessions.
- Persist sessions, normalized session events, and API requests to SQLite
  (modernc.org/sqlite, pure-Go) behind a Repository interface.
- Correlate API traffic to sessions best-effort (prompt content match +
  most-recent-active fallback) since the API carries no session id.
- Redact secrets (API keys, tokens, AWS keys, PEM blocks, bearer tokens)
  from bodies, stream events, and hook payloads before persistence.
- Control plane: /sessions, /sessions/{id}, /sessions/{id}/timeline, plus
  optional bearer auth and localhost-only WebSocket origin + CORS.
- Session-first dashboard (Sessions | Requests) with live timelines.
- Portable curl-based hook installer (idempotent jq merge).

Hardening from an adversarial review pass: deep-copy records out of the
store (data race), redact derived prompt/summary fields, bound WebSocket
writes, guard hook endpoint with the dashboard token, and degrade to a
no-op repository if the database cannot be opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…manager

Turn raw hook events into signal-ready structured data (Phase 3-lite).

- commands.go: ClassifyCommand categorizes shell commands
  (package_manager|test|lint|typecheck|build|git|migration|dangerous) and
  detects the JS package manager invoked; extractToolOutcome pulls exit code /
  success from a PostToolUse tool_response across its several shapes.
- Detect the repo package manager from lockfiles at SessionStart.
- A failing PostToolUse becomes a distinct tool.failure timeline event.
- SessionEvent gains command/commandCategory/packageManager/exitCode/success;
  Session gains packageManager. Commands are redacted before persistence.
- Additive SQLite columns with an idempotent ALTER TABLE migration so existing
  databases upgrade in place.
- Dashboard timeline renders command text, category/pm badges, exit codes, and
  failure styling; session header shows the package manager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ssues view

Turn the recorder into an insight engine (Phase 4).

- signals.go: deterministic detectors over enriched session events —
  repeated_failed_command, package_manager_mismatch, edited_generated_file,
  human_correction, token_bloat. Each yields an evidence-linked Signal with a
  severity and a suggestedFixType (rule|skill|hook|claude_md) for Phase 5.
- AggregateSignals groups per-session signals by type for a "top issues" view.
- API: GET /sessions/{id}/signals (per session) and GET /signals (aggregate
  across recent sessions), reusing the existing auth + CORS wrappers.
- Dashboard: a third "Signals" view (top recurring issues with severity,
  counts, suggested fix, and example sessions) plus a per-session Signals
  section in the detail pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review of the C/B changes surfaced six confirmed defects:

- tool.failure was effectively never emitted: the Bash PostToolUse payload is
  {interrupted,stderr,stdout,...} with no exit code, so extractToolOutcome
  returned unknown and detectRepeatedFailedCommand/the failure UI were dead.
  Register PostToolUseFailure in the installer and derive failure from the real
  `interrupted` field. (Full exit-code fidelity needs transcript ingestion.)
- A leading env-var assignment (NODE_ENV=production npm run build) fell through
  primarySegment and classified as unknown with no package manager; now strip
  leading VAR=val assignments and env/sudo/nice wrappers before classifying.
- `curl … | bash` and other dangerous patterns in piped/chained portions were
  unreachable (primarySegment stripped the pipe); scan the full command for
  dangerous patterns first. Also catch plain `chmod 777` (optional flag).
- GET /signals is O(sessions × events) and was refetched on every live
  SESSION_UPDATE; add a 5s server-side cache and switch the dashboard to a
  fixed-interval refresh keyed on new sessions only.
- token_bloat no longer overloads Signal.Count with the token total.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tier 1 of subagent/newer-feature awareness — persist fields the hook payload
already carries and start capturing subagent lifecycle.

- SessionEvent/TimelineEvent gain tool_use_id (pairs PreToolUse<->PostToolUse)
  and prompt_id (groups a user turn); additive columns + migration.
- Register SubagentStop and PreCompact hooks in the installer (already mapped in
  normalizeHookType), so Task-subagent completion and context compaction are
  recorded.
- Dashboard timeline groups events into turns by prompt_id and renders Task
  spawns as "Subagent" spans plus subagent.stop / context-compaction events.

Groundwork for Tier 2 (transcript ingestion), which will use the transcript's
requestId/isSidechain/toolUseResult for exact correlation, true subagent
hierarchy, and real tool outcomes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Open in Devin Review

Comment thread install/install-hooks.sh
Comment on lines +28 to +30
if [[ -n "$TOKEN" ]]; then
out="${out//-H 'content-type: application/json'/-H 'content-type: application/json' -H 'authorization: Bearer ${TOKEN}'}"
fi

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Hook installer never adds the auth token, so session recording silently stops when the proxy requires one

The bearer token is never inserted into the generated hook commands (the ${out//...} substitution at install/install-hooks.sh:29) because the replacement expression never matches, so every hook the CLI sends is rejected and nothing is recorded.

Impact: Anyone who protects the proxy with a token installs hooks that are silently refused, so no coding sessions, prompts or timelines ever appear in the dashboard.

Why the bash pattern substitution silently no-ops

In ${var//pattern/string} bash terminates the pattern at the first unquoted /. The pattern here contains application/json, so bash parses the pattern as -H 'content-type: application (quotes removed → -H content-type: application) and treats the rest as the replacement. That pattern never matches the rendered template text, so out is returned unchanged.

Verified locally:

$ out="curl -H 'content-type: application/json' --data-binary @-"; TOKEN=abc
$ echo "${out//-H 'content-type: application/json'/-H 'content-type: application/json' -H 'authorization: Bearer ${TOKEN}'}"
curl -H 'content-type: application/json' --data-binary @-   # unchanged

Because the hook commands end in >/dev/null 2>&1 || true, the resulting 401 from RequireToken (proxy/main.go:55) is invisible to the user.

Suggested change
if [[ -n "$TOKEN" ]]; then
out="${out//-H 'content-type: application/json'/-H 'content-type: application/json' -H 'authorization: Bearer ${TOKEN}'}"
fi
if [[ -n "$TOKEN" ]]; then
out="$(printf '%s' "$out" | sed "s#-H 'content-type: application/json'#-H 'content-type: application/json' -H 'authorization: Bearer ${TOKEN//#/\\#}'#g")"
fi
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread proxy/hooks.go
Comment on lines +100 to +113
sess, err := m.repo.GetSession(hook.SessionID)
if err != nil {
log.Printf("hook: get session: %v", err)
}
isNew := sess == nil
if isNew {
sess = &Session{
ID: hook.SessionID,
Provider: ProviderAnthropic,
CLI: "claude-code",
StartedAt: t,
Status: "active",
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 A momentary database read failure erases a session's accumulated token and request totals

A session that cannot be read back is treated as brand new and written out with zeroed counters (GetSession error path at proxy/hooks.go:100-113) instead of being left alone, so the previously accumulated totals for that session are permanently overwritten.

Impact: After a transient storage hiccup, an in-progress session's request count and token usage reset to zero in the dashboard and stay wrong for the rest of the session.

Read-modify-write cycle loses data on read error

ingest logs the error from m.repo.GetSession but then falls through to isNew := sess == nil, builds a fresh Session{} with InputTokens/OutputTokens/RequestCount == 0, and calls UpsertSession. The SQL upsert's ON CONFLICT(id) DO UPDATE (proxy/repository.go:79-96) unconditionally sets input_tokens, output_tokens and request_count from the new zeroed row, clobbering the stored values. It also re-broadcasts SESSION_START for an already-known session.

A safer shape is to bail out of the ingest (or skip the upsert of aggregate columns) when the read failed, distinguishing "not found" from "error".

Prompt for agents
In proxy/hooks.go, SessionManager.ingest calls m.repo.GetSession and, on error, only logs it before treating sess == nil as "new session". Because SQLiteRepository.UpsertSession overwrites input_tokens, output_tokens and request_count from the passed struct, a failed read causes the existing row's accumulated counters to be reset to zero, and a spurious SESSION_START is broadcast. Distinguish a genuine "not found" (nil, nil) from an error: on error, either abort the ingest for that hook (still returning 200 to the CLI) or avoid the upsert of aggregate columns. The same pattern exists in OnRequestStart/OnRequestComplete, which discard the error from GetSession entirely.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread proxy/commands.go
Comment on lines +98 to +110
func jsPackageManager(head string) string {
switch head {
case "npm", "npx":
return "npm"
case "pnpm", "pnpx":
return "pnpm"
case "yarn":
return "yarn"
case "bun", "bunx":
return "bun"
}
return ""
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Running npx in a pnpm or yarn repo is reported as a high-severity wrong-package-manager problem

Commands run through npx are recorded as if they used npm (jsPackageManager at proxy/commands.go:100-101), so in a repository whose lockfile says pnpm/yarn/bun every such command is counted as using the wrong package manager.

Impact: The Signals view shows false high-severity "wrong package manager" issues for perfectly normal commands like npx tsc, drowning out real findings.

Detector path and a related substring-matching issue

jsPackageManager maps npxnpm, pnpxpnpm, bunxbun. detectPackageManagerMismatch (proxy/signals.go:94-121) then flags any event whose PackageManager differs from the session's lockfile-derived manager, with severity high. npx <bin> is the conventional binary runner even in pnpm/yarn repos, so it should not count as an install-manager mismatch (only real manager subcommands such as install/add/run should).

Separately, classifyPMSub (proxy/commands.go:113-132) matches bare substrings against the whole command, so pnpm add @testing-library/react matches test and is categorized as a test run rather than a package install (latest, rebuild, etc. have the same problem). That only affects timeline labels, but it comes from the same classification code.

Prompt for agents
proxy/commands.go maps npx→npm, pnpx→pnpm and bunx→bun in jsPackageManager, and proxy/signals.go detectPackageManagerMismatch flags any event whose PackageManager differs from the session's lockfile-derived manager as a high-severity signal. Because npx is the standard binary runner regardless of the repo's package manager, this produces false positives. Consider distinguishing 'runner' invocations (npx/pnpx/bunx/`yarn dlx`) from real manager invocations — e.g. record the runner separately, or only set CommandInfo.PackageManager for manager subcommands (install/add/remove/run/test/...), so the mismatch detector only fires on genuine manager usage.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread proxy/api.go
Comment on lines +77 to +87
func tokenAuthorized(r *http.Request, token string) bool {
if constantTimeEqual(r.URL.Query().Get("token"), token) {
return true
}
const prefix = "Bearer "
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, prefix) {
return false
}
return constantTimeEqual(strings.TrimSpace(auth[len(prefix):]), token)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Control-plane token can be leaked via URL query parameter

tokenAuthorized accepts the dashboard bearer token from the token query parameter as well as the Authorization header (proxy/api.go:77-87). The dashboard puts the token into the WebSocket URL (dashboard/src/app/lib/api.ts:8-11), and the same check guards the REST endpoints and the hook ingestion endpoint. Query-string credentials end up in browser history, referrers, and any HTTP access logs, and are more likely to be shoulder-surfed or copied into bug reports than a header value.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread proxy/config.go
Comment on lines +19 to +25
cfg := Config{
ProxyAddr: envOr("CCPROXY_PROXY_ADDR", ":8080"),
ControlAddr: envOr("CCPROXY_CONTROL_ADDR", ":8081"),
DBPath: envOr("CCPROXY_DB_PATH", defaultDBPath()),
DashboardToken: os.Getenv("CCPROXY_DASHBOARD_TOKEN"),
MaxLiveItems: 1000,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Control plane is unauthenticated by default and binds to all interfaces

CCPROXY_DASHBOARD_TOKEN is unset by default (proxy/config.go:23), so RequireToken passes every request through unguarded, and the control plane listens on :8081 (all interfaces, proxy/config.go:21) rather than 127.0.0.1:8081. On a shared or untrusted network anyone who can reach the port can read /sessions, /sessions/{id}/timeline and the stored request records — which contain user prompts, file paths and source code — and can POST forged hook events to /events/claude-code. The WebSocket origin check only stops browsers, not direct clients (proxy/websocket.go:23-28 returns true when no Origin header is present).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment thread install/install-hooks.sh
Comment on lines +26 to +32
local out
out="$(sed "s#http://localhost:8081/events/claude-code#${URL//#/\\#}#g" "$TEMPLATE")"
if [[ -n "$TOKEN" ]]; then
out="${out//-H 'content-type: application/json'/-H 'content-type: application/json' -H 'authorization: Bearer ${TOKEN}'}"
fi
printf '%s\n' "$out"
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Hook installer writes the auth token in cleartext into the user's Claude settings file

When CCPROXY_DASHBOARD_TOKEN/CCPROXY_TOKEN is set, the installer is intended to embed -H 'authorization: Bearer <token>' directly into the hook command strings persisted in ~/.claude/settings.json (install/install-hooks.sh:28-30). The settings file is created with default permissions and is commonly committed or shared, so the control-plane token would be stored in cleartext alongside ordinary configuration.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant