Skip to content

Commit 709af58

Browse files
fix(core): stop after declined permissions (#35356)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
1 parent bcbbf32 commit 709af58

12 files changed

Lines changed: 190 additions & 24 deletions

packages/core/src/permission.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,21 @@ export type AskResult = typeof AskResult.Type
5757

5858
export const Event = Permission.Event
5959

60-
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("PermissionV2.RejectedError", {}) {}
60+
export class DeclinedError extends Schema.TaggedErrorClass<DeclinedError>()("PermissionV2.DeclinedError", {}) {}
6161

6262
export class CorrectedError extends Schema.TaggedErrorClass<CorrectedError>()("PermissionV2.CorrectedError", {
6363
feedback: Schema.String,
6464
}) {}
6565

66-
export class DeniedError extends Schema.TaggedErrorClass<DeniedError>()("PermissionV2.DeniedError", {
66+
export class BlockedError extends Schema.TaggedErrorClass<BlockedError>()("PermissionV2.BlockedError", {
6767
rules: Permission.Ruleset,
6868
}) {}
6969

7070
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("PermissionV2.NotFoundError", {
7171
requestID: ID,
7272
}) {}
7373

74-
export type Error = DeniedError | RejectedError | CorrectedError
74+
export type Error = BlockedError | CorrectedError
7575

7676
export function evaluate(action: string, resource: string, ...rulesets: Permission.Ruleset[]): Permission.Rule {
7777
return (
@@ -103,7 +103,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
103103
interface Pending {
104104
readonly request: Request
105105
readonly agent?: AgentV2.ID
106-
readonly deferred: Deferred.Deferred<void, RejectedError | CorrectedError>
106+
readonly deferred: Deferred.Deferred<void, DeclinedError | CorrectedError>
107107
}
108108

109109
const layer = Layer.effect(
@@ -117,7 +117,7 @@ const layer = Layer.effect(
117117
const pending = new Map<ID, Pending>()
118118

119119
yield* EffectRuntime.addFinalizer(() =>
120-
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
120+
EffectRuntime.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new DeclinedError()), {
121121
discard: true,
122122
}).pipe(
123123
EffectRuntime.ensuring(
@@ -176,7 +176,7 @@ const layer = Layer.effect(
176176
const create = (request: Request, agent?: AgentV2.ID) =>
177177
EffectRuntime.uninterruptible(
178178
EffectRuntime.gen(function* () {
179-
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>()
179+
const deferred = yield* Deferred.make<void, DeclinedError | CorrectedError>()
180180
const item = { request, agent, deferred }
181181
if (pending.has(request.id)) return yield* EffectRuntime.die(`Duplicate pending permission ID: ${request.id}`)
182182
pending.set(request.id, item)
@@ -199,13 +199,14 @@ const layer = Layer.effect(
199199
EffectRuntime.gen(function* () {
200200
const result = yield* evaluateInput(input)
201201
if (result.effect === "deny") {
202-
return yield* new DeniedError({
202+
return yield* new BlockedError({
203203
rules: relevant(input, result.rules),
204204
})
205205
}
206206
if (result.effect === "allow") return
207207
const item = yield* create(request(input), input.agent)
208208
return yield* restore(Deferred.await(item.deferred)).pipe(
209+
EffectRuntime.catchTag("PermissionV2.DeclinedError", (error) => EffectRuntime.die(error)),
209210
EffectRuntime.ensuring(
210211
EffectRuntime.sync(() => {
211212
pending.delete(item.request.id)
@@ -230,7 +231,7 @@ const layer = Layer.effect(
230231
if (input.reply === "reject") {
231232
yield* Deferred.fail(
232233
existing.deferred,
233-
input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(),
234+
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
234235
)
235236
pending.delete(input.requestID)
236237
for (const [id, item] of pending) {
@@ -240,7 +241,7 @@ const layer = Layer.effect(
240241
requestID: item.request.id,
241242
reply: "reject",
242243
})
243-
yield* Deferred.fail(item.deferred, new RejectedError())
244+
yield* Deferred.fail(item.deferred, new DeclinedError())
244245
pending.delete(id)
245246
}
246247
return

packages/core/src/session/runner/llm.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Database } from "../../database/database"
1515
import { EventV2 } from "../../event"
1616
import { Location } from "../../location"
1717
import { ModelV2 } from "../../model"
18+
import { PermissionV2 } from "../../permission"
1819
import { ProviderV2 } from "../../provider"
1920
import { QuestionV2 } from "../../question"
2021
import { SystemContext } from "../../system-context/index"
@@ -140,9 +141,13 @@ const layer = Layer.effect(
140141
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
141142
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))
142143

143-
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
144-
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
145-
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
144+
// Match V1: declining a user prompt halts the loop instead of becoming model-facing tool output.
145+
const isUserDeclined = (cause: Cause.Cause<unknown>) =>
146+
cause.reasons.some(
147+
(reason) =>
148+
Cause.isDieReason(reason) &&
149+
(reason.defect instanceof PermissionV2.DeclinedError || reason.defect instanceof QuestionV2.RejectedError),
150+
)
146151

147152
type TurnTransition =
148153
// Automatic compaction completed; rebuild the request from compacted history.
@@ -289,7 +294,7 @@ const layer = Layer.effect(
289294
}
290295
if (stream._tag === "Failure" && Cause.hasInterrupts(stream.cause)) yield* FiberSet.clear(toolFibers)
291296
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
292-
if (settled._tag === "Failure" && isQuestionRejected(settled.cause)) {
297+
if (settled._tag === "Failure" && isUserDeclined(settled.cause)) {
293298
yield* FiberSet.clear(toolFibers)
294299
yield* withPublication(publisher.failUnsettledTools("Tool execution interrupted"))
295300
return yield* Effect.interrupt

packages/core/test/permission.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect } from "bun:test"
2-
import { Deferred, Effect, Fiber, Layer } from "effect"
2+
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
33
import { AgentV2 } from "@opencode-ai/core/agent"
44
import { Database } from "@opencode-ai/core/database/database"
55
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -147,8 +147,8 @@ describe("PermissionV2", () => {
147147
const service = yield* PermissionV2.Service
148148
yield* service.assert(assertion())
149149
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
150-
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
151-
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
150+
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
151+
expect(blocked).toBeInstanceOf(PermissionV2.BlockedError)
152152
expect(yield* service.list()).toEqual([])
153153
}),
154154
)
@@ -265,6 +265,24 @@ describe("PermissionV2", () => {
265265
}),
266266
)
267267

268+
it.effect("defects when an asked permission is declined", () =>
269+
Effect.gen(function* () {
270+
yield* setup()
271+
const { service, fiber, request } = yield* waitForRequest()
272+
yield* service.reply({ requestID: request.id, reply: "reject" })
273+
const exit = yield* Fiber.await(fiber)
274+
275+
expect(exit._tag).toBe("Failure")
276+
if (exit._tag === "Failure")
277+
expect(
278+
exit.cause.reasons.some(
279+
(reason) => Cause.isDieReason(reason) && reason.defect instanceof PermissionV2.DeclinedError,
280+
),
281+
).toBe(true)
282+
expect(yield* service.list()).toEqual([])
283+
}),
284+
)
285+
268286
it.effect("stores and removes saved resources for a project", () =>
269287
Effect.gen(function* () {
270288
yield* setup()

packages/core/test/session-runner.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2609,6 +2609,148 @@ describe("SessionRunnerLLM", () => {
26092609
}),
26102610
)
26112611

2612+
it.effect("returns policy-blocked tools to the model and continues", () =>
2613+
Effect.gen(function* () {
2614+
yield* setup
2615+
const session = yield* SessionV2.Service
2616+
const registry = yield* ToolRegistry.Service
2617+
yield* registry.register({
2618+
blocked: Tool.make({
2619+
description: "Fail because policy blocked execution",
2620+
input: Schema.Struct({}),
2621+
output: Schema.Struct({}),
2622+
execute: () =>
2623+
Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe(
2624+
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
2625+
),
2626+
}),
2627+
})
2628+
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call blocked" }), resume: false })
2629+
2630+
requests.length = 0
2631+
responses = [
2632+
[
2633+
LLMEvent.stepStart({ index: 0 }),
2634+
LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }),
2635+
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2636+
LLMEvent.finish({ reason: "tool-calls" }),
2637+
],
2638+
[
2639+
LLMEvent.stepStart({ index: 0 }),
2640+
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2641+
LLMEvent.finish({ reason: "stop" }),
2642+
],
2643+
]
2644+
2645+
yield* session.resume(sessionID)
2646+
2647+
expect(requests).toHaveLength(2)
2648+
expect(yield* session.context(sessionID)).toMatchObject([
2649+
{ type: "user", text: "Call blocked" },
2650+
{
2651+
type: "assistant",
2652+
content: [
2653+
{ type: "tool", id: "call-blocked", state: { status: "error", error: { message: "Permission blocked" } } },
2654+
],
2655+
},
2656+
{ type: "assistant", finish: "stop" },
2657+
])
2658+
}),
2659+
)
2660+
2661+
it.effect("interrupts runner continuation when permission approval is declined", () =>
2662+
Effect.gen(function* () {
2663+
yield* setup
2664+
const session = yield* SessionV2.Service
2665+
const registry = yield* ToolRegistry.Service
2666+
yield* registry.register({
2667+
declined: Tool.make({
2668+
description: "Fail because the user declined approval",
2669+
input: Schema.Struct({}),
2670+
output: Schema.Struct({}),
2671+
execute: () => Effect.die(new PermissionV2.DeclinedError()),
2672+
}),
2673+
})
2674+
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call declined" }), resume: false })
2675+
2676+
requests.length = 0
2677+
response = [
2678+
LLMEvent.stepStart({ index: 0 }),
2679+
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
2680+
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2681+
LLMEvent.finish({ reason: "tool-calls" }),
2682+
]
2683+
2684+
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
2685+
2686+
expect(exit._tag).toBe("Failure")
2687+
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
2688+
expect(requests).toHaveLength(1)
2689+
expect(yield* session.context(sessionID)).toMatchObject([
2690+
{ type: "user", text: "Call declined" },
2691+
{
2692+
type: "assistant",
2693+
content: [
2694+
{
2695+
type: "tool",
2696+
id: "call-declined",
2697+
state: { status: "error", error: { message: "Tool execution interrupted" } },
2698+
},
2699+
],
2700+
},
2701+
])
2702+
}),
2703+
)
2704+
2705+
it.effect("returns permission corrections to the model and continues", () =>
2706+
Effect.gen(function* () {
2707+
yield* setup
2708+
const session = yield* SessionV2.Service
2709+
const registry = yield* ToolRegistry.Service
2710+
yield* registry.register({
2711+
corrected: Tool.make({
2712+
description: "Fail with user correction feedback",
2713+
input: Schema.Struct({}),
2714+
output: Schema.Struct({}),
2715+
execute: () =>
2716+
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
2717+
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
2718+
),
2719+
}),
2720+
})
2721+
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call corrected" }), resume: false })
2722+
2723+
requests.length = 0
2724+
responses = [
2725+
[
2726+
LLMEvent.stepStart({ index: 0 }),
2727+
LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }),
2728+
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
2729+
LLMEvent.finish({ reason: "tool-calls" }),
2730+
],
2731+
[
2732+
LLMEvent.stepStart({ index: 0 }),
2733+
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
2734+
LLMEvent.finish({ reason: "stop" }),
2735+
],
2736+
]
2737+
2738+
yield* session.resume(sessionID)
2739+
2740+
expect(requests).toHaveLength(2)
2741+
expect(yield* session.context(sessionID)).toMatchObject([
2742+
{ type: "user", text: "Call corrected" },
2743+
{
2744+
type: "assistant",
2745+
content: [
2746+
{ type: "tool", id: "call-corrected", state: { status: "error", error: { message: "Use another tool" } } },
2747+
],
2748+
},
2749+
{ type: "assistant", finish: "stop" },
2750+
])
2751+
}),
2752+
)
2753+
26122754
it.effect("interrupts runner continuation when a question is dismissed", () =>
26132755
Effect.gen(function* () {
26142756
yield* setup

packages/core/test/tool-apply-patch.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const permission = Layer.succeed(
4040
}).pipe(
4141
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
4242
Effect.andThen(
43-
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
43+
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
4444
),
4545
),
4646
ask: () => Effect.die("unused"),

packages/core/test/tool-bash.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ const permission = Layer.succeed(
5151
Effect.sync(() => assertions.push(input)).pipe(
5252
Effect.andThen(Effect.suspend(() => afterPermission(input))),
5353
Effect.andThen(
54-
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
54+
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
5555
),
5656
),
5757
ask: () => Effect.die("unused"),

packages/core/test/tool-edit.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ const permission = Layer.succeed(
3333
assert: (input) =>
3434
Effect.sync(() => assertions.push(input)).pipe(
3535
Effect.andThen(
36-
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
36+
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
3737
),
3838
),
3939
ask: () => Effect.die("unused"),

packages/core/test/tool-question.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const permission = Layer.succeed(
2222
PermissionV2.Service.of({
2323
assert: (input) =>
2424
Effect.sync(() => assertions.push(input)).pipe(
25-
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
25+
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
2626
),
2727
ask: () => Effect.die("unused"),
2828
reply: () => Effect.die("unused"),

packages/core/test/tool-read.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ const permission = Layer.succeed(
6464
assert: (input) =>
6565
Effect.sync(() => {
6666
assertions.push(input)
67-
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
67+
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
6868
ask: () => Effect.die("unused"),
6969
reply: () => Effect.die("unused"),
7070
get: () => Effect.die("unused"),

packages/core/test/tool-skill.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ describe("SkillTool", () => {
4747
PermissionV2.Service.of({
4848
assert: (input) =>
4949
Effect.sync(() => assertions.push(input)).pipe(
50-
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
50+
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
5151
),
5252
ask: () => Effect.die("unused"),
5353
reply: () => Effect.die("unused"),

0 commit comments

Comments
 (0)