diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 50e4309bdb..abf9647d72 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -98,4 +98,17 @@ export namespace PermissionProvenance { rule: { permission: rule.permission, pattern: rule.pattern, action: rule.action }, } } + + /** + * Classify why a tool call was denied, from the `ruleset` a `DeniedError` carries. + * + * `DeniedError.ruleset` is untyped (`Schema.Any`) but is always the tagged ruleset `askPermission` + * built, filtered to the request's permission. The last `deny` rule in it is the one that decided. + */ + export function classifyDenial(input: { ruleset: unknown; agent: string; origins: Origins }): Approval { + const rule = Array.isArray(input.ruleset) + ? (input.ruleset as Permission.Rule[]).findLast((rule) => rule.action === "deny") + : undefined + return classify({ rule, agent: input.agent, origins: input.origins }) + } } diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index f6d78654cb..f33c8fc627 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -26,6 +26,7 @@ import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change start import { SwePruner } from "@/kilocode/swe-pruner" import { Config } from "@/config/config" +import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change end export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { @@ -82,6 +83,18 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { }).pipe( // record why the call was allowed onto the tool part, then discard the outcome for the tool-facing ask Effect.tap((approval) => input.processor.metadata(options.toolCallId, { metadata: { approval } })), + // record why the call was denied too, so JSON exports and clients can explain the denial + Effect.tapErrorTag("PermissionDeniedError", (err) => + input.processor.metadata(options.toolCallId, { + metadata: { + approval: PermissionProvenance.classifyDenial({ + ruleset: err.ruleset, + agent: input.agent.name, + origins: permissionOrigins, + }), + }, + }), + ), Effect.asVoid, Effect.orDie, ), diff --git a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts index 39331ff436..a41a244cec 100644 --- a/packages/opencode/test/kilocode/sandbox/session-tools.test.ts +++ b/packages/opencode/test/kilocode/sandbox/session-tools.test.ts @@ -157,14 +157,15 @@ const registry = Layer.effect( const it = testEffect(registry) const mac = process.platform === "darwin" && existsSync("/usr/bin/sandbox-exec") ? it.live : it.live.skip -function resolve(ctx: InstanceContext) { +function resolve(ctx: InstanceContext, metadataCalls: { toolCallID: string; value: Record }[] = []) { return SessionTools.resolve({ agent, model, session: session(ctx.directory), processor: { message: message(ctx), - metadata: () => Effect.void, + // capture metadata writes so tests can assert on recorded approval provenance + metadata: (toolCallID, value) => Effect.sync(() => void metadataCalls.push({ toolCallID, value })), completeToolCall: () => Effect.void, }, bypassAgentCheck: false, @@ -355,3 +356,29 @@ mac("confines a model-originated sandboxed process to the active worktree", () = expect(yield* exists(primary)).toBe(false) }), ) + +it.live("records why a denied tool call was refused on the tool part's metadata", () => + Effect.gen(function* () { + const dirs = yield* fixture() + const metadataCalls: { toolCallID: string; value: Record }[] = [] + const deniedRuleset = [{ permission: "bash", pattern: "*", action: "deny" as const, source: "project" as const }] + const overrides = Layer.mergeAll( + TestConfig.layer({ get: () => Effect.succeed({ sandbox: { enabled: false } }) }), + Layer.mock(Permission.Service)({ + ask: () => Effect.fail(new Permission.DeniedError({ ruleset: deniedRuleset })), + }), + ) + const tools = yield* resolve(dirs.ctx, metadataCalls).pipe(Effect.provide(overrides)) + const shell = tools.bash + if (!shell) yield* Effect.die(new Error("bash tool is missing")) + + const result = yield* call(shell, { command: "echo hi", workdir: dirs.a }, "call-denied").pipe(Effect.exit) + + expect(Exit.isFailure(result)).toBe(true) + const approval = metadataCalls.find((c) => c.toolCallID === "call-denied")?.value?.metadata?.approval + expect(approval).toEqual({ + source: "project", + rule: { permission: "bash", pattern: "*", action: "deny" }, + }) + }), +)