diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1f971a6126ed..339f4dffe740 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -37,7 +37,11 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { + makeClaudeAdapter, + ClaudeSettingsResolveError, + type ClaudeAdapterLiveOptions, +} from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. @@ -154,6 +158,32 @@ class FakeClaudeQuery implements AsyncIterable { } } +const PERMISSIVE_CLAUDE_SETTINGS = { effective: {} } as const; +const BYPASS_DISABLED_CLAUDE_SETTINGS = { + effective: { + permissions: { + disableBypassPermissionsMode: "disable", + }, + }, +} as const; +const AUTO_DISABLED_CLAUDE_SETTINGS = { + effective: { + disableAutoMode: "disable", + permissions: { + disableAutoMode: "disable", + }, + }, +} as const; +const BYPASS_AND_AUTO_DISABLED_CLAUDE_SETTINGS = { + effective: { + disableAutoMode: "disable", + permissions: { + disableBypassPermissionsMode: "disable", + disableAutoMode: "disable", + }, + }, +} as const; + function makeHarness(config?: { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; @@ -161,6 +191,8 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly resolveSettings?: ClaudeAdapterLiveOptions["resolveSettings"]; + readonly resolveSdkSettings?: ClaudeAdapterLiveOptions["resolveSdkSettings"]; }) { const query = new FakeClaudeQuery(); let createInput: @@ -176,6 +208,12 @@ function makeHarness(config?: { createInput = input; return query; }, + ...(config?.resolveSdkSettings + ? { resolveSdkSettings: config.resolveSdkSettings } + : { + resolveSettings: + config?.resolveSettings ?? (() => Effect.succeed(PERMISSIVE_CLAUDE_SETTINGS)), + }), ...(config?.nativeEventLogger ? { nativeEventLogger: config.nativeEventLogger, @@ -313,6 +351,7 @@ describe("ClaudeAdapterLive", () => { createQuery: () => { throw cause; }, + resolveSettings: () => Effect.succeed(PERMISSIVE_CLAUDE_SETTINGS), }); }), ).pipe( @@ -419,6 +458,578 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect( + "does not request bypassPermissions when resolved policy disables it for full access", + () => { + const resolveCalls: Array<{ + readonly cwd?: string; + readonly settingSources: ReadonlyArray; + }> = []; + const harness = makeHarness({ + resolveSettings: (input) => { + resolveCalls.push({ + ...(input.cwd ? { cwd: input.cwd } : {}), + settingSources: [...input.settingSources], + }); + return Effect.succeed(BYPASS_DISABLED_CLAUDE_SETTINGS); + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + cwd: "/tmp/claude-policy-project", + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.runtimeMode, "full-access"); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.deepEqual(resolveCalls, [ + { + cwd: "/tmp/claude-policy-project", + settingSources: ["user", "project", "local"], + }, + ]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("keeps auto permission mode when policy disables bypass permissions", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps acceptEdits when policy disables bypass permissions", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto-accept-edits", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "acceptEdits"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps default approval mode when policy disables bypass permissions", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "approval-required", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("starts supervised when policy disables both bypass and auto for full access", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_AND_AUTO_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.runtimeMode, "full-access"); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("treats settings resolution failure as bypass disabled for full access", () => { + const harness = makeHarness({ + resolveSettings: () => + Effect.fail( + new ClaudeSettingsResolveError({ + settingSources: ["user", "project", "local"], + cause: new Error("settings resolve failed"), + }), + ), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.runtimeMode, "full-access"); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("still requests bypass when disableBypassPermissionsMode is not disable", () => { + const harness = makeHarness({ + resolveSettings: () => + Effect.succeed({ + effective: { + permissions: { + disableBypassPermissionsMode: "allow", + }, + }, + }), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("restores auto after plan when full access was policy-downgraded", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-policy-auto", + uuid: "result-policy-auto", + } as unknown as SDKMessage); + + yield* Fiber.join(turnCompletedFiber); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "auto"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("starts supervised when policy disables auto for auto runtime", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(AUTO_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.runtimeMode, "auto"); + assert.equal(createInput?.options.permissionMode, undefined); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("honors permissions.disableAutoMode without the top-level copy", () => { + const harness = makeHarness({ + resolveSettings: () => + Effect.succeed({ + effective: { + permissions: { + disableAutoMode: "disable", + }, + }, + }), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("honors top-level disableAutoMode without the permissions copy", () => { + const harness = makeHarness({ + resolveSettings: () => + Effect.succeed({ + effective: { + disableAutoMode: "disable", + }, + }), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, undefined); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("keeps bypass when auto is locked but bypass is allowed", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(AUTO_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect( + "restores default after plan when full access was policy-downgraded to supervised", + () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_AND_AUTO_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "plan this", + interactionMode: "plan", + attachments: [], + }); + + const turnCompletedFiber = yield* Stream.filter( + adapter.streamEvents, + (event) => event.type === "turn.completed", + ).pipe(Stream.runHead, Effect.forkChild); + + harness.query.emit({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + session_id: "sdk-session-policy-default", + uuid: "result-policy-default", + } as unknown as SDKMessage); + + yield* Fiber.join(turnCompletedFiber); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "now do it", + interactionMode: "default", + attachments: [], + }); + + assert.deepEqual(harness.query.setPermissionModeCalls, ["plan", "default"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("resolves Claude settings with the instance CLAUDE_CONFIG_DIR", () => { + const homePath = "/tmp/claude-policy-home-test"; + const seenConfigDirs: Array = []; + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + const harness = makeHarness({ + claudeConfig: { homePath }, + resolveSdkSettings: async () => { + seenConfigDirs.push(process.env.CLAUDE_CONFIG_DIR); + return BYPASS_DISABLED_CLAUDE_SETTINGS; + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(session.runtimeMode, "full-access"); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); + assert.deepEqual(seenConfigDirs, [homePath]); + assert.equal(process.env.CLAUDE_CONFIG_DIR, previousConfigDir); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("restores CLAUDE_CONFIG_DIR when settings resolution throws", () => { + const homePath = "/tmp/claude-policy-home-throw"; + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + const harness = makeHarness({ + claudeConfig: { homePath }, + resolveSdkSettings: async () => { + throw new Error("sdk resolve boom"); + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "auto"); + assert.equal(process.env.CLAUDE_CONFIG_DIR, previousConfigDir); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("serializes CLAUDE_CONFIG_DIR swaps across Claude adapter instances", () => { + let active = 0; + let maxActive = 0; + const makeResolver = + (): NonNullable => async () => + Effect.runPromise( + Effect.gen(function* () { + active += 1; + maxActive = Math.max(maxActive, active); + yield* Effect.sleep("25 millis"); + active -= 1; + return BYPASS_DISABLED_CLAUDE_SETTINGS; + }), + ); + const first = makeHarness({ + claudeConfig: { homePath: "/tmp/claude-policy-home-a" }, + resolveSdkSettings: makeResolver(), + }); + const second = makeHarness({ + instanceId: ProviderInstanceId.make("claudeAgent-work"), + claudeConfig: { homePath: "/tmp/claude-policy-home-b" }, + resolveSdkSettings: makeResolver(), + }); + const start = (harness: ReturnType, threadId: ThreadId) => + Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + + return Effect.gen(function* () { + yield* Effect.all( + [start(first, THREAD_ID), start(second, ThreadId.make("thread-claude-2"))], + { concurrency: "unbounded" }, + ); + assert.equal(maxActive, 1); + }); + }); + + it.effect("auto-allows tools when effective Claude mode is still bypass", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionResult = yield* Effect.promise(() => + canUseTool( + "Bash", + { command: "pwd" }, + { + signal: new AbortController().signal, + toolUseID: "tool-use-bypass", + }, + ), + ); + assert.equal(permissionResult.behavior, "allow"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("does not auto-allow tools when full access is policy-downgraded to supervised", () => { + const harness = makeHarness({ + resolveSettings: () => Effect.succeed(BYPASS_AND_AUTO_DISABLED_CLAUDE_SETTINGS), + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain); + + const canUseTool = harness.getLastCreateQueryInput()?.options.canUseTool; + assert.equal(typeof canUseTool, "function"); + if (!canUseTool) { + return; + } + + const permissionPromise = canUseTool( + "Bash", + { command: "pwd" }, + { + signal: new AbortController().signal, + toolUseID: "tool-use-supervised", + }, + ); + + const requested = yield* Stream.runHead(adapter.streamEvents); + assert.equal(requested._tag, "Some"); + if (requested._tag !== "Some") { + return; + } + assert.equal(requested.value.type, "request.opened"); + if (requested.value.type !== "request.opened") { + return; + } + const runtimeRequestId = requested.value.requestId; + assert.equal(typeof runtimeRequestId, "string"); + if (runtimeRequestId === undefined) { + return; + } + + yield* adapter.respondToRequest( + session.threadId, + ApprovalRequestId.make(runtimeRequestId), + "decline", + ); + + const permissionResult = yield* Effect.promise(() => permissionPromise); + assert.equal(permissionResult.behavior, "deny"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1960,6 +2571,7 @@ describe("ClaudeAdapterLive", () => { queries.push(query); return query; }, + resolveSettings: () => Effect.succeed(PERMISSIVE_CLAUDE_SETTINGS), }); }), ).pipe( @@ -2051,6 +2663,7 @@ describe("ClaudeAdapterLive", () => { })(); return query; }, + resolveSettings: () => Effect.succeed(PERMISSIVE_CLAUDE_SETTINGS), }); }), ).pipe( diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 5715b68a1e45..1b8fe0f7d25a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -13,6 +13,7 @@ import { type PermissionMode, type PermissionResult, type PermissionUpdate, + resolveSettings as resolveClaudeSdkSettings, type SDKMessage, type SDKControlGetContextUsageResponse, type SDKResultMessage, @@ -70,6 +71,7 @@ import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; @@ -292,6 +294,25 @@ interface ClaudeQueryRuntime extends AsyncIterable { readonly close: () => void; } +export interface ClaudeResolvedSettingsSnapshot { + readonly effective?: { + readonly disableAutoMode?: unknown; + readonly permissions?: { + readonly disableAutoMode?: unknown; + readonly disableBypassPermissionsMode?: unknown; + }; + }; +} + +export class ClaudeSettingsResolveError extends Schema.TaggedErrorClass()( + "ClaudeSettingsResolveError", + { + cwd: Schema.optional(Schema.String), + settingSources: Schema.Array(Schema.String), + cause: Schema.Defect(), + }, +) {} + export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -301,6 +322,26 @@ export interface ClaudeAdapterLiveOptions { }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Optional settings snapshot used to honor Claude's + * `permissions.disableBypassPermissionsMode` and `disableAutoMode` policy + * before requesting those modes. Production uses the SDK's + * `resolveSettings()` with the same filesystem sources as the session. + * Tests inject fixtures. + */ + readonly resolveSettings?: (input: { + readonly cwd?: string; + readonly settingSources: ReadonlyArray; + }) => Effect.Effect; + /** + * Optional inner `resolveSettings()` used when `resolveSettings` is omitted. + * Production calls the SDK. Tests inject this to observe `CLAUDE_CONFIG_DIR` + * without hitting disk or managed policy. + */ + readonly resolveSdkSettings?: (input: { + readonly cwd?: string; + readonly settingSources: ReadonlyArray; + }) => Promise; } function isUuid(value: string): boolean { @@ -1206,6 +1247,76 @@ const CLAUDE_SETTING_SOURCES = [ "project", "local", ] as const satisfies ReadonlyArray; +const CLAUDE_RUNTIME_MODE_TO_PERMISSION: Record = { + "auto-accept-edits": "acceptEdits", + auto: "auto", + "full-access": "bypassPermissions", +}; +const claudeConfigDirLock = Semaphore.makeUnsafe(1); +const CLAUDE_BYPASS_DISABLED_SETTINGS = { + effective: { + permissions: { + disableBypassPermissionsMode: "disable", + }, + }, +} as const satisfies ClaudeResolvedSettingsSnapshot; + +function isClaudePolicyDisabled(value: unknown): boolean { + return value === "disable"; +} + +function isClaudeBypassPermissionsDisabled(settings: ClaudeResolvedSettingsSnapshot): boolean { + return isClaudePolicyDisabled(settings.effective?.permissions?.disableBypassPermissionsMode); +} + +function isClaudeAutoModeDisabled(settings: ClaudeResolvedSettingsSnapshot): boolean { + return ( + isClaudePolicyDisabled(settings.effective?.disableAutoMode) || + isClaudePolicyDisabled(settings.effective?.permissions?.disableAutoMode) + ); +} + +function effectiveClaudePermissionMode( + requested: PermissionMode | undefined, + settings: ClaudeResolvedSettingsSnapshot, +): PermissionMode | undefined { + const autoDisabled = isClaudeAutoModeDisabled(settings); + if (requested === "bypassPermissions" && isClaudeBypassPermissionsDisabled(settings)) { + return autoDisabled ? undefined : "auto"; + } + if (requested === "auto" && autoDisabled) { + return undefined; + } + return requested; +} + +function withProcessClaudeConfigDir( + configDir: string | undefined, + effect: Effect.Effect, +): Effect.Effect { + if (configDir === undefined) { + return effect; + } + + // acquire/release are uninterruptible; keep `use` interruptible so a hung + // SDK resolve cannot pin CLAUDE_CONFIG_DIR or block later session starts. + return Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + return previous; + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previous; + } + }), + ); +} function buildPromptText( input: ProviderSendTurnInput, @@ -1687,6 +1798,28 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( prompt: input.prompt, options: input.options, }) as ClaudeQueryRuntime); + const resolveSdkSettings = options?.resolveSdkSettings ?? resolveClaudeSdkSettings; + const resolveSettings = + options?.resolveSettings ?? + ((input) => + claudeConfigDirLock.withPermits(1)( + withProcessClaudeConfigDir( + claudeEnvironment.CLAUDE_CONFIG_DIR, + Effect.tryPromise({ + try: () => + resolveSdkSettings({ + ...(input.cwd ? { cwd: input.cwd } : {}), + settingSources: [...input.settingSources], + }), + catch: (cause) => + new ClaudeSettingsResolveError({ + ...(input.cwd ? { cwd: input.cwd } : {}), + settingSources: [...input.settingSources], + cause, + }), + }), + ), + )); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -3808,6 +3941,33 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const contextRef = yield* Ref.make(undefined); + const requestedPermissionMode = CLAUDE_RUNTIME_MODE_TO_PERMISSION[input.runtimeMode]; + const permissionMode = + requestedPermissionMode === "bypassPermissions" || requestedPermissionMode === "auto" + ? effectiveClaudePermissionMode( + requestedPermissionMode, + yield* resolveSettings({ + ...(input.cwd ? { cwd: input.cwd } : {}), + settingSources: CLAUDE_SETTING_SOURCES, + }).pipe( + Effect.tapError(() => + Effect.logWarning("claude.settings.resolve-failed", { + threadId, + }), + ), + Effect.orElseSucceed(() => CLAUDE_BYPASS_DISABLED_SETTINGS), + ), + ) + : requestedPermissionMode; + if (permissionMode !== requestedPermissionMode) { + yield* Effect.logWarning("claude.permission.mode-downgraded", { + threadId, + requestedRuntimeMode: input.runtimeMode, + requestedPermissionMode, + effectivePermissionMode: permissionMode ?? "default", + }); + } + /** * Handle AskUserQuestion tool calls by emitting a `user-input.requested` * runtime event and waiting for the user to respond via `respondToUserInput`. @@ -3988,8 +4148,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } satisfies PermissionResult; } - const runtimeMode = input.runtimeMode ?? "full-access"; - if (runtimeMode === "full-access") { + if (permissionMode === "bypassPermissions") { return { behavior: "allow", updatedInput: toolInput, @@ -4131,12 +4290,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( : undefined; const ultracode = isClaudeUltracodeEffort(effort); const effectiveEffort = getEffectiveClaudeAgentEffort(effort, modelSelection?.model); - const runtimeModeToPermission: Record = { - "auto-accept-edits": "acceptEdits", - auto: "auto", - "full-access": "bypassPermissions", - }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -4195,6 +4348,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( "provider.kind": PROVIDER, "provider.thread_id": threadId, "provider.runtime_mode": input.runtimeMode, + "claude.query.requested_permission_mode": requestedPermissionMode ?? "", "claude.resume.source": existingResumeSessionId !== undefined ? "resume-session" : "generated-session", "claude.resume.thread_id": resumeState?.threadId ?? "", diff --git a/docs/user/providers-claude.md b/docs/user/providers-claude.md index f9699388b7db..90eb1919962a 100644 --- a/docs/user/providers-claude.md +++ b/docs/user/providers-claude.md @@ -215,3 +215,11 @@ If the preset needs different Claude files, give it a different `CLAUDE_CONFIG_D different API keys, base URLs, or router settings, use Environment variables. Do not put environment variable assignments in `Launch arguments`. + +## Full Access When Bypass Permissions Are Locked + +Some Claude setups, including organization policy, forbid bypass-permissions mode. If you pick +**Full access** there, T3 Code keeps the thread on Full access but starts Claude in **Auto** so the +session can run. If Auto is also locked — or you pick Auto while Auto is locked — the session starts +in **Supervised** instead. Pick the composer mode that matches what Claude will enforce if you want +the label to stay in sync.