Skip to content
73 changes: 56 additions & 17 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,17 @@ describe("ClaudeAdapterV2 approval presentation", () => {
callbackOptions: { decisionReason: "Run make?" },
});
assert.deepEqual(presentation, {});

// With no command, title, or description, the prompt is the raw reason itself.
const reasonOnly = "\u001b[33mHook needs confirmation\u001b[0m";
assert.deepEqual(
ClaudeAdapterV2.claudeApprovalPresentation({
permissionMode: "default",
prompt: reasonOnly,
callbackOptions: { decisionReason: reasonOnly },
}),
{},
);
});
});

Expand All @@ -832,6 +843,22 @@ describe("ClaudeAdapterV2 session permissions", () => {
assert.equal(result.interrupt, undefined);
});

it.each(["Bash", "Monitor"])(
"does not grant all %s commands without an SDK suggestion",
(toolName) => {
const result = ClaudeAdapterV2.permissionResultFromDecision({
toolName,
decision: "acceptForSession",
toolInput: { command: "git push" },
toolUseID: "escalated-command",
});
assert.equal(result.behavior, "allow");
if (result.behavior !== "allow") return;
assert.deepEqual(result.updatedInput, { command: "git push" });
assert.deepEqual(result.updatedPermissions ?? [], []);
},
);

it("forces suggested permission updates to session scope", () => {
const result = ClaudeAdapterV2.permissionResultFromDecision({
toolName: "Bash",
Expand Down Expand Up @@ -911,8 +938,12 @@ describe("ClaudeAdapterV2 session permissions", () => {
});
});

describe("ClaudeAdapterV2 Auto-accept edits", () => {
it.effect("asks before a command instead of allowing it", () =>
describe("ClaudeAdapterV2 escalated permissions", () => {
it.effect.each([
{ mode: "auto", response: "accept" },
{ mode: "auto", response: "decline" },
{ mode: "auto-accept-edits", response: "accept" },
] as const)("asks in $mode ($response)", ({ mode, response }) =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -957,7 +988,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
},
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "auto-accept-edits",
runtimeMode: mode,
interactionMode: "default",
cwd: "/workspace",
});
Expand Down Expand Up @@ -985,16 +1016,16 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
runtimePolicy,
}),
);
assert.equal(openedOptions?.permissionMode, "acceptEdits");
assert.equal(openedOptions?.permissionMode, mode === "auto" ? "auto" : "acceptEdits");
const canUseTool = openedOptions?.canUseTool;
assert.isFunction(canUseTool);

const requestEvent = yield* runtime.events.pipe(
Stream.takeUntil(
Stream.filter(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
),
Stream.runCollect,
Stream.runHead,
Effect.forkScoped,
);
const command = { command: "node -e 'console.log(42)'" };
Expand All @@ -1003,7 +1034,8 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
signal: new AbortController().signal,
toolUseID: "tool-bash-accept-edits",
requestId: "request-bash-accept-edits",
description: "Check the Node runtime",
title: "Claude wants to run node",
decisionReason: "\u001b[33mHook needs confirmation\u001b[0m",
}),
).pipe(Effect.forkScoped);
const first = yield* Effect.raceFirst(
Expand All @@ -1016,19 +1048,26 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
);
assert.equal(first.type, "request", "the command ran without asking");
if (first.type !== "request") return;
const event = first.event.find((event) => event.type === "runtime_request.updated");
assert.equal(event?.runtimeRequest.kind, "command");
const approval = first.event.findLast((event) => event.type === "turn_item.updated");
assert.equal(
approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined,
command.command,
);
const event = first.event;
assert.isTrue(Option.isSome(event));
if (
Option.isNone(event) ||
event.value.type !== "turn_item.updated" ||
event.value.turnItem.type !== "approval_request"
)
return;
assert.equal(event.value.turnItem.requestKind, "command");
assert.equal(event.value.turnItem.prompt, command.command);
assert.equal(event.value.turnItem.reason, "Hook needs confirmation");

yield* runtime.respondToRuntimeRequest({
requestId: event!.runtimeRequest.id,
decision: "accept",
requestId: event.value.turnItem.requestId,
decision: response,
});
assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
assert.equal(
(yield* Fiber.join(decision))?.behavior,
response === "accept" ? "allow" : "deny",
);
}).pipe(
Effect.provide(
Layer.mergeAll(
Expand Down
13 changes: 9 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2887,12 +2887,11 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy(
readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy)
? readOnlyTools
: undefined;
// acceptEdits approves edits before the callback; the callback must ask for everything else.
// Claude handles mode-approved actions before the callback; escalations still need approval.
const installPermissionCallback =
downgraded ||
(runtimePolicy.approvalPolicy === undefined
? runtimePolicy.runtimeMode === "approval-required" ||
runtimePolicy.runtimeMode === "auto-accept-edits"
? runtimePolicy.runtimeMode !== "full-access"
: runtimePolicy.approvalPolicy !== "never");

if (permissionMode === "plan") {
Expand Down Expand Up @@ -3738,7 +3737,9 @@ export function claudeApprovalPresentation(input: {
? "provider_check"
: "ask_rule";
return {
...(reason === "" || reason === input.prompt ? {} : { reason }),
...(reason === "" || reason === NodeUtil.stripVTControlCharacters(input.prompt).trim()
? {}
: { reason }),
...(fullAccessOverride === undefined ? {} : { fullAccessOverride }),
...(input.callbackOptions.suppressAlwaysAllowRule === true
? { options: CLAUDE_NO_STANDING_GRANT_OPTIONS }
Expand Down Expand Up @@ -3800,6 +3801,10 @@ function toSessionPermissionUpdates(
if (updates.length > 0) {
return updates;
}
// Shell tools need an SDK-scoped suggestion to avoid allowing unrelated commands.
if (toolName === "Bash" || toolName === "Monitor") {
return [];
}
return [
{
type: "addRules",
Expand Down
Loading