> ## Documentation Index
> Fetch the complete documentation index at: https://quintsecurity.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# v1.0.3 — Session Attribution & Edge Stability

> session_id on every audit row, decoded timeline API, NE CPU watchdog fix, SSE streaming framing fix

# v1.0.3 — Session Attribution & Edge Stability

Released April 25, 2026. Three edge-side bugs closed and one API added. The local pipeline is now production-shape: every captured byte is attributable to a specific AI agent invocation, and the macOS extensions survive the load patterns that were killing them.

## What's New

### Every audit row is now session-attributable

Before this release, `audit_log` rows carried only a per-tunnel `trace_id`. Two simultaneous Claude Code sessions interleaved in the log with no way to group by invocation. Now every row stamps `session_id` (the unisession ID — `{rootPID}-{startUnixMs}`) and `process_pid` at capture time, pulled from `unisession.Tracker.SessionByPID`.

**What this enables:**

```sql theme={null}
-- Everything that happened in one claude session
SELECT timestamp, tool_name, arguments_json, response_json
FROM audit_log
WHERE session_id = '59247-1777085379101'
ORDER BY id ASC;
```

* Two terminals running `claude` in parallel → two distinct `session_id` values, cleanly separated
* Cloud `QuintEvent` payloads already carry `session_id` — local audit and cloud `actions` table now share the same key
* New `audit.QueryOpts.SessionID` / `ProcessPID` filters expose this via `/api/audit?session_id=...`

**Schema changes** (4 additive migrations):

```sql theme={null}
ALTER TABLE audit_log ADD COLUMN session_id TEXT;
ALTER TABLE audit_log ADD COLUMN process_pid INTEGER;
ALTER TABLE audit_log ADD COLUMN tool_use_id TEXT;
ALTER TABLE audit_log ADD COLUMN parent_session_id TEXT;
CREATE INDEX idx_session_id ON audit_log(session_id);
CREATE INDEX idx_tool_use_id ON audit_log(tool_use_id);
CREATE INDEX idx_parent_session_id ON audit_log(parent_session_id);
```

All migrations run on startup; no downtime. Pre-existing rows have `NULL` for these fields.

### Decoded Timeline API

New endpoint: `GET /api/sessions/timeline?pid=N` or `?session_id=X`.

Server-side decoder walks the full wire format:

```
audit_log.response_json
  → AWS eventstream binary framing
    → JSON wrapper with "bytes":"<base64>"
      → base64 decode
        → Anthropic SSE events
          → content_block_start / content_block_delta / content_block_stop
            → TimelineEvent { kind, host, model, tool_name, tool_input, text }
```

Returns a flat chronological timeline of `request`, `assistant_text`, and `tool_call` events — no base64 soup, no binary framing, fully reconstructed `tool_input` JSON for each tool use.

### Historical sessions visible after the process exits

`unisession.Tracker` reaps sessions \~10 seconds after their root PID exits. Before this release, that reaping erased captured session data from the viewer even though it remained in `audit_log`. New helper `audit.DB.HistoricalSessions(limit)` groups audit rows by `(session_id, process_pid)` so ended sessions remain discoverable. `/api/es/sessions` merges live tracker state with historical audit data, ordered by `MAX(timestamp)` descending.

## Fixed

### SSE responses no longer hang Claude Code

A prior fix attempted to re-chunk SSE responses on egress but was looking at the wrong place for the upstream `Transfer-Encoding`. `http.ReadResponse` strips `Transfer-Encoding: chunked` from the header map and stashes it in `resp.TransferEncoding`. The fix always re-emits SSE and AWS eventstream responses as chunked regardless of upstream framing — the only way a streaming response on a keep-alive connection can signal end-of-stream to the client.

**Before:** Claude Code completed the visible response, then hung on the spinner forever. Every subsequent turn on the same keep-alive connection stalled.

**After:** Responses terminate cleanly. `/debug/flows` shows 0 stuck flows across multi-turn sessions.

### NE extension stops burning 99% CPU

macOS's `cpu_resource` watchdog was SIGKILLing the NetworkExtension every \~90 seconds under load. Diagnostic report showed all 35 stack samples identical: `emitFlowEvent → sendRaw → write (libsystem_kernel)`.

Root cause: NE's socket to the daemon is `O_NONBLOCK`. When the daemon was slow or its read loop stalled, `write()` returned `-1` with `errno=EAGAIN`, and the socket client's `if errno == EAGAIN { continue }` pegged a CPU core. macOS's watchdog kicked in after 50% average over 180 seconds.

Fix: replaced the hot loop with a **per-frame 50 ms poll budget**. On `EAGAIN`, call `poll(POLLOUT)` with the remaining budget. If `poll()` returns 0 (timeout), drop the frame and reconnect the socket. Telemetry is lossy-at-the-edge by design — dropping an event is preferable to killing the extension.

**Before:** NE process died every 90 seconds under load. Required bouncing the QuintAgent app to recover.

**After:** NE survives arbitrary daemon backpressure. Verified with `strings` on the installed binary:

```bash theme={null}
strings /Library/SystemExtensions/*/com.quint.security.ne-extension.systemextension/Contents/MacOS/QuintNetworkExtension | grep backpressure
# Write timed out (socket backpressure), dropping frame
```

### Tool result capture in audit DB raised from 1 KB to 10 KB

The audit write path was truncating tool results at 1 KB, which cut off most Bash outputs mid-line. Raised to 10 KB. The LLM parser upstream already caps at 10 KB, so this just aligns the two.

### Tool results no longer dropped on the pairing turn

`tool_use` events are echoed on every subsequent turn of an Anthropic conversation, but the corresponding `tool_result` lands in the *next* request's message array — produced locally by the client after the tool ran. The prior `toolDedup.Seen(id)` check dropped that second sighting, and with it the `tool_result` payload.

Replaced with `toolDedup.Observe(id, hasResult)` returning one of three states:

* `FirstSeen` — brand-new `tool_use`, full insert path (scoring, audit row, pipeline event)
* `ResultAdded` — same `tool_use` seen again, but now carrying the `tool_result`. Takes the **backfill path**: `UPDATE audit_log SET response_json = ? WHERE tool_use_id = ? AND response_json IS NULL`, plus a supplement pipeline event tagged `source: "tool_result"` with `tool_use_id` in labels. No re-scoring, no duplicate forwarding.
* `Duplicate` — nothing new, dropped.

End-to-end verified: `QUINT_SENTINEL_abc123` captured verbatim, `/etc/hosts` contents captured, env dumps captured in the live viewer timeline.

### Risk scores now populated on daemon audit writes

Gateway mode has been writing `risk_score` / `risk_level` on `tools/call` audit rows since v1.0.0, but the daemon (`callbacks.go::onToolCall`) was not — every daemon-captured row had `NULL` scoring fields. Fixed by threading `tcResult.RiskScore` (value, level, base score, scoring source) into the `audit.Entry` at insert time, mirroring the gateway pattern. The local viewer now renders risk badges (low/medium/high/critical) inline on every tool call event.

## Sub-Agent Detection: In-Process Task Dispatch

New detector in `forwardproxy.SubagentDetector` that identifies when Claude Code's in-process `Task` / `Agent` tool spawns a child session **without** a new CONNECT tunnel or process. Existing passive detection covers cross-process spawns; this handles the case where the child runs inside the parent's tunnel.

**Mechanism:**

* On every POST with a JSON body, hash the top-level `system` prompt with FNV-1a
* Track `(tunnelKey → {parentHash, currentHash, msgCount})` state
* Gate `subagent_start`: hash changed AND `messages.length <= 2` (fresh conversation inside the same tunnel)
* Gate `parent_resume`: hash reverted to `parentHash` AND `messages.length > 2` (parent turn appended to prior history)

Child session IDs are minted as `{parentSession}:sub:{counter}`, threaded through `AgentToolEvent.ParentSessionID` on both request-parse and response-parse emission sites, and written to `audit_log.parent_session_id`. The viewer renders nested rows with a purple left border and `sub` badge under their parent session.

Edge cases handled:

* Mid-session prompt rewrites (MCP servers connecting / CLAUDE.md edits change the hash but keep `msgCount > 2`) — **not** classified as subagent
* Multiple sequential subagents in the same parent — counter increments (`:sub:1`, `:sub:2`, `:sub:3`)
* Separate CONNECT tunnels keep independent detector state

Covered by 5 unit tests in `subagent_test.go`. Verified live: `:sub:2` and `:sub:3` minted correctly during a real Claude Code session with two Task dispatches.

## Developer Experience

### Local viewer drill-down

`http://localhost:8080/viewer?token=<dashboard-token>` gained session drill-downs. Click a session card → inline timeline with tool calls, assistant text, and request metadata. Cards sort by most-recent activity (live + ended together). Auto-refresh preserves expanded drill-downs and scroll position. Keyboard shortcuts: `/` focuses search, `Esc` collapses all. "Copy JSON" button per timeline dumps the decoded events for debugging.

This is an internal admin surface — not part of the customer-facing dashboard, but useful for validating that the local pipeline is capturing what it should.

## Migration Notes

* Additive-only schema changes; no code or config changes required by callers
* `audit.LogOpts` gained `SessionID string` and `ProcessPID int` fields — existing call sites that don't populate them continue to work (stored as `NULL`)
* `forwardproxy.Options` gained `SessionLookup func(pid int) string` — optional; `nil` leaves rows un-attributed
* Cloud `QuintEvent` schema unchanged — `session_id` field was already present; callers just populate it more consistently now

## What's Next

* NE auto-reconnect on daemon restart (today: NE survives backpressure but can still detach cleanly on a daemon crash — reconnect is lazy on next send)
* End-to-end integration test harness covering the two interception paths + session attribution + cloud delivery
* Code-signing path cleanup for extension builds (currently `xcodebuild -configuration Release` is the only supported install path; `swift build` + ad-hoc codesign produces a duplicate-TeamID bundle that macOS treats as a separate extension)
