From 57b3a83072b77bc4c908fcfb95fea826557826e6 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:44:12 +0000 Subject: [PATCH 1/3] fix(agent): stop Auto Mode from re-prompting via the SDK's native auto mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostHog's "auto" mode is host-arbitrated: canUseTool auto-approves file edits and shell commands while still gating MCP tools. The Claude SDK bump to 0.3.197 added an org "ask" ceiling (isOrgAskCeiling) to its own native "auto" mode, so handing our "auto" straight to the SDK now lets the SDK's classifier/ceiling force user prompts for the very tools auto mode is meant to auto-approve — re-introducing the behavior fixed in #2970. Map the custom "auto" mode to the SDK's "default" mode at both SDK boundaries (buildSessionOptions and setPermissionMode) so the SDK defers every gated tool to canUseTool, which stays the single arbiter. The host keeps session.permissionMode "auto" so canUseTool still auto-allows edits and bash. Generated-By: PostHog Code Task-Id: fcf9316e-57bd-4783-aff8-be4cd70ef82d --- .../agent/src/adapters/claude/claude-agent.ts | 5 ++- .../adapters/claude/session/options.test.ts | 22 +++++++++++ .../src/adapters/claude/session/options.ts | 4 +- .../agent/src/adapters/claude/tools.test.ts | 39 +++++++++++++++++++ packages/agent/src/adapters/claude/tools.ts | 16 ++++++++ 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 packages/agent/src/adapters/claude/tools.test.ts diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 9e2716888a..2ed43af846 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -135,6 +135,7 @@ import { CODE_EXECUTION_MODES, type CodeExecutionMode, getAvailableModes, + toSdkPermissionMode, } from "./tools"; import type { BackgroundTerminal, @@ -1778,7 +1779,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.session.modeBeforePlan = previousMode; } try { - await this.session.query.setPermissionMode(modeId as CodeExecutionMode); + await this.session.query.setPermissionMode( + toSdkPermissionMode(modeId as CodeExecutionMode), + ); } catch (error) { this.session.permissionMode = previousMode; if (error instanceof Error) { diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index b167b5594e..d59e4ef7c5 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -57,6 +57,28 @@ describe("buildSessionOptions", () => { }, ); + it("maps the custom auto mode to the SDK's default mode", () => { + // The SDK's own "auto" mode runs a classifier / org ask-ceiling that can + // re-prompt; the host keeps arbitration in canUseTool, so the SDK must be + // told "default" for auto sessions. + const options = buildSessionOptions({ + ...makeParams(), + permissionMode: "auto", + }); + expect(options.permissionMode).toBe("default"); + }); + + it.each(["default", "acceptEdits", "plan", "bypassPermissions"] as const)( + "passes native SDK mode %s through to options.permissionMode", + (mode) => { + const options = buildSessionOptions({ + ...makeParams(), + permissionMode: mode, + }); + expect(options.permissionMode).toBe(mode); + }, + ); + it("preserves caller-provided agents alongside defaults", () => { const params = makeParams(); const options = buildSessionOptions({ diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 25a3817f2b..547f21eda9 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -24,7 +24,7 @@ import { type EnrichedReadCache, type OnModeChange, } from "../hooks"; -import type { CodeExecutionMode } from "../tools"; +import { type CodeExecutionMode, toSdkPermissionMode } from "../tools"; import type { EffortLevel } from "../types"; import { APPENDED_INSTRUCTIONS } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; @@ -445,7 +445,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { cwd: params.cwd, includePartialMessages: true, allowDangerouslySkipPermissions: !IS_ROOT || !!process.env.IS_SANDBOX, - permissionMode: params.permissionMode, + permissionMode: toSdkPermissionMode(params.permissionMode), canUseTool: params.canUseTool, tools, agents, diff --git a/packages/agent/src/adapters/claude/tools.test.ts b/packages/agent/src/adapters/claude/tools.test.ts new file mode 100644 index 0000000000..97a29fffe6 --- /dev/null +++ b/packages/agent/src/adapters/claude/tools.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { CodeExecutionMode } from "../../execution-mode"; +import { isToolAllowedForMode, toSdkPermissionMode } from "./tools"; + +describe("toSdkPermissionMode", () => { + // PostHog's "auto" is host-arbitrated (canUseTool auto-approves edits/bash). + // It must NOT be handed to the SDK's native "auto" mode, whose classifier and + // org "ask" ceiling can force prompts for the tools we mean to auto-approve. + it("maps the custom auto mode to the SDK's default mode", () => { + expect(toSdkPermissionMode("auto")).toBe("default"); + }); + + it.each([ + "default", + "acceptEdits", + "plan", + "bypassPermissions", + ])("passes native SDK mode %s through unchanged", (mode) => { + expect(toSdkPermissionMode(mode)).toBe(mode); + }); +}); + +describe("isToolAllowedForMode stays authoritative for auto", () => { + // Even though the SDK is told "default", the host arbiter still treats the + // session as "auto" and auto-allows edits and shell commands. + it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])( + "auto-allows %s in auto mode", + (tool) => { + expect(isToolAllowedForMode(tool, "auto")).toBe(true); + }, + ); + + it.each(["Bash", "Edit", "Write"])( + "still gates %s in default mode", + (tool) => { + expect(isToolAllowedForMode(tool, "default")).toBe(false); + }, + ); +}); diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index eaccc7d218..2e235536ee 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -5,6 +5,7 @@ export { type ModeInfo, } from "../../execution-mode"; +import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk"; import type { CodeExecutionMode } from "../../execution-mode"; import { isMcpToolReadOnly } from "./mcp/tool-metadata"; @@ -55,6 +56,21 @@ const AUTO_ALLOWED_TOOLS: Record> = { plan: new Set(BASE_ALLOWED_TOOLS), }; +// PostHog's "auto" mode is host-arbitrated: canUseTool auto-approves file edits +// and shell commands (see AUTO_ALLOWED_TOOLS) while still gating MCP tools. The +// Claude SDK, however, has its own native "auto" mode that runs a model +// classifier and honors an org "ask" ceiling (isOrgAskCeiling), either of which +// can force a user prompt for the very tools we mean to auto-approve. Handing +// our "auto" straight to the SDK therefore surrenders control to that classifier +// and reintroduces the permission prompts auto mode exists to remove. Map it to +// the SDK's "default" mode so the SDK defers every gated tool to canUseTool, +// which stays the single arbiter; the host keeps session.permissionMode "auto" +// so canUseTool still auto-allows edits and bash. Every other mode is a native +// SDK mode whose semantics match ours, so it passes through unchanged. +export function toSdkPermissionMode(mode: CodeExecutionMode): PermissionMode { + return mode === "auto" ? "default" : mode; +} + export function isToolAllowedForMode( toolName: string, mode: CodeExecutionMode, From 0645fbc1bc81570e4eb7a8439c22ee7276895f1c Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 12 Jul 2026 22:11:46 -0700 Subject: [PATCH 2/3] remove ai-generated comments from permission mode mapping --- .../agent/src/adapters/claude/session/options.test.ts | 3 --- packages/agent/src/adapters/claude/tools.test.ts | 5 ----- packages/agent/src/adapters/claude/tools.ts | 11 ----------- 3 files changed, 19 deletions(-) diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index d59e4ef7c5..854bc461ff 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -58,9 +58,6 @@ describe("buildSessionOptions", () => { ); it("maps the custom auto mode to the SDK's default mode", () => { - // The SDK's own "auto" mode runs a classifier / org ask-ceiling that can - // re-prompt; the host keeps arbitration in canUseTool, so the SDK must be - // told "default" for auto sessions. const options = buildSessionOptions({ ...makeParams(), permissionMode: "auto", diff --git a/packages/agent/src/adapters/claude/tools.test.ts b/packages/agent/src/adapters/claude/tools.test.ts index 97a29fffe6..6f87485db4 100644 --- a/packages/agent/src/adapters/claude/tools.test.ts +++ b/packages/agent/src/adapters/claude/tools.test.ts @@ -3,9 +3,6 @@ import type { CodeExecutionMode } from "../../execution-mode"; import { isToolAllowedForMode, toSdkPermissionMode } from "./tools"; describe("toSdkPermissionMode", () => { - // PostHog's "auto" is host-arbitrated (canUseTool auto-approves edits/bash). - // It must NOT be handed to the SDK's native "auto" mode, whose classifier and - // org "ask" ceiling can force prompts for the tools we mean to auto-approve. it("maps the custom auto mode to the SDK's default mode", () => { expect(toSdkPermissionMode("auto")).toBe("default"); }); @@ -21,8 +18,6 @@ describe("toSdkPermissionMode", () => { }); describe("isToolAllowedForMode stays authoritative for auto", () => { - // Even though the SDK is told "default", the host arbiter still treats the - // session as "auto" and auto-allows edits and shell commands. it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])( "auto-allows %s in auto mode", (tool) => { diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 2e235536ee..78423cb55c 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -56,17 +56,6 @@ const AUTO_ALLOWED_TOOLS: Record> = { plan: new Set(BASE_ALLOWED_TOOLS), }; -// PostHog's "auto" mode is host-arbitrated: canUseTool auto-approves file edits -// and shell commands (see AUTO_ALLOWED_TOOLS) while still gating MCP tools. The -// Claude SDK, however, has its own native "auto" mode that runs a model -// classifier and honors an org "ask" ceiling (isOrgAskCeiling), either of which -// can force a user prompt for the very tools we mean to auto-approve. Handing -// our "auto" straight to the SDK therefore surrenders control to that classifier -// and reintroduces the permission prompts auto mode exists to remove. Map it to -// the SDK's "default" mode so the SDK defers every gated tool to canUseTool, -// which stays the single arbiter; the host keeps session.permissionMode "auto" -// so canUseTool still auto-allows edits and bash. Every other mode is a native -// SDK mode whose semantics match ours, so it passes through unchanged. export function toSdkPermissionMode(mode: CodeExecutionMode): PermissionMode { return mode === "auto" ? "default" : mode; } From 8ca2274be28567bbeca17300ab13620cc54aebdb Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sun, 12 Jul 2026 22:35:27 -0700 Subject: [PATCH 3/3] cover setPermissionMode SDK call site, dealias PermissionMode type --- .../claude-agent.permission-mode.test.ts | 151 ++++++++++++++++++ packages/agent/src/adapters/claude/tools.ts | 6 +- 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts diff --git a/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts b/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts new file mode 100644 index 0000000000..adfc6415f9 --- /dev/null +++ b/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts @@ -0,0 +1,151 @@ +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + CODE_EXECUTION_MODES, + type CodeExecutionMode, +} from "../../execution-mode"; +import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk"; +import { Pushable } from "../../utils/streams"; +import { toSdkPermissionMode } from "./tools"; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn(), +})); + +vi.mock("./mcp/tool-metadata", () => ({ + fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined), + getConnectedMcpServerNames: vi.fn().mockReturnValue([]), + setMcpToolApprovalStates: vi.fn(), + isMcpToolReadOnly: vi.fn().mockReturnValue(false), + getMcpToolMetadata: vi.fn().mockReturnValue(undefined), + getMcpToolApprovalState: vi.fn().mockReturnValue(undefined), +})); + +const { ClaudeAcpAgent } = await import("./claude-agent"); +type Agent = InstanceType; + +interface ClientMocks { + sessionUpdate: ReturnType; + extNotification: ReturnType; +} + +function makeAgent(): { agent: Agent; client: ClientMocks } { + const client: ClientMocks = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + extNotification: vi.fn().mockResolvedValue(undefined), + }; + const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection); + return { agent, client }; +} + +function installFakeSession( + agent: Agent, + sessionId: string, + permissionMode: CodeExecutionMode = "default", +): MockQuery { + const query = createMockQuery(); + const input = new Pushable(); + const abortController = new AbortController(); + + const session = { + query, + queryOptions: { sessionId, cwd: "/tmp/repo", abortController }, + buildInProcessMcpServers: () => ({}), + localToolsServerNames: [] as string[], + input, + cancelled: false, + interruptReason: undefined, + settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" }, + permissionMode, + abortController, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + sessionResources: new Set(), + configOptions: [], + turnQueue: [], + activeTurn: null, + pendingOrphanResults: 0, + queryGeneration: 0, + cwd: "/tmp/repo", + notificationHistory: [] as unknown[], + taskRunId: "run-1", + lastContextWindowSize: 200_000, + modelId: "claude-sonnet-4-6", + knownSlashCommands: undefined, + }; + + (agent as unknown as { session: typeof session }).session = session; + (agent as unknown as { sessionId: string }).sessionId = sessionId; + + return query; +} + +describe("ClaudeAcpAgent.setSessionMode — SDK permission-mode translation", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(CODE_EXECUTION_MODES)( + "maps modeId %s to the SDK's permission mode at the setPermissionMode call site", + async (modeId) => { + const { agent } = makeAgent(); + const query = installFakeSession(agent, "s-mode"); + + await agent.setSessionMode({ sessionId: "s-mode", modeId }); + + expect(query.setPermissionMode).toHaveBeenCalledWith( + toSdkPermissionMode(modeId), + ); + expect( + (agent as unknown as { session: { permissionMode: string } }).session + .permissionMode, + ).toBe(modeId); + }, + ); + + it("reverts session.permissionMode to the previous mode when the SDK rejects", async () => { + const { agent } = makeAgent(); + const query = installFakeSession(agent, "s-mode", "default"); + vi.mocked(query.setPermissionMode).mockRejectedValueOnce( + new Error("sdk rejected"), + ); + + await expect( + agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }), + ).rejects.toThrow("sdk rejected"); + + expect( + (agent as unknown as { session: { permissionMode: string } }).session + .permissionMode, + ).toBe("default"); + }); + + it("falls back to a generic error message when the SDK rejection has none", async () => { + const { agent } = makeAgent(); + const query = installFakeSession(agent, "s-mode", "default"); + vi.mocked(query.setPermissionMode).mockRejectedValueOnce(new Error()); + + await expect( + agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }), + ).rejects.toThrow("Invalid Mode"); + }); + + it("records modeBeforePlan using the host mode, unaffected by the SDK translation", async () => { + const { agent } = makeAgent(); + installFakeSession(agent, "s-mode", "auto"); + + await agent.setSessionMode({ sessionId: "s-mode", modeId: "plan" }); + + const session = ( + agent as unknown as { + session: { permissionMode: string; modeBeforePlan?: string }; + } + ).session; + expect(session.permissionMode).toBe("plan"); + expect(session.modeBeforePlan).toBe("auto"); + }); +}); diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index 78423cb55c..1911b037fd 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -5,7 +5,7 @@ export { type ModeInfo, } from "../../execution-mode"; -import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk"; +import type { PermissionMode as SdkPermissionMode } from "@anthropic-ai/claude-agent-sdk"; import type { CodeExecutionMode } from "../../execution-mode"; import { isMcpToolReadOnly } from "./mcp/tool-metadata"; @@ -56,7 +56,9 @@ const AUTO_ALLOWED_TOOLS: Record> = { plan: new Set(BASE_ALLOWED_TOOLS), }; -export function toSdkPermissionMode(mode: CodeExecutionMode): PermissionMode { +export function toSdkPermissionMode( + mode: CodeExecutionMode, +): SdkPermissionMode { return mode === "auto" ? "default" : mode; }