From 77e1702ea23c1dc13d5a859392a67dcd34dfd2de Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 4 Jul 2026 00:41:30 -0400 Subject: [PATCH] fix(core): preserve denied permission context --- packages/core/src/permission.ts | 10 ++++++++- packages/core/src/session/to-session-error.ts | 2 +- packages/core/test/tool-apply-patch.test.ts | 10 ++++++++- packages/core/test/tool-edit.test.ts | 10 ++++++++- packages/core/test/tool-question.test.ts | 22 +++++++++++++++++-- packages/core/test/tool-read.test.ts | 14 +++++++++++- packages/core/test/tool-shell.test.ts | 10 ++++++++- packages/core/test/tool-skill.test.ts | 12 +++++++++- packages/core/test/tool-todowrite.test.ts | 12 +++++++++- packages/core/test/tool-write.test.ts | 10 ++++++++- 10 files changed, 101 insertions(+), 11 deletions(-) diff --git a/packages/core/src/permission.ts b/packages/core/src/permission.ts index 05b5e674a9..369f8a8d1e 100644 --- a/packages/core/src/permission.ts +++ b/packages/core/src/permission.ts @@ -74,7 +74,13 @@ export class CorrectedError extends Schema.TaggedErrorClass()("P export class DeniedError extends Schema.TaggedErrorClass()("PermissionV2.DeniedError", { rules: Permission.Ruleset, -}) {} + permission: Schema.String, + resources: Schema.Array(Schema.String), +}) { + override get message() { + return `Permission denied: ${this.permission}` + } +} export class NotFoundError extends Schema.TaggedErrorClass()("PermissionV2.NotFoundError", { requestID: ID, @@ -216,6 +222,8 @@ const layer = Layer.effect( if (result.effect === "deny") { return yield* new DeniedError({ rules: relevant(input, result.rules), + permission: input.action, + resources: input.resources, }) } if (result.effect === "allow") return diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index db42d1a0a9..5a7018b9bc 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -40,7 +40,7 @@ export function toSessionError(cause: unknown): SessionError.Error { } } } - if (cause instanceof PermissionV2.RejectedError) + if (cause instanceof PermissionV2.DeniedError || cause instanceof PermissionV2.RejectedError) return { type: "permission.rejected", message: cause.message, diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index a717b799e3..56dd8bb935 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -47,7 +47,15 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 9df73c9b7d..23fe6c91ce 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -40,7 +40,15 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-question.test.ts b/packages/core/test/tool-question.test.ts index dc01563022..e83ca04532 100644 --- a/packages/core/test/tool-question.test.ts +++ b/packages/core/test/tool-question.test.ts @@ -23,7 +23,17 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), @@ -72,7 +82,15 @@ describe("QuestionTool", () => { ...toolIdentity, call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } }, }), - ).toEqual({ result: { type: "error", value: "Permission denied: question" } }) + ).toEqual({ + result: { type: "error", value: "Permission denied: question" }, + error: { + type: "permission.rejected", + message: "Permission denied: question", + permission: "question", + resources: ["*"], + }, + }) expect(capturedInput()).toBeUndefined() deny = false }), diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index f6f60df485..adcf510277 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -81,7 +81,19 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ), + ), + ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), get: () => Effect.die("unused"), diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index bec55e4b8a..ffcc941fe5 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -47,7 +47,15 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"), diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 23e51f6d08..f290818910 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -55,7 +55,17 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), diff --git a/packages/core/test/tool-todowrite.test.ts b/packages/core/test/tool-todowrite.test.ts index 977ece9fc8..5b1a00eeab 100644 --- a/packages/core/test/tool-todowrite.test.ts +++ b/packages/core/test/tool-todowrite.test.ts @@ -33,7 +33,17 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, + ), ), ask: () => Effect.die("unused"), reply: () => Effect.die("unused"), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 80fb011cc2..6fd5fe273c 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -38,7 +38,15 @@ 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.DeniedError({ + rules: [], + permission: input.action, + resources: input.resources, + }), + ) + : Effect.void, ), ), ask: () => Effect.die("unused"),