From 7d98c3f2789f2115e458ca1fb799d6610d1ef6eb Mon Sep 17 00:00:00 2001 From: Helge Tesdal Date: Mon, 27 Apr 2026 19:06:08 +0200 Subject: [PATCH] feat(run-events): track and emit auto-approve telemetry for skipPermissions Previously skipPermissions=true silently replied 'once' to every permission.asked event with no counter and no JSON event. Operators running --dangerously-skip-permissions had no audit trail of what the agent was allowed to do. Adds symmetric autoApprovedPermissions stat and 'auto-approve' JSON event mirroring the existing 'auto-reject' telemetry shape. Addresses audit finding F8 (Opus diamond review, 2026-04-22). --- packages/opencode/src/cli/cmd/run-events.ts | 17 ++++ packages/opencode/test/cli/run-events.test.ts | 82 +++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/packages/opencode/src/cli/cmd/run-events.ts b/packages/opencode/src/cli/cmd/run-events.ts index 791a157902ac..2ec97ecaa769 100644 --- a/packages/opencode/src/cli/cmd/run-events.ts +++ b/packages/opencode/src/cli/cmd/run-events.ts @@ -21,6 +21,7 @@ export interface Config { export interface Stats { autoRejectedQuestions: number autoRejectedPermissions: number + autoApprovedPermissions: number livelockWarned: boolean } @@ -38,6 +39,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) { const stats: Stats = { autoRejectedQuestions: 0, autoRejectedPermissions: 0, + autoApprovedPermissions: 0, livelockWarned: false, } @@ -86,6 +88,20 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) { } } + // No question-equivalent of bumpApprove: questions are always auto-rejected + // when they belong to our subagent lineage, never auto-approved. The approve + // counter is also intentionally separate from the livelock total — operators + // opt into skipPermissions and shouldn't trip the warn-threshold meant to + // detect auto-reject loops. + const bumpApprove = (sid: SessionID) => { + stats.autoApprovedPermissions++ + emit("auto-approve", { + kind: "permission", + autoApproveSessionID: sid, + totalAutoApproves: stats.autoApprovedPermissions, + }) + } + // bus.subscribeCallback wraps the callback in an Effect.tryPromise-based // subscription handler, so a Promise-returning callback (like Effect.runPromise) // serializes handler completion per subscription. runFork returns a Fiber @@ -139,6 +155,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) { const mine = yield* isDescendant(evt.properties.sessionID) if (!mine) return if (config.skipPermissions) { + bumpApprove(evt.properties.sessionID) yield* permission.reply({ requestID: evt.properties.id, reply: "once" }) return } diff --git a/packages/opencode/test/cli/run-events.test.ts b/packages/opencode/test/cli/run-events.test.ts index ad0ebb804916..5a392e956cfa 100644 --- a/packages/opencode/test/cli/run-events.test.ts +++ b/packages/opencode/test/cli/run-events.test.ts @@ -569,4 +569,86 @@ describe("cli/run-events", () => { }), ), ) + + // F8: when skipPermissions=true the auto-approve branch must produce symmetric + // telemetry — Stats counter + JSON event — so operators running + // --dangerously-skip-permissions get an audit trail of what was approved. + it.live("increments autoApprovedPermissions and emits JSON event when skipPermissions=true", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const permission = yield* Permission.Service + const bus = yield* Bus.Service + const rootSessionID = SessionID.make("ses_root_auto_approve_0000000000000") + const replies: Array<{ sessionID: SessionID; reply: string }> = [] + const unsubscribeReply = yield* bus.subscribeCallback(Permission.Event.Replied, (evt) => { + replies.push({ sessionID: evt.properties.sessionID, reply: evt.properties.reply }) + }) + + const writes: string[] = [] + const originalWrite = process.stdout.write.bind(process.stdout) + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")) + return true + }) as typeof process.stdout.write + + yield* Effect.acquireUseRelease( + RunEvents.make({ + rootSessionID, + skipPermissions: true, + jsonMode: true, + }), + (handle) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + permission.ask({ + sessionID: rootSessionID, + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }), + ) + + yield* pollUntil( + () => Effect.sync(() => (replies.length === 1 ? Option.some(true) : Option.none())), + { label: "permission.replied event" }, + ) + + expect(Exit.isSuccess(exit)).toBe(true) + expect(replies[0]?.reply).toBe("once") + expect(handle.stats.autoApprovedPermissions).toBe(1) + expect(handle.stats.autoRejectedPermissions).toBe(0) + }), + (handle) => + Effect.sync(() => { + unsubscribeReply() + handle.unsubscribe() + }), + ).pipe( + Effect.ensuring( + Effect.sync(() => { + process.stdout.write = originalWrite + }), + ), + ) + + const payload = JSON.parse((writes[0] ?? "").trim()) as { + type: string + timestamp: number + sessionID: string + kind: string + autoApproveSessionID: string + totalAutoApproves: number + } + + expect(payload.type).toBe("auto-approve") + expect(typeof payload.timestamp).toBe("number") + expect(payload.sessionID).toBe(rootSessionID) + expect(payload.kind).toBe("permission") + expect(payload.autoApproveSessionID).toBe(rootSessionID) + expect(payload.totalAutoApproves).toBe(1) + }), + ), + ) })