Skip to content

Commit c105c60

Browse files
committed
merge: F8 symmetric auto-approve telemetry for skipPermissions
PR: #11 (closed, review-only) Diamond: codex-5.3 APPROVED_WITH_COMMENTS + Opus APPROVED_WITH_NITS (applied) Copilot: R1 clean (no comments)
2 parents 521665f + 7d98c3f commit c105c60

2 files changed

Lines changed: 99 additions & 0 deletions

File tree

packages/opencode/src/cli/cmd/run-events.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface Config {
2121
export interface Stats {
2222
autoRejectedQuestions: number
2323
autoRejectedPermissions: number
24+
autoApprovedPermissions: number
2425
livelockWarned: boolean
2526
}
2627

@@ -38,6 +39,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
3839
const stats: Stats = {
3940
autoRejectedQuestions: 0,
4041
autoRejectedPermissions: 0,
42+
autoApprovedPermissions: 0,
4143
livelockWarned: false,
4244
}
4345

@@ -86,6 +88,20 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
8688
}
8789
}
8890

91+
// No question-equivalent of bumpApprove: questions are always auto-rejected
92+
// when they belong to our subagent lineage, never auto-approved. The approve
93+
// counter is also intentionally separate from the livelock total — operators
94+
// opt into skipPermissions and shouldn't trip the warn-threshold meant to
95+
// detect auto-reject loops.
96+
const bumpApprove = (sid: SessionID) => {
97+
stats.autoApprovedPermissions++
98+
emit("auto-approve", {
99+
kind: "permission",
100+
autoApproveSessionID: sid,
101+
totalAutoApproves: stats.autoApprovedPermissions,
102+
})
103+
}
104+
89105
// bus.subscribeCallback wraps the callback in an Effect.tryPromise-based
90106
// subscription handler, so a Promise-returning callback (like Effect.runPromise)
91107
// serializes handler completion per subscription. runFork returns a Fiber
@@ -139,6 +155,7 @@ export const make = Effect.fn("RunEvents.make")(function* (config: Config) {
139155
const mine = yield* isDescendant(evt.properties.sessionID)
140156
if (!mine) return
141157
if (config.skipPermissions) {
158+
bumpApprove(evt.properties.sessionID)
142159
yield* permission.reply({ requestID: evt.properties.id, reply: "once" })
143160
return
144161
}

packages/opencode/test/cli/run-events.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,4 +569,86 @@ describe("cli/run-events", () => {
569569
}),
570570
),
571571
)
572+
573+
// F8: when skipPermissions=true the auto-approve branch must produce symmetric
574+
// telemetry — Stats counter + JSON event — so operators running
575+
// --dangerously-skip-permissions get an audit trail of what was approved.
576+
it.live("increments autoApprovedPermissions and emits JSON event when skipPermissions=true", () =>
577+
provideTmpdirInstance(() =>
578+
Effect.gen(function* () {
579+
const permission = yield* Permission.Service
580+
const bus = yield* Bus.Service
581+
const rootSessionID = SessionID.make("ses_root_auto_approve_0000000000000")
582+
const replies: Array<{ sessionID: SessionID; reply: string }> = []
583+
const unsubscribeReply = yield* bus.subscribeCallback(Permission.Event.Replied, (evt) => {
584+
replies.push({ sessionID: evt.properties.sessionID, reply: evt.properties.reply })
585+
})
586+
587+
const writes: string[] = []
588+
const originalWrite = process.stdout.write.bind(process.stdout)
589+
process.stdout.write = ((chunk: string | Uint8Array) => {
590+
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"))
591+
return true
592+
}) as typeof process.stdout.write
593+
594+
yield* Effect.acquireUseRelease(
595+
RunEvents.make({
596+
rootSessionID,
597+
skipPermissions: true,
598+
jsonMode: true,
599+
}),
600+
(handle) =>
601+
Effect.gen(function* () {
602+
const exit = yield* Effect.exit(
603+
permission.ask({
604+
sessionID: rootSessionID,
605+
permission: "bash",
606+
patterns: ["ls"],
607+
metadata: {},
608+
always: [],
609+
ruleset: [{ permission: "bash", pattern: "*", action: "ask" }],
610+
}),
611+
)
612+
613+
yield* pollUntil(
614+
() => Effect.sync(() => (replies.length === 1 ? Option.some(true) : Option.none())),
615+
{ label: "permission.replied event" },
616+
)
617+
618+
expect(Exit.isSuccess(exit)).toBe(true)
619+
expect(replies[0]?.reply).toBe("once")
620+
expect(handle.stats.autoApprovedPermissions).toBe(1)
621+
expect(handle.stats.autoRejectedPermissions).toBe(0)
622+
}),
623+
(handle) =>
624+
Effect.sync(() => {
625+
unsubscribeReply()
626+
handle.unsubscribe()
627+
}),
628+
).pipe(
629+
Effect.ensuring(
630+
Effect.sync(() => {
631+
process.stdout.write = originalWrite
632+
}),
633+
),
634+
)
635+
636+
const payload = JSON.parse((writes[0] ?? "").trim()) as {
637+
type: string
638+
timestamp: number
639+
sessionID: string
640+
kind: string
641+
autoApproveSessionID: string
642+
totalAutoApproves: number
643+
}
644+
645+
expect(payload.type).toBe("auto-approve")
646+
expect(typeof payload.timestamp).toBe("number")
647+
expect(payload.sessionID).toBe(rootSessionID)
648+
expect(payload.kind).toBe("permission")
649+
expect(payload.autoApproveSessionID).toBe(rootSessionID)
650+
expect(payload.totalAutoApproves).toBe(1)
651+
}),
652+
),
653+
)
572654
})

0 commit comments

Comments
 (0)