authorgravatar for alfian1991@gmail.comJoaquin Navarro <alfian1991@gmail.com> 2026-08-15 08:05:21-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:05:21+00:00
loge9e46972fbabdee0eb39cbd86470a5b5f5f26910
treeb031d45eb4c2d9fcbfb5da15f7ed3fba1852d05f
parent551f4c99c1c42c767daea1f2920cfc8fbc78a1cb
signature Signed by PGP key B5690EEEBB952194

fix(claude): make "Always allow for session" stick, and only for the session (#5041)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

2 files changed, 148 insertions(+), 2 deletions(-)

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+112
...@@ -3360,6 +3360,118 @@ describe("ClaudeAdapterLive", () => {...@@ -3360,6 +3360,118 @@ describe("ClaudeAdapterLive", () => {
3360 );3360 );
3361 });3361 });
33623362
3363 it.effect("acceptForSession returns session-scoped permission updates", () => {
3364 const harness = makeHarness();
3365 return Effect.gen(function* () {
3366 const adapter = yield* ClaudeAdapter;
3367
3368 const session = yield* adapter.startSession({
3369 threadId: THREAD_ID,
3370 provider: ProviderDriverKind.make("claudeAgent"),
3371 runtimeMode: "approval-required",
3372 });
3373
3374 yield* Stream.take(adapter.streamEvents, 3).pipe(Stream.runDrain);
3375
3376 yield* adapter.sendTurn({
3377 threadId: session.threadId,
3378 input: "approve this for the session",
3379 attachments: [],
3380 });
3381 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
3382
3383 const createInput = harness.getLastCreateQueryInput();
3384 const canUseTool = createInput?.options.canUseTool;
3385 assert.equal(typeof canUseTool, "function");
3386 if (!canUseTool) {
3387 return;
3388 }
3389
3390 const respondToNextRequest = Effect.gen(function* () {
3391 const requested = yield* Stream.runHead(adapter.streamEvents);
3392 assert.equal(requested._tag, "Some");
3393 if (requested._tag !== "Some" || requested.value.type !== "request.opened") {
3394 return;
3395 }
3396 const runtimeRequestId = requested.value.requestId;
3397 assert.equal(typeof runtimeRequestId, "string");
3398 if (runtimeRequestId === undefined) {
3399 return;
3400 }
3401 yield* adapter.respondToRequest(
3402 session.threadId,
3403 ApprovalRequestId.make(runtimeRequestId),
3404 "acceptForSession",
3405 );
3406 yield* Stream.take(adapter.streamEvents, 1).pipe(Stream.runDrain);
3407 });
3408
3409 // MCP tools frequently arrive with no usable suggestion (Claude Code
3410 // sends an empty array); the decision must still stick for the session.
3411 const mcpPermissionPromise = canUseTool(
3412 "mcp__linear__create_issue",
3413 { title: "hello" },
3414 {
3415 signal: new AbortController().signal,
3416 suggestions: [],
3417 toolUseID: "tool-use-mcp-1",
3418 },
3419 );
3420 yield* respondToNextRequest;
3421 const mcpPermission = (yield* Effect.promise(() => mcpPermissionPromise)) as PermissionResult;
3422 assert.equal(mcpPermission.behavior, "allow");
3423 if (mcpPermission.behavior !== "allow") {
3424 return;
3425 }
3426 assert.deepEqual(mcpPermission.updatedPermissions, [
3427 {
3428 type: "addRules",
3429 rules: [{ toolName: "mcp__linear__create_issue" }],
3430 behavior: "allow",
3431 destination: "session",
3432 },
3433 ]);
3434
3435 // Received suggestions are reused but rescoped to the session —
3436 // echoing "localSettings" would persist a session-only choice to disk.
3437 const bashPermissionPromise = canUseTool(
3438 "Bash",
3439 { command: "git status" },
3440 {
3441 signal: new AbortController().signal,
3442 suggestions: [
3443 {
3444 type: "addRules",
3445 rules: [{ toolName: "Bash", ruleContent: "git status" }],
3446 behavior: "allow",
3447 destination: "localSettings",
3448 },
3449 ],
3450 toolUseID: "tool-use-bash-1",
3451 },
3452 );
3453 yield* respondToNextRequest;
3454 const bashPermission = (yield* Effect.promise(
3455 () => bashPermissionPromise,
3456 )) as PermissionResult;
3457 assert.equal(bashPermission.behavior, "allow");
3458 if (bashPermission.behavior !== "allow") {
3459 return;
3460 }
3461 assert.deepEqual(bashPermission.updatedPermissions, [
3462 {
3463 type: "addRules",
3464 rules: [{ toolName: "Bash", ruleContent: "git status" }],
3465 behavior: "allow",
3466 destination: "session",
3467 },
3468 ]);
3469 }).pipe(
3470 Effect.provideService(Random.Random, makeDeterministicRandomService()),
3471 Effect.provide(harness.layer),
3472 );
3473 });
3474
3363 it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => {3475 it.effect("classifies Agent tools and read-only Claude tools correctly for approvals", () => {
3364 const harness = makeHarness();3476 const harness = makeHarness();
3365 return Effect.gen(function* () {3477 return Effect.gen(function* () {
apps/server/src/provider/Layers/ClaudeAdapter.ts+36-2
...@@ -160,6 +160,37 @@ interface PendingApproval {...@@ -160,6 +160,37 @@ interface PendingApproval {
160 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;160 readonly decision: Deferred.Deferred<ProviderApprovalDecision>;
161}161}
162162
163/**
164 * Permission updates applied for an "Always allow this session" decision.
165 *
166 * Claude Code's suggestions are reused when present but rescoped to
167 * `destination: "session"` — echoing them verbatim would persist the
168 * session-only choice as a permanent rule (suggestions typically target
169 * `localSettings`, i.e. `.claude/settings.local.json`). When Claude Code
170 * offers no suggestion — common for MCP tools — fall back to a whole-tool
171 * session allow rule so the decision still sticks for the session instead of
172 * silently degrading into a one-shot accept.
173 */
174function toSessionPermissionUpdates(
175 toolName: string,
176 suggestions: ReadonlyArray<PermissionUpdate> | undefined,
177): Array<PermissionUpdate> {
178 const sessionScoped = (suggestions ?? []).map(
179 (suggestion): PermissionUpdate => ({ ...suggestion, destination: "session" }),
180 );
181 if (sessionScoped.length > 0) {
182 return sessionScoped;
183 }
184 return [
185 {
186 type: "addRules",
187 rules: [{ toolName }],
188 behavior: "allow",
189 destination: "session",
190 },
191 ];
192}
193
163interface PendingUserInput {194interface PendingUserInput {
164 readonly questions: ReadonlyArray<UserInputQuestion>;195 readonly questions: ReadonlyArray<UserInputQuestion>;
165 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;196 readonly answers: Deferred.Deferred<ProviderUserInputAnswers>;
...@@ -4039,9 +4070,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (...@@ -4039,9 +4070,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
4039 return {4070 return {
4040 behavior: "allow",4071 behavior: "allow",
4041 updatedInput: toolInput,4072 updatedInput: toolInput,
4042 ...(decision === "acceptForSession" && pendingApproval.suggestions4073 ...(decision === "acceptForSession"
4043 ? {4074 ? {
4044 updatedPermissions: [...pendingApproval.suggestions],4075 updatedPermissions: toSessionPermissionUpdates(
4076 toolName,
4077 pendingApproval.suggestions,
4078 ),
4045 }4079 }
4046 : {}),4080 : {}),
4047 } satisfies PermissionResult;4081 } satisfies PermissionResult;