diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 1f971a6126ed..b5d0872b223e 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,436 @@ 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({})), + }); + 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("forwards claude effort levels into query options", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -1960,6 +2429,7 @@ describe("ClaudeAdapterLive", () => { queries.push(query); return query; }, + resolveSettings: () => Effect.succeed(PERMISSIVE_CLAUDE_SETTINGS), }); }), ).pipe( @@ -2051,6 +2521,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..31a60a605453 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,23 @@ 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", + { + cause: Schema.optional(Schema.Defect()), + }, +) {} + export interface ClaudeAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; @@ -301,6 +320,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 +1245,70 @@ const CLAUDE_SETTING_SOURCES = [ "project", "local", ] as const satisfies ReadonlyArray; +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 +1790,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( prompt: input.prompt, options: input.options, }) as ClaudeQueryRuntime); + const settingsResolveLock = yield* Semaphore.make(1); + const resolveSdkSettings = options?.resolveSdkSettings ?? resolveClaudeSdkSettings; + const resolveSettings = + options?.resolveSettings ?? + ((input) => + settingsResolveLock.withPermits(1)( + withProcessClaudeConfigDir( + claudeEnvironment.CLAUDE_CONFIG_DIR, + Effect.tryPromise({ + try: () => + resolveSdkSettings({ + ...(input.cwd ? { cwd: input.cwd } : {}), + settingSources: [...input.settingSources], + }), + catch: (cause) => new ClaudeSettingsResolveError({ cause }), + }), + ), + )); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -4136,7 +4257,32 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( auto: "auto", "full-access": "bypassPermissions", }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; + const requestedPermissionMode = runtimeModeToPermission[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", + }); + } const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), @@ -4195,6 +4341,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.