feat(cli): record why a tool call was denied on its metadata

Auto-approval provenance was only recorded on the metadata of allowed
tool calls (state.metadata.approval), so denied calls had no
structured explanation of which rule/config/agent denied them. Since
'kilo export' serializes state.metadata verbatim into the JSON session
log, denials showed up with no provenance at all.

Add PermissionProvenance.classifyDenial, which reads the deciding deny
rule off a DeniedError's tagged ruleset and classifies it the same way
approvals are classified. Wire it into SessionTools' ctx.ask via
Effect.tapErrorTag so denials are recorded before the tool call fails,
reusing the existing carryApproval/failToolCall preservation so the
metadata survives onto the final error state.
This commit is contained in:
Bruno Agatao
2026-07-30 18:04:39 +02:00
parent af6bb00eed
commit 63d35d06cf
3 changed files with 55 additions and 2 deletions
@@ -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 })
}
}
+13
View File
@@ -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,
),
@@ -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<string, any> }[] = []) {
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<string, any> }[] = []
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" },
})
}),
)