From f3dd15227c037e99debe4b31878ac394ca9b08ea Mon Sep 17 00:00:00 2001 From: reasv <7143787+reasv@users.noreply.github.com> Date: Sun, 19 Apr 2026 02:05:46 +0200 Subject: [PATCH 1/4] fix(server): prevent probeClaudeCapabilities from leaking API requests Co-Authored-By: Claude Opus 4.6 --- .../server/src/provider/Layers/ClaudeProvider.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 5274f84285b..ea9ec4b8037 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -14,6 +14,7 @@ import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SlashCommand as ClaudeSlashCommand, + type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -484,9 +485,11 @@ function dedupeSlashCommands( * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. * - * The prompt is never sent to the Anthropic API — we abort immediately - * after the local initialization phase completes. This gives us the - * user's subscription type without incurring any token cost. + * We pass a never-yielding AsyncIterable as the prompt so that no user + * message is ever written to the subprocess stdin. This means the Claude + * Code subprocess completes its local initialization IPC (returning + * account info and slash commands) but never starts an API request to + * Anthropic. We read the init data and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include * subscription type information. @@ -495,12 +498,15 @@ const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { const q = claudeQuery({ - prompt: ".", + prompt: (async function* (): AsyncGenerator { + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + await new Promise(() => {}); + })(), options: { persistSession: false, pathToClaudeCodeExecutable: binaryPath, abortController: abort, - maxTurns: 0, settingSources: ["user", "project", "local"], allowedTools: [], stderr: () => {}, From 859785ff0b502c810b5fa2f3e92191f444cebe10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 10:48:23 -0700 Subject: [PATCH 2/4] Fix Claude capability probe to avoid API prompt leak - wait on abort instead of a never-settling promise - add tests for abort-signal behavior --- .../provider/Layers/ClaudeProvider.test.ts | 27 +++++++++++++++++++ .../src/provider/Layers/ClaudeProvider.ts | 12 ++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/Layers/ClaudeProvider.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.test.ts b/apps/server/src/provider/Layers/ClaudeProvider.test.ts new file mode 100644 index 00000000000..a264f8eb5e2 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeProvider.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { waitForAbortSignal } from "./ClaudeProvider.ts"; + +describe("waitForAbortSignal", () => { + it("stays pending until the signal aborts", async () => { + const abort = new AbortController(); + let settled = false; + const waitPromise = waitForAbortSignal(abort.signal).then(() => { + settled = true; + }); + + await Promise.resolve(); + expect(settled).toBe(false); + + abort.abort(); + await waitPromise; + expect(settled).toBe(true); + }); + + it("resolves immediately for an already-aborted signal", async () => { + const abort = new AbortController(); + abort.abort(); + + await expect(waitForAbortSignal(abort.signal)).resolves.toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ea9ec4b8037..b29e495c2f6 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -481,6 +481,15 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } +export function waitForAbortSignal(signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + return new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(), { once: true }); + }); +} + /** * Probe account information by spawning a lightweight Claude Agent SDK * session and reading the initialization result. @@ -499,9 +508,10 @@ const probeClaudeCapabilities = (binaryPath: string) => { return Effect.tryPromise(async () => { const q = claudeQuery({ prompt: (async function* (): AsyncGenerator { + yield* [] as ReadonlyArray; // Never yield — we only need initialization data, not a conversation. // This prevents any prompt from reaching the Anthropic API. - await new Promise(() => {}); + await waitForAbortSignal(abort.signal); })(), options: { persistSession: false, From 039d7c69b372b5ae00220e138ad955944b633fc5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 10:49:20 -0700 Subject: [PATCH 3/4] Prevent capability probe from leaking API prompts - Keep Claude probe prompt from yielding any user content - Avoid sending initialization-only probe data to Anthropic --- apps/server/src/provider/Layers/ClaudeProvider.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b29e495c2f6..436b4ec2261 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -507,10 +507,10 @@ const probeClaudeCapabilities = (binaryPath: string) => { const abort = new AbortController(); return Effect.tryPromise(async () => { const q = claudeQuery({ + // Never yield — we only need initialization data, not a conversation. + // This prevents any prompt from reaching the Anthropic API. + // oxlint-disable-next-line require-yield prompt: (async function* (): AsyncGenerator { - yield* [] as ReadonlyArray; - // Never yield — we only need initialization data, not a conversation. - // This prevents any prompt from reaching the Anthropic API. await waitForAbortSignal(abort.signal); })(), options: { From 2df510b092ec0b71a4d431677c2f39ab0e2155a6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 19 Apr 2026 17:05:19 -0700 Subject: [PATCH 4/4] kewl --- .../provider/Layers/ClaudeProvider.test.ts | 27 ------------------- .../src/provider/Layers/ClaudeProvider.ts | 2 +- 2 files changed, 1 insertion(+), 28 deletions(-) delete mode 100644 apps/server/src/provider/Layers/ClaudeProvider.test.ts diff --git a/apps/server/src/provider/Layers/ClaudeProvider.test.ts b/apps/server/src/provider/Layers/ClaudeProvider.test.ts deleted file mode 100644 index a264f8eb5e2..00000000000 --- a/apps/server/src/provider/Layers/ClaudeProvider.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { waitForAbortSignal } from "./ClaudeProvider.ts"; - -describe("waitForAbortSignal", () => { - it("stays pending until the signal aborts", async () => { - const abort = new AbortController(); - let settled = false; - const waitPromise = waitForAbortSignal(abort.signal).then(() => { - settled = true; - }); - - await Promise.resolve(); - expect(settled).toBe(false); - - abort.abort(); - await waitPromise; - expect(settled).toBe(true); - }); - - it("resolves immediately for an already-aborted signal", async () => { - const abort = new AbortController(); - abort.abort(); - - await expect(waitForAbortSignal(abort.signal)).resolves.toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 436b4ec2261..7c8a4c27a6e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -481,7 +481,7 @@ function dedupeSlashCommands( return [...commandsByName.values()]; } -export function waitForAbortSignal(signal: AbortSignal): Promise { +function waitForAbortSignal(signal: AbortSignal): Promise { if (signal.aborted) { return Promise.resolve(); }