mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
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:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user