test(cli): cover approval-provenance metadata preservation

This commit is contained in:
Bruno Agatao
2026-07-23 17:26:12 +02:00
parent 85dbf443af
commit ff02a977c5
3 changed files with 49 additions and 7 deletions
@@ -35,6 +35,21 @@ export namespace PermissionProvenance {
return "agent"
}
/**
* Preserve an existing `approval` marker when a tool part's metadata is replaced.
*
* The approval is written once during `ask()`, but tools freely overwrite `state.metadata`
* during execution and on completion. Carry the prior `approval` onto the replacement unless
* the replacement sets its own.
*/
export function carryApproval(
prev: Record<string, unknown> | undefined,
next: Record<string, unknown> | undefined,
) {
if (!next || !prev?.approval || "approval" in next) return next
return { ...next, approval: prev.approval }
}
/** Classify the winning rule of an auto-approval into an Approval payload. */
export function classify(input: { rule?: Permission.Rule; agent: string; origins: Origins }): Approval {
const rule = input.rule
+8 -7
View File
@@ -22,6 +22,7 @@ import type { Provider } from "@/provider/provider"
import { Question } from "@/question"
// kilocode_change start
import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor"
import { PermissionProvenance } from "@/kilocode/permission/provenance" // kilocode_change
import { KiloSessionOverflow } from "@/kilocode/session/overflow"
import { KiloRoutedModel } from "@/kilocode/session/routed-model"
import { Suggestion } from "@/kilocode/suggestion"
@@ -270,13 +271,13 @@ export const layer = Layer.effect(
) {
const match = yield* readToolCall(toolCallID)
// approval provenance is written once during ask() and must survive later tool metadata writes
const carryApproval = (prev: Record<string, any> | undefined, next: Record<string, any> | undefined) => {
if (!next || !prev?.approval || "approval" in next) return next
return { ...next, approval: prev.approval }
}
if (!match || match.part.state.status !== "running") {
const prev = ctx.toolmeta[toolCallID]
ctx.toolmeta[toolCallID] = { ...prev, ...input, metadata: carryApproval(prev?.metadata, input.metadata) }
ctx.toolmeta[toolCallID] = {
...prev,
...input,
metadata: PermissionProvenance.carryApproval(prev?.metadata, input.metadata),
}
return
}
yield* updateToolCall(toolCallID, (part) => {
@@ -286,7 +287,7 @@ export const layer = Layer.effect(
state: {
...part.state,
title: input.title ?? part.state.title,
metadata: carryApproval(part.state.metadata, input.metadata) ?? part.state.metadata,
metadata: PermissionProvenance.carryApproval(part.state.metadata, input.metadata) ?? part.state.metadata,
},
}
})
@@ -306,7 +307,7 @@ export const layer = Layer.effect(
if (!match || match.part.state.status !== "running") return
// kilocode_change start - preserve approval provenance recorded during permission checks
const prior = isRecord(match.part.state.metadata) ? match.part.state.metadata : undefined
const metadata = prior?.approval ? { ...output.metadata, approval: prior.approval } : output.metadata
const metadata = PermissionProvenance.carryApproval(prior, output.metadata) ?? output.metadata
// kilocode_change end
yield* session.updatePart({
...match.part,
@@ -56,6 +56,32 @@ describe("PermissionProvenance", () => {
})
})
describe("PermissionProvenance.carryApproval", () => {
const approval = { source: "agent" as const, agent: "build" }
test("carries a prior approval onto a replacement that omits it", () => {
// The tool overwrites metadata during execution; the approval written during ask() must survive.
expect(PermissionProvenance.carryApproval({ approval }, { command: "echo hi" })).toEqual({
command: "echo hi",
approval,
})
})
test("does not override an approval the replacement sets itself", () => {
const next = { approval: { source: "yolo" as const } }
expect(PermissionProvenance.carryApproval({ approval }, next)).toBe(next)
})
test("leaves the replacement untouched when there is no prior approval", () => {
const next = { command: "echo hi" }
expect(PermissionProvenance.carryApproval({ command: "old" }, next)).toBe(next)
})
test("returns the replacement as-is when it is undefined", () => {
expect(PermissionProvenance.carryApproval({ approval }, undefined)).toBeUndefined()
})
})
describe("askPermission returns provenance", () => {
const sessionID = SessionID.make("ses_prov")
const agent: Agent.Info = {