diff --git a/packages/opencode/src/kilocode/permission/provenance.ts b/packages/opencode/src/kilocode/permission/provenance.ts index 9552e41913..5bfb06922e 100644 --- a/packages/opencode/src/kilocode/permission/provenance.ts +++ b/packages/opencode/src/kilocode/permission/provenance.ts @@ -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 | undefined, + next: Record | 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 diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b10b3643a7..712718048a 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -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 | undefined, next: Record | 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, diff --git a/packages/opencode/test/kilocode/permission/provenance.test.ts b/packages/opencode/test/kilocode/permission/provenance.test.ts index a406a4762a..d9f990193c 100644 --- a/packages/opencode/test/kilocode/permission/provenance.test.ts +++ b/packages/opencode/test/kilocode/permission/provenance.test.ts @@ -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 = {