fix(core): stop after declined permissions (#35356)

Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-07-04 16:17:11 -05:00 committed by GitHub
commit 709af58612
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 190 additions and 24 deletions

View file

@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Fiber, Layer } from "effect"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@ -147,8 +147,8 @@ describe("PermissionV2", () => {
const service = yield* PermissionV2.Service
yield* service.assert(assertion())
yield* setRules([{ action: "read", resource: "*", effect: "deny" }])
const denied = yield* service.assert(assertion()).pipe(Effect.flip)
expect(denied).toBeInstanceOf(PermissionV2.DeniedError)
const blocked = yield* service.assert(assertion()).pipe(Effect.flip)
expect(blocked).toBeInstanceOf(PermissionV2.BlockedError)
expect(yield* service.list()).toEqual([])
}),
)
@ -265,6 +265,24 @@ describe("PermissionV2", () => {
}),
)
it.effect("defects when an asked permission is declined", () =>
Effect.gen(function* () {
yield* setup()
const { service, fiber, request } = yield* waitForRequest()
yield* service.reply({ requestID: request.id, reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure")
expect(
exit.cause.reasons.some(
(reason) => Cause.isDieReason(reason) && reason.defect instanceof PermissionV2.DeclinedError,
),
).toBe(true)
expect(yield* service.list()).toEqual([])
}),
)
it.effect("stores and removes saved resources for a project", () =>
Effect.gen(function* () {
yield* setup()

View file

@ -2609,6 +2609,148 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("returns policy-blocked tools to the model and continues", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
blocked: Tool.make({
description: "Fail because policy blocked execution",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.BlockedError({ rules: [] })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Permission blocked" })),
),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call blocked" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-blocked", name: "blocked", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call blocked" },
{
type: "assistant",
content: [
{ type: "tool", id: "call-blocked", state: { status: "error", error: { message: "Permission blocked" } } },
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("interrupts runner continuation when permission approval is declined", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
declined: Tool.make({
description: "Fail because the user declined approval",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () => Effect.die(new PermissionV2.DeclinedError()),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call declined" }), resume: false })
requests.length = 0
response = [
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
]
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call declined" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-declined",
state: { status: "error", error: { message: "Tool execution interrupted" } },
},
],
},
])
}),
)
it.effect("returns permission corrections to the model and continues", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
yield* registry.register({
corrected: Tool.make({
description: "Fail with user correction feedback",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new PermissionV2.CorrectedError({ feedback: "Use another tool" })).pipe(
Effect.mapError(() => new Tool.Failure({ message: "Use another tool" })),
),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Call corrected" }), resume: false })
requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-corrected", name: "corrected", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.stepFinish({ index: 0, reason: "stop" }),
LLMEvent.finish({ reason: "stop" }),
],
]
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Call corrected" },
{
type: "assistant",
content: [
{ type: "tool", id: "call-corrected", state: { status: "error", error: { message: "Use another tool" } } },
],
},
{ type: "assistant", finish: "stop" },
])
}),
)
it.effect("interrupts runner continuation when a question is dismissed", () =>
Effect.gen(function* () {
yield* setup

View file

@ -40,7 +40,7 @@ const permission = Layer.succeed(
}).pipe(
Effect.andThen(input.action === "edit" ? Effect.suspend(afterEditApproval) : Effect.void),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),

View file

@ -51,7 +51,7 @@ const permission = Layer.succeed(
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(Effect.suspend(() => afterPermission(input))),
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),

View file

@ -33,7 +33,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),

View file

@ -22,7 +22,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),

View file

@ -64,7 +64,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => {
assertions.push(input)
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.DeniedError({ rules: [] })))),
}).pipe(Effect.andThen(allow ? Effect.void : Effect.fail(new PermissionV2.BlockedError({ rules: [] })))),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),

View file

@ -47,7 +47,7 @@ describe("SkillTool", () => {
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),

View file

@ -26,7 +26,7 @@ const permission = Layer.succeed(
PermissionV2.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(deny ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void),
Effect.andThen(deny ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),

View file

@ -31,7 +31,7 @@ const permission = Layer.succeed(
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
input.action === denyAction ? Effect.fail(new PermissionV2.DeniedError({ rules: [] })) : Effect.void,
input.action === denyAction ? Effect.fail(new PermissionV2.BlockedError({ rules: [] })) : Effect.void,
),
),
ask: () => Effect.die("unused"),