Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4879,14 +4879,17 @@ describe("ClaudeAdapterV2 background wake turns", () => {
turn: Effect.Success<ReturnType<typeof openPlanTurn>>,
toolName: string,
toolInput: Record<string, unknown>,
options?: { toolUseID: string; agentID?: string },
options?: { toolUseID: string; agentID?: string; decisionReasonType?: "mode" | "hook" },
) {
const decision = yield* Effect.promise(() =>
turn.canUseTool(toolName, toolInput, {
signal: new AbortController().signal,
toolUseID: options?.toolUseID ?? "tool-plan-write",
requestId: "request-plan-write",
...(options?.agentID === undefined ? {} : { agentID: options.agentID }),
...(options?.decisionReasonType === undefined
? {}
: { decisionReasonType: options.decisionReasonType }),
}),
).pipe(Effect.forkScoped);
const first = yield* Effect.raceFirst(
Expand Down Expand Up @@ -4950,6 +4953,55 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

// regression: #23918 the CLI asks again in Plan despite the session rule it was given
it.effect("does not ask again for an MCP tool approved for the session in Plan", () =>
Effect.gen(function* () {
const turn = yield* openPlanTurn("full-access");
const toolName = "mcp__t3-code__t3_thread_read";
const first = yield* requestWrite(
turn,
toolName,
{ threadId: "thread-a", afterPosition: -1 },
{ toolUseID: "read-page-1", decisionReasonType: "mode" },
);
assert.equal(first.first.type, "request");
if (first.first.type !== "request") return;
yield* turn.runtime.respondToRuntimeRequest({
requestId: first.first.event.runtimeRequest.id,
decision: "acceptForSession",
});
assert.equal((yield* Fiber.join(first.decision))?.behavior, "allow");

const second = yield* requestWrite(
turn,
toolName,
{ threadId: "thread-a", afterPosition: 1 },
{ toolUseID: "read-page-2", decisionReasonType: "mode" },
);
assert.equal(second.first.type, "decision", "the session approval asked again");
if (second.first.type !== "decision") return;
assert.equal(second.first.result?.behavior, "allow");

const hooked = yield* requestWrite(
turn,
toolName,
{ threadId: "thread-a", afterPosition: 3 },
{ toolUseID: "read-page-3", decisionReasonType: "hook" },
);
assert.equal(hooked.first.type, "request", "a hook's ask was answered from the grant");
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);

it.effect("denies Plan writes without asking when approvals are never allowed", () =>
Effect.gen(function* () {
const turn = yield* openPlanTurn("full-access", "never");
Expand Down
34 changes: 33 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5629,6 +5629,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
new Map<string, PendingClaudeRuntimeRequest>(),
);
const mcpElicitationSessionGrants = yield* Ref.make(new Set<string>());
// Plan asks for a tool without a read-only annotation even when a session rule allows it.
const planSessionToolGrants = yield* Ref.make(new Set<string>());
// Background local_bash tasks can trigger wake turns on the query stream with no active provider turn.
const lastTurnRouteByNativeThread = yield* Ref.make(
new Map<
Expand Down Expand Up @@ -11677,6 +11679,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
} satisfies PermissionResult;
}

// The SDK passes decisionReasonType but leaves it out of its types. Only the mode's own
// ask is answered from a grant: a hook, an ask rule or a safety check still asks.
if (
"decisionReasonType" in callbackOptions &&
callbackOptions.decisionReasonType === "mode" &&
callbackOptions.matchedAskRule === undefined &&
callbackOptions.suppressAlwaysAllowRule !== true &&
(yield* Ref.get(planSessionToolGrants)).has(toolName)
) {
return {
behavior: "allow",
updatedInput: toolInput,
toolUseID: callbackOptions.toolUseID,
} satisfies PermissionResult;
}

const requestKind = providerRequestKindFromClaudeTool(toolName);
// The user approves the command itself; Claude's title and description only summarize it.
const command = inputRecordValue(nativeToolInput, "command");
Expand Down Expand Up @@ -11743,7 +11761,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
);
if (!(yield* Deferred.isDone(decision))) yield* cancelWithdrawnRequest(artifacts);

return permissionResultFromDecision({
const result = permissionResultFromDecision({
toolName,
...resolvedDecision,
toolInput,
Expand All @@ -11753,6 +11771,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
? {}
: { suggestions: callbackOptions.suggestions }),
});
if (
result.behavior === "allow" &&
result.updatedPermissions?.some(
(update) =>
update.type === "addRules" &&
update.behavior === "allow" &&
update.rules.some(
(rule) => rule.toolName === toolName && rule.ruleContent === undefined,
),
) === true
) {
yield* Ref.update(planSessionToolGrants, (current) => new Set(current).add(toolName));
}
return result;
});

const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
Expand Down
Loading