Skip to content

fix(claude-sdk-oauth): trusted restart continuity bindings - #959

Merged
code-yeongyu merged 12 commits into
mainfrom
fix/issue-6981-headless-continuity
Aug 18, 2026
Merged

fix(claude-sdk-oauth): trusted restart continuity bindings#959
code-yeongyu merged 12 commits into
mainfrom
fix/issue-6981-headless-continuity

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Summary

Headless Claude SDK OAuth continuation (senpi -p -c) now restores its SDK lineage across separate CLI processes through a trusted private sidecar instead of importable session-file data, so a restart sends only the new turn while tampered, stale, or inherited state fails closed. This is the security-corrected follow-up to #943; it supersedes #954 (whose implementation failed the five-lane review) and #809, and it carries the corrections from this PR's own review round.

Changes

Lineage capability leaves the session file (session-binding-store.ts, session-binding.ts)

  • Successful turns write a fixed-size, schema-strict record to a mode-0600 sidecar; the session JSONL carries only a capability-free {schemaVersion: 2, marker: true} entry
  • Restoration requires the record to belong to the current session file and header id, its exact marker entry to remain on the active branch, the adjacent committed assistant to hash-match, and only display-only metadata to follow; legacy schema-1 payloads are never lineage authority
  • Records are keyed by canonical session path, so a symlinked directory or another spelling resolves to one sidecar
  • Branch-derived and context-derived sent hashes share one selection rule, enforced inside the hashing function itself, so the two can never disagree and report a false divergence

Records anchor at branch state, not the binding map (session-registry-wiring.ts)

  • The record derives from the resident registry entry plus the sent-hashes the branch actually carries. The process binding map is no longer read at message_end: it holds the previous turn's state while that handler runs, and only a prefix digest right after a restart — reading it anchored a stale sent-stream (duplicate resend after restart) or threw on the restored shape (orphaned marker, silent flatten)
  • The safe-suffix allowlist admits the full display-only family (stop-hook state/diagnostics/output, rule activations, rule scans), so Stop-hook projects no longer fail restore on every restart
  • Non-clean commit outcomes never anchor a record; orphaned records are deleted rather than left on disk

Restored state is verified before resume (session-restored-admission.ts, session-reattach.ts, sdk-boundary.ts, session-continuity.ts)

  • Persisted account/model/prompt/toolset drift flattens (cold-seed) instead of resuming; live-entry drift behavior is unchanged
  • Before any resumed delta, verifyRestoredTranscript reads the local SDK transcript and requires the stored top-level assistant boundary; missing, mismatched, nested-only, throwing, or config-dir roots fail closed

Lifecycle boundaries invalidate durably (session-registry-wiring.ts)

  • Leaving the provider, accepted compaction, tree navigation, assistant rewrites, fork starts, and extension removal forget the process binding, delete the sidecar, and append a ledger invalidation
  • Forked sessions no longer inherit the parent lineage — reproduced against the real SessionManager and confirmed non-reproducing

Structure (session-turn-attempt.ts, session-stream.ts) — turn execution extracted; every touched file is under the 250 pure-LOC ceiling (session-stream.ts 197).

QA & Evidence

  • npm run check (biome, pinned-deps, ts-imports, shrinkwrap, install-lock, platform-lock, tsc --noEmit, browser-smoke): PASS
  • Focused security cluster (7 files): 44 passed
  • Complete claude-sdk-oauth suite: 49 files, 398 passed, 3 skipped
  • Full coding-agent suite: 997 files, 8199 passed, 36 skipped, 0 failures (bounded workers; the footer-data-provider reftable debounce test is timing-flaky under contention and passes 9/9 in isolation)
  • Real two-process headless restart probe: PASS — second process deltaMessages: 1, no flatten, no full-history replay; the probe reports sidecar identity and branch entry sequence on failure
  • Independent five-lane review: goal PASS, QA PASS (55/55 adversarial tamper checks fail closed), context PASS, code-quality FAIL on the first head → both blocking findings fixed, then re-reviewed with all six findings confirmed closed; security PASS twice, the second pass re-confirmed at the final head after a 12-shape adversarial sweep over the shared filter (0 fail-open) and symlink/traversal/cross-session probes against the canonical-path rewrite
  • RED→GREEN: the sidecar, anchor, lifecycle, and restored-security suites were written failing first, then turned green by these commits. The hash-parity test was additionally mutation-verified (fails [2 items] vs [1] with the filter removed) by me and independently by both reviewers

Risks & Residuals

  • Trust boundary: the session JSONL is untrusted (importable, hand-editable) and holds only a marker; the sidecar is trusted — mode 0600, strict schema, bounded, keyed to one canonical path and session id. Integrity rests on same-user file ownership, the same boundary as the SDK's own config dir
  • Restart resume cold-seeds on identity drift where fix(claude-sdk-oauth): harden persisted restart checkpoints #954 resumed with an explicit reason — intentional; observable as a one-time full resend after account/model/prompt changes across restart
  • The config-dir auth lane disables persisted restart resume (its transcript root cannot be addressed without a process-global env swap); same-lane continuation is unaffected
  • Each committed assistant appends one small marker entry and rewrites the fixed-size sidecar, so session files grow by one marker per assistant message while the record stays constant-size
  • Restart resume declines to anchor when a compaction boundary sits on the branch (the walk is not compaction-aware while admission compares against the truncated context), so such a session gets no restart resume rather than a silently wrong one. Unreachable while this lane is active, since it stands senpi compaction down
  • A legacy or hand-edited user message with a null content diverges between the two hash derivations; it fails closed (cold-seed) and falls in the untrusted-input class above
  • verifyRestoredTranscript requires the stored assistant boundary to exist in the transcript, not to be its tip
  • Cubic review: quota exhausted (80,038/80,000 lines this month; resumes 2026-09-01) — recorded as the allowed skip

Related Issues

Persist only bounded, ledger-verified restart checkpoints and fail closed across malformed state, branch rewrites, compaction, forks, and tree navigation. Strengthen lifecycle coverage and the two-process restart probe so full-history replay cannot pass unnoticed.
Move persisted SDK lineage authority out of importable session JSONL into a strict mode-0600 sidecar. Anchor restoration to the current session, exact marker, committed assistant hash, and fail-closed lifecycle invalidation.
Require persisted identity equality and a matching top-level assistant in the local SDK transcript before resume. Fail closed for config-dir or unreadable transcripts and isolate turn execution into a focused module.
Report the hermetic sidecar identity and branch entry sequence on probe failure without exposing message content, making marker and suffix admission regressions directly actionable.
Document the private sidecar authority, marker and assistant anchoring, transcript verification, and fail-closed lifecycle policy introduced by the security review.
Derive the persisted record from the registry entry and the hashes the branch actually carries instead of the process binding map, which holds the previous turn's state during message_end and only a prefix digest after a restart. Admit the full stop-hook and rule-activation metadata family after an anchor, key records by canonical session path, ignore non-clean commit outcomes, delete orphaned records, and drop pending-fork labels that lost their producers.
Document the branch-state anchoring, the widened metadata allowlist, canonical record keying, the untrusted-JSONL versus trusted-sidecar boundary, and the per-assistant marker growth surfaced by the security review.
Branch-derived and context-derived sent hashes now run the same isTransmittedMessage filter. The branch path previously kept content-less user messages that the context path skips, so a single such message shifted every later index and made a restart report a false divergence.
Replace the Reflect.get loader, its runtime type guard, and the locally redeclared boundary type with plain imports now that verifyRestoredTranscript is exported and SdkBoundary carries getSessionMessages.
sentMessageHashes now applies isTransmittedMessage itself, so a future caller cannot produce a hash list that disagrees with another caller's by omitting the filter. Idempotent for both current callers.
The branch walk is not compaction-aware while admission compares against the compaction-truncated context, so anchoring across a boundary inflated sentCount and flattened every later restart. Decline to anchor instead, and document that residual alongside the content-normalization and transcript-tip residuals from the security review.
The context path converts custom_message and branch_summary entries to user messages and hashes them, while the branch walk skips them. The branch under-counts rather than over-counts, so a restart flattens or re-sends as delta and the cost is a lost cache, not a wrong resume. Record it with the other residuals.
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.

Headless -p -c cache-misses every turn (flatten/registry_miss) — ~2x cost vs claude -p -c

1 participant