Skip to content

fix(agent): pin the agent's secure-storage dir so no keychain login leaks - #1191

Draft
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixagent-keep-the-macos-keychain-claude-2015d6
Draft

fix(agent): pin the agent's secure-storage dir so no keychain login leaks#1191
posthog[bot] wants to merge 1 commit into
mainfrom
posthog-self-driving/fixagent-keep-the-macos-keychain-claude-2015d6

Conversation

@posthog

@posthog posthog Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Problem

  • A stored Claude login on the user's machine can still stop a wizard run at the auth-error screen — a hard stop at the start of setup, with no in-product workaround.
  • v2.71.0 closed the file half: the agent subprocess gets an empty CLAUDE_CONFIG_DIR, so the binary finds no .credentials.json or .claude.json.
  • The keychain half stayed reachable in one case. CLAUDE_SECURESTORAGE_CONFIG_DIR sits outside the ANTHROPIC_* / CLAUDE_CODE_* namespace sanitizeAgentSubprocessEnv strips, so a value in the user's shell survives into the subprocess and points the keychain lookup back at the real login item.
  • The doc comment claimed the job was done for both halves. It was not.

Changes

  • Mechanism: pin the binary's secure store, not just its config dir. One isolatedAgentCredentialEnv() returns both vars set to the same fresh per-run temp dir; both spawn sites spread it.
  • The binary names the keychain item after that dir, so a throwaway dir names an item that does not exist and the lookup finds nothing.
  • Setting the var explicitly also overrides any inherited shell value, which is the part the namespace strip cannot do.
  • Corrected the comments that said the file-dir fix already covered the keychain.
Credential path Before After
~/.claude/.credentials.json, ~/.claude.json isolated isolated
Keychain, plain CLAUDE_CONFIG_DIR isolation isolated by a derived, undocumented service-name suffix isolated by an explicitly pinned dir
Keychain, CLAUDE_SECURESTORAGE_CONFIG_DIR set in the user's shell reachable isolated

Test plan

  • pnpm build && pnpm test && pnpm fix — 2586 tests pass.
  • New unit tests cover the two-var contract and the per-run-fresh-dir guarantee.
  • Ran the real SDK binary (@anthropic-ai/claude-agent-sdk@0.3.169) against a local HTTP probe that logs auth headers, with a planted stored credential:
Probe results
Case Header the binary sent
No isolation authorization: Bearer <gateway> and x-api-key: <stored key> → the gateway 401s
Config dir isolated authorization: Bearer <gateway> only
Both dirs pinned (this PR) authorization: Bearer <gateway> only; the subprocess runs normally and writes its scratch state into the temp dir

This is why the failure is a 401 and not a silent bill: a stored login travels as x-api-key, which the binary resolves separately from the ANTHROPIC_AUTH_TOKEN the wizard injects, and the gateway accepts only its own token.

Note

Scope check, because it narrows the linked report. Reading the SDK binary shows the keychain lookup is path-scoped after all — the service name carries a hash of the config dir — so the shipped CLAUDE_CONFIG_DIR fix already covered most macOS users as a side effect. What remains is the CLAUDE_SECURESTORAGE_CONFIG_DIR override above, plus the risk of leaning on undocumented SDK behaviour across upgrades. The error-screen numbers in the report predate v2.71.0, so they cannot yet show how much is left; worth re-reading them a week after this ships.

LLM context

Investigated by decompiling the pinned SDK's macOS binary to confirm the credential resolution order, then reproducing header-by-header against a local probe server. Considered and rejected: adding CLAUDE_SECURESTORAGE_CONFIG_DIR to the namespace strip in agent-env-isolation.ts — unsetting it makes the binary fall back to the default keychain item, which is the login being defended against, so the var has to be pinned rather than removed. Also left alone: the shared spawn-site env recipe, which would be a wider refactor than this fix needs.


Created with PostHog Desktop from this inbox report.

…eaks

The agent subprocess got an empty CLAUDE_CONFIG_DIR, which closed the
file half of a stored Claude login. The macOS keychain half stayed open
whenever a user shell set CLAUDE_SECURESTORAGE_CONFIG_DIR: that var sits
outside the ANTHROPIC_*/CLAUDE_CODE_* namespace the wizard strips, and
the binary derives the keychain service name from it.

Both spawn sites now take one env pair from isolatedAgentCredentialEnv(),
which points CLAUDE_CONFIG_DIR and CLAUDE_SECURESTORAGE_CONFIG_DIR at the
same per-run temp dir. The keychain lookup then names an item that does
not exist, so it finds nothing.

Generated-By: PostHog Desktop
Task-Id: 658d6c4a-2e6b-4936-9845-77c4fa863fd2
@posthog

posthog Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

🦔 PostHog Review reviewed this pull request

Found 0 must fix, 1 should fix, 0 consider.

Published 1 finding (view the review).

Resolved comments: 1 left for you

@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🧙 Wizard CI

Run the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands:

Test all apps:

  • /wizard-ci all

Test all apps in a directory:

  • /wizard-ci ai-observability
  • /wizard-ci basic-integration
  • /wizard-ci mcp-analytics
  • /wizard-ci replay-vision
  • /wizard-ci revenue
  • /wizard-ci self-driving
  • /wizard-ci warehouse
  • /wizard-ci warehouse-seeded

Test an individual app:

  • /wizard-ci ai-observability/anthropic
  • /wizard-ci ai-observability/groq
  • /wizard-ci ai-observability/manual-capture
Show more apps
  • /wizard-ci ai-observability/openai
  • /wizard-ci ai-observability/openai-agents
  • /wizard-ci ai-observability/vercel-ai
  • /wizard-ci basic-integration/android
  • /wizard-ci basic-integration/angular
  • /wizard-ci basic-integration/astro
  • /wizard-ci basic-integration/django
  • /wizard-ci basic-integration/fastapi
  • /wizard-ci basic-integration/flask
  • /wizard-ci basic-integration/flutter
  • /wizard-ci basic-integration/javascript-node
  • /wizard-ci basic-integration/javascript-web
  • /wizard-ci basic-integration/laravel
  • /wizard-ci basic-integration/next-js
  • /wizard-ci basic-integration/nuxt
  • /wizard-ci basic-integration/python
  • /wizard-ci basic-integration/rails
  • /wizard-ci basic-integration/react-native
  • /wizard-ci basic-integration/react-router
  • /wizard-ci basic-integration/sveltekit
  • /wizard-ci basic-integration/swift
  • /wizard-ci basic-integration/tanstack-router
  • /wizard-ci basic-integration/tanstack-start
  • /wizard-ci basic-integration/vue
  • /wizard-ci mcp-analytics/custom-dispatcher
  • /wizard-ci mcp-analytics/typescript-sdk
  • /wizard-ci replay-vision/javascript-node
  • /wizard-ci replay-vision/next-js
  • /wizard-ci replay-vision/react-vite
  • /wizard-ci revenue/stripe
  • /wizard-ci self-driving/astro
  • /wizard-ci self-driving/fastapi
  • /wizard-ci self-driving/nuxt
  • /wizard-ci self-driving/react-router
  • /wizard-ci self-driving/sveltekit
  • /wizard-ci warehouse/monorepo-env
  • /wizard-ci warehouse/multi-source-next
  • /wizard-ci warehouse/stripe-node
  • /wizard-ci warehouse/zero-source
  • /wizard-ci warehouse-seeded/next-stripe
  • /wizard-ci warehouse-seeded/next-stripe-declined

Test against a Context Mill branch:

  • /wizard-ci all context-mill:my-branch

Add context-mill:<branch> to any command above to pin the Context Mill branch. It defaults to main.

Results will be posted here when complete.

@posthog

posthog Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

PostHog Review alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

PostHog Review

Found 1 should fix.

Comment on lines +370 to +372
// Per-run empty config + secure-storage dirs, so no stored Claude
// login reaches the gateway and 401s the run. See stored-login.ts.
...isolatedAgentCredentialEnv(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fresh config directories break resumed tutorial sessions

should_fix bug

Why we think it's a valid issue
  • Checked: the pinned SDK itself. Downloaded and unpacked @anthropic-ai/claude-agent-sdk@0.3.169 (the version pinned at package.json:35) and read its typings and bundle, rather than reasoning from the snippet.
  • Found: the SDK ties resume to the config dir. sdk.d.ts documents persistSession as "When false, disables session persistence to disk. Sessions will not be saved to ~/.claude/projects/ and cannot be resumed later. @default true", and the sessionStore doc says "the subprocess still writes to CLAUDE_CONFIG_DIR". In sdk.mjs the config root resolves as (process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude")).normalize("NFC"), and lookup failures raise Session ${id} not found in project directory / Session ${id} not found (no projects directory).
  • Found: isolatedAgentCredentialEnv() calls mkdtempSync on every invocation (stored-login.ts:57-60, stored-login.ts:90). The PR's own test asserts this at __tests__/stored-login.test.ts:129-131 — "returns a distinct directory on each call". So prompt 2 spawns against a different, empty root than prompt 1 wrote its transcript into.
  • Found: the resume path is a shipped feature, not a corner. McpSuggestedPromptsScreen.tsx:412 passes resumeSessionId: currentSessionIdRef.current, which mcp-prompt-streaming.ts:309 turns into resume. MAX_PROMPT_RUNS = 5 (McpSuggestedPromptsScreen.tsx:121) with a dedicated Phase.FollowUp, so every tutorial user who picks a second prompt hits it. The code states the intent it no longer delivers: mcp-prompt-streaming.ts:202-205 and :298-300 describe resumed follow-ups keeping prior turns as context.
  • Found: no mitigation applies. The SDK's resume-materialization temp dir (claude-resume-*) only runs on the sessionStore path, which this call site does not use.
  • Found: the root cause landed one commit earlier. git diff FETCH_HEAD HEAD shows main already had CLAUDE_CONFIG_DIR: createIsolatedAgentConfigDir() here, added by 919e7d1 (fix(agent): isolate CLAUDE_CONFIG_DIR so a stored Claude login cannot 401 the run #1180, v2.71.0). This PR replaces that line with the two-var spread at mcp-prompt-streaming.ts:372.
  • Impact: every follow-up prompt in the MCP tutorial resumes a session id whose transcript is unreachable. The run either errors — surfaced to the user through the error chunk at mcp-prompt-streaming.ts:400-403 and McpSuggestedPromptsScreen.tsx:430-433 — or silently starts fresh and drops the conversation the follow-up suggestions refer to. The trigger and the consequence are both concrete and deterministic, so this clears the bar even though the PR only re-expresses the line. It is the right moment to fix, because the change edits that exact line and adds a test that locks in the per-call fresh directory.
Issue description

The SDK stores resumable sessions under CLAUDE_CONFIG_DIR because persistSession defaults to true. This call creates a new directory for every prompt, including prompts with resumeSessionId. The resumed process cannot find the first prompt's transcript. Follow-up prompts can fail or lose their prior context.

Suggested fix

Create one isolated credential directory for each tutorial conversation. Reuse both environment values for resumed prompts. Create a new directory only when the user starts a new conversation. Add a test that checks path reuse for follow-ups and path replacement after reset.

Prompt to fix with AI (copy-paste)
## Context
@src/lib/agent/mcp-prompt-streaming.ts#L370-372

<issue_description>
The SDK stores resumable sessions under `CLAUDE_CONFIG_DIR` because `persistSession` defaults to `true`. This call creates a new directory for every prompt, including prompts with `resumeSessionId`. The resumed process cannot find the first prompt's transcript. Follow-up prompts can fail or lose their prior context.
</issue_description>

<issue_validation>
- **Checked:** the pinned SDK itself. Downloaded and unpacked `@anthropic-ai/claude-agent-sdk@0.3.169` (the version pinned at `package.json:35`) and read its typings and bundle, rather than reasoning from the snippet.
- **Found:** the SDK ties resume to the config dir. `sdk.d.ts` documents `persistSession` as "When false, disables session persistence to disk. Sessions will not be saved to ~/.claude/projects/ and cannot be resumed later. @default true", and the `sessionStore` doc says "the subprocess still writes to CLAUDE_CONFIG_DIR". In `sdk.mjs` the config root resolves as `(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude")).normalize("NFC")`, and lookup failures raise `Session ${id} not found in project directory` / `Session ${id} not found (no projects directory)`.
- **Found:** `isolatedAgentCredentialEnv()` calls `mkdtempSync` on every invocation (`stored-login.ts:57-60`, `stored-login.ts:90`). The PR's own test asserts this at `__tests__/stored-login.test.ts:129-131` — "returns a distinct directory on each call". So prompt 2 spawns against a different, empty root than prompt 1 wrote its transcript into.
- **Found:** the resume path is a shipped feature, not a corner. `McpSuggestedPromptsScreen.tsx:412` passes `resumeSessionId: currentSessionIdRef.current`, which `mcp-prompt-streaming.ts:309` turns into `resume`. `MAX_PROMPT_RUNS = 5` (`McpSuggestedPromptsScreen.tsx:121`) with a dedicated `Phase.FollowUp`, so every tutorial user who picks a second prompt hits it. The code states the intent it no longer delivers: `mcp-prompt-streaming.ts:202-205` and `:298-300` describe resumed follow-ups keeping prior turns as context.
- **Found:** no mitigation applies. The SDK's resume-materialization temp dir (`claude-resume-*`) only runs on the `sessionStore` path, which this call site does not use.
- **Found:** the root cause landed one commit earlier. `git diff FETCH_HEAD HEAD` shows main already had `CLAUDE_CONFIG_DIR: createIsolatedAgentConfigDir()` here, added by `919e7d1` (#1180, v2.71.0). This PR replaces that line with the two-var spread at `mcp-prompt-streaming.ts:372`.
- **Impact:** every follow-up prompt in the MCP tutorial resumes a session id whose transcript is unreachable. The run either errors — surfaced to the user through the `error` chunk at `mcp-prompt-streaming.ts:400-403` and `McpSuggestedPromptsScreen.tsx:430-433` — or silently starts fresh and drops the conversation the follow-up suggestions refer to. The trigger and the consequence are both concrete and deterministic, so this clears the bar even though the PR only re-expresses the line. It is the right moment to fix, because the change edits that exact line and adds a test that locks in the per-call fresh directory.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Create one isolated credential directory for each tutorial conversation. Reuse both environment values for resumed prompts. Create a new directory only when the user starts a new conversation. Add a test that checks path reuse for follow-ups and path replacement after reset.
</potential_solution>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed this is a real bug against the current head, and I'm escalating it for a human decision rather than fixing it unattended.

What happens: the MCP tutorial resumes sessions for follow-up prompts (it carries the previous run's session id forward as resumeSessionId, up to 5 prompts per session). But each run is handed a brand-new, empty per-run config directory — the isolation helper creates a fresh temp dir on every call, and a test in this PR deliberately locks that in ("a distinct directory on each call"). Because the SDK keeps resumable transcripts under that config directory, a resumed follow-up looks for the first prompt's transcript in a directory that never held it. The result is either a "session not found" error surfaced to the user or a silent fresh start that drops the conversation the follow-up suggestions refer to. The root cause actually shipped in v2.71.0 with the original config-dir isolation, but this PR edits the exact line, so it's a reasonable place to address it.

Why it needs a human: fixing it means reconciling two goals that currently conflict — per-run credential isolation wants a fresh, empty directory, while session resume wants a stable directory for the whole conversation. The natural fix is one isolated directory per tutorial conversation (created when the user picks a new prompt, reused for follow-ups, reset on a new conversation), which requires threading that directory through the screen → services → streaming runner, changing the isolation helper's contract, and updating its locking test. Two things put it beyond an unattended fix: (1) its correctness — that resume actually recovers the transcript once the directory is stable — can only be proven by running the real Agent SDK against a resumed session, which this pass can't do; and (2) the helper is credential-leak-prevention code, so changing how its directory is created and reused shouldn't be done without a human confirming it doesn't reopen a leak path.

Decision needed: adopt the per-conversation stable isolated directory (and confirm via a real resumed-session run that it fixes resume without reintroducing a stored-login leak), or decide the tutorial shouldn't resume at all (drop resume here and accept each prompt as standalone).

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.

0 participants