Skip to main content

Documentation Index

Fetch the complete documentation index at: https://quintsecurity.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

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:
-- 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):
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:
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)