@mortz.prk/opencode-auto-mode 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -5,144 +5,414 @@ import { defaultPolicy } from "./policy.js";
5
5
  import { extractReviewContext } from "./transcript.js";
6
6
  import { scanResultContent, withInjectionWarning } from "./probe.js";
7
7
  import { buildStage1Prompt, buildStage2Prompt, parseDecision, parseStage1, withDenySuffix, type PermissionRequest, type ReviewResult, type StageResult } from "./reviewer.js";
8
- import { parseOptions, parseReviewerModel, type ReviewerModelRef } from "./config.js";
8
+ import { DEFAULT_REVIEW_TIMEOUT_MS, parseOptions, parseReviewerModel, type ReviewerModelRef } from "./config.js";
9
9
  import { compositeDecision } from "./classifier.js";
10
- import { decisionStage } from "./decisions.js";
10
+ import { decisionStage, DEFAULT_DECISION_TIMEOUT_MS } from "./decisions.js";
11
11
  import { createAuditLog } from "./audit.js";
12
12
  import { createOtlpExporter } from "./otlp.js";
13
- import { createTraceExporter } from "./traces.js";
13
+ import { createTraceExporter, type ClassifierAttempt } from "./traces.js";
14
+ import { errorKindOf, ReviewFailure, type ReviewErrorKind } from "./errors.js";
15
+ import { withTimeout } from "./timeout.js";
16
+ import { AutoMode, type AutoModeDecision } from "./rpc.js";
17
+ import { createStatusTracker } from "./status.js";
14
18
 
15
19
  export type { AutoModeOptions, ClassifierStage, DecisionProvider, DecisionStage, ReviewerModel } from "./config.js";
16
20
 
21
+ /** Delivered whenever auto-mode abandons a review: only the user can decide now. */
22
+ const INTERRUPTED_REASON = "Auto-mode review was interrupted; please decide yourself";
23
+
17
24
  export default Plugin.define({
18
25
  id: "opencode-auto-mode",
19
26
  setup: async (ctx) => {
20
27
  const log = createLogger("events.log");
21
28
  const directory = ctx.location?.directory;
29
+ let configError: string | undefined;
22
30
  let options: ReturnType<typeof parseOptions> | undefined;
23
31
  try {
24
32
  options = parseOptions(ctx.options);
25
33
  if (directory !== undefined) clearConfigError(directory);
26
34
  } catch (error) {
27
35
  const detail = error instanceof Error ? error.message : String(error);
36
+ configError = detail;
28
37
  log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
29
38
  if (directory !== undefined) recordConfigError(directory, detail);
30
39
  }
40
+ const reviewTimeoutMs = options?.reviewTimeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
31
41
  const sinks = [createAuditLog(options?.auditLog), createOtlpExporter(options?.otlp)].filter((sink) => sink !== undefined);
32
42
  const traces = createTraceExporter(options?.otlp);
33
43
  const counts = new Map<string, number>();
44
+ // Permission evaluations can overlap in one session; keep every active
45
+ // controller so an interruption aborts all of them, not only the newest.
46
+ const running = new Map<string, Set<AbortController>>();
47
+ const subscriptions = new AbortController();
48
+ const status = createStatusTracker();
34
49
  let reviewCounter = 0;
35
- const permissionRegistration = await ctx.permission.hook("evaluate", async (event) => {
36
- if (event.effect !== "ask") return;
37
- if (!options) {
38
- event.effect = "ask";
39
- event.message = "Invalid auto-mode configuration; please decide yourself";
40
- return;
41
- }
42
- const reviewID = `review-${process.pid}-${Date.now()}-${reviewCounter++}`;
43
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: "reviewing", action: event.action.slice(0, 128), reason: "", at: Date.now() });
44
- const startedAt = Date.now();
45
- const trace = traces?.begin(startedAt);
46
- const hostEffect = event.effect;
47
- let failure: string | undefined;
48
- let result: ReviewResult;
50
+ let rpc: Awaited<ReturnType<typeof ctx.rpc.register<typeof AutoMode>>> | undefined;
51
+ try {
52
+ rpc = await ctx.rpc.register(AutoMode, {
53
+ status: async (input) => {
54
+ const { sessionID } = input as { sessionID?: string };
55
+ return status.snapshot({
56
+ enabled: options !== undefined,
57
+ ...(configError === undefined ? {} : { configError }),
58
+ classifiers: options?.classifiers.map((stage) => stage.type) ?? [],
59
+ reviewTimeoutMs,
60
+ ...(sessionID === undefined ? {} : { sessionID }),
61
+ });
62
+ },
63
+ decisions: async (input) => {
64
+ const { limit, sessionID } = input as { limit?: number; sessionID?: string };
65
+ return status.recent(Number.isFinite(limit) ? Math.min(Math.max(Math.trunc(limit!), 1), 50) : 20, sessionID);
66
+ },
67
+ });
68
+ } catch { log.log("Status RPC is unavailable"); }
69
+
70
+ // Status events are only a freshness bonus: clients poll `status`, so a
71
+ // slow or failing emit must never keep the permission hook waiting. Start
72
+ // the delivery without awaiting it and observe both a synchronous throw
73
+ // from `emit` and a rejected promise it returns.
74
+ const publish = (payload: Record<string, unknown>) => {
49
75
  try {
50
- const info = await withTimeout(ctx.session.get({ sessionID: event.sessionID }), 3_000).catch(() => undefined);
51
- const request: PermissionRequest = {
52
- action: event.action, resources: [...event.resources], agent: event.agent,
53
- projectDir: info?.location?.directory, metadata: event.metadata, source: event.source,
54
- isDelegation: event.action === "task" || event.action === "subagent",
55
- };
56
- let contextLoaded = false;
57
- let contextComplete = false;
58
- let contextReason = "User authorization context is unavailable";
59
- result = await compositeDecision(options.classifiers, request, async (stage) => {
60
- if (!contextLoaded) {
61
- const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }), 3_000);
62
- const context = extractReviewContext(messages);
63
- request.transcript = context.transcript;
64
- contextComplete = context.complete;
65
- contextReason = context.reason ?? contextReason;
66
- contextLoaded = true;
76
+ void Promise.resolve(rpc?.events.emit("decision", payload)).catch(() => {});
77
+ } catch { /* status delivery is best effort */ }
78
+ };
79
+
80
+ const abortSession = (sessionID: string) => {
81
+ const controllers = running.get(sessionID);
82
+ if (controllers === undefined) return;
83
+ for (const controller of controllers) controller.abort();
84
+ };
85
+
86
+ // Abandon work for a session as soon as the host interrupts it, so a stale
87
+ // review cannot deliver an approval after the user stopped the turn.
88
+ const subscribe = (ctx.event as { subscribe?: (input: { types: string[]; signal?: AbortSignal }) => AsyncIterable<unknown> } | undefined)?.subscribe;
89
+ if (subscribe) {
90
+ void (async () => {
91
+ try {
92
+ const events = subscribe.call(ctx.event, { types: ["session.execution.interrupted"], signal: subscriptions.signal });
93
+ for await (const event of events) {
94
+ const data = (event as { data?: { sessionID?: unknown } }).data;
95
+ const sessionID = data?.sessionID;
96
+ if (sessionID !== undefined) abortSession(String(sessionID));
67
97
  }
68
- if (!contextComplete)
69
- return { decision: "ask", reason: contextReason };
70
- const policy = defaultPolicy(stage.policy);
71
- const attemptStartedAt = Date.now();
72
- let outcome: StageResult;
98
+ } catch { /* the event stream is best effort */ }
99
+ })();
100
+ }
101
+
102
+ // Once abandonment starts, an evaluation the host dispatched earlier may
103
+ // still reach the hook while cleanup is stalled. Record it so the handler
104
+ // never starts review work the interruption subscription can no longer
105
+ // cancel.
106
+ let stopping = false;
107
+
108
+ // Stop the event reader and every active review synchronously, before any
109
+ // cleanup is awaited: a stalled disposal must not leave an in-flight review
110
+ // able to deliver an allow instead of the cancellation that becomes `ask`.
111
+ const abandon = () => {
112
+ stopping = true;
113
+ subscriptions.abort();
114
+ for (const controllers of running.values()) {
115
+ for (const controller of controllers) controller.abort();
116
+ }
117
+ running.clear();
118
+ };
119
+
120
+ // A cleanup can throw synchronously instead of rejecting. Turn that into a
121
+ // rejection so it cannot skip the cleanups started beside it, escape before
122
+ // the caller awaits them, or replace the original registration error.
123
+ const start = (cleanup: () => unknown): Promise<unknown> => {
124
+ try { return Promise.resolve(cleanup()); } catch (error) { return Promise.reject(error); }
125
+ };
126
+
127
+ // Abandon work and start every cleanup before awaiting any of them: a
128
+ // stalled status RPC or hook disposal must not keep the others alive. Their
129
+ // failures settle together and never mask the caller's original error.
130
+ const abandonSetup = async (...cleanups: (() => unknown)[]) => {
131
+ abandon();
132
+ await Promise.allSettled([start(() => rpc?.dispose()), ...cleanups.map((cleanup) => start(cleanup))]);
133
+ };
134
+
135
+ const permissionRegistration = await registerHook(
136
+ () => ctx.permission.hook("evaluate", async (event) => {
137
+ if (event.effect !== "ask") return;
138
+ // The host can still dispatch an evaluation it decided to ask about
139
+ // after abandonment started, while a stalled cleanup keeps this hook
140
+ // registered. The interruption subscription is already gone, so never
141
+ // start review work here: leave the ask to the user.
142
+ if (stopping) {
143
+ event.message = INTERRUPTED_REASON;
144
+ return;
145
+ }
146
+ if (!options) {
147
+ event.effect = "ask";
148
+ event.message = "Invalid auto-mode configuration; please decide yourself";
149
+ return;
150
+ }
151
+ const reviewID = `review-${process.pid}-${Date.now()}-${reviewCounter++}`;
152
+ recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: "reviewing", action: event.action.slice(0, 128), reason: "", at: Date.now() });
153
+ status.start(event.sessionID, reviewID, Date.now());
154
+ const startedAt = Date.now();
155
+ const trace = traces?.begin(startedAt);
156
+ const hostEffect = event.effect;
157
+ const controller = new AbortController();
158
+ const active = running.get(event.sessionID);
159
+ if (active === undefined) running.set(event.sessionID, new Set([controller]));
160
+ else active.add(controller);
161
+ let failure: string | undefined;
162
+ let errorKind: ReviewErrorKind | undefined;
163
+ let expired = false;
164
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
165
+ let pendingAttempt: ClassifierAttempt | undefined;
166
+
167
+ const review = (async (): Promise<ReviewResult> => {
73
168
  try {
74
- outcome = stage.type === "llm" ? await llmDecision(ctx, request, policy, stage.model, info?.model) :
75
- await decisionStage(stage, request, policy);
76
- } catch {
77
- outcome = { decision: "abstain", reason: `${stage.type} reviewer unavailable` };
169
+ const signal = controller.signal;
170
+ // A cancelled review must not start host or provider work at all.
171
+ throwIfAborted(signal);
172
+ let info: Awaited<ReturnType<typeof ctx.session.get>> | undefined;
173
+ let lookupError: ReviewErrorKind | undefined;
174
+ try {
175
+ info = await withTimeout(ctx.session.get({ sessionID: event.sessionID }, { signal }), 3_000, signal);
176
+ } catch (error) {
177
+ // The lookup only adds the session model and project directory, so
178
+ // a failure must not fail the review. Keep its category: when the
179
+ // missing session model is what prevents model review, the audit
180
+ // record and spans must still explain that the lookup blocked it.
181
+ lookupError = errorKindOf(error);
182
+ }
183
+ // The session lookup swallow keeps a deadline or interruption from
184
+ // failing the review, but an interrupted lookup must stop here.
185
+ throwIfAborted(signal);
186
+ const request: PermissionRequest = {
187
+ action: event.action, resources: [...event.resources], agent: event.agent,
188
+ projectDir: info?.location?.directory, metadata: event.metadata, source: event.source,
189
+ isDelegation: event.action === "task" || event.action === "subagent",
190
+ };
191
+ let contextLoaded = false;
192
+ let contextComplete = false;
193
+ let contextReason = "User authorization context is unavailable";
194
+ return await compositeDecision(options.classifiers, request, async (stage) => {
195
+ // Fallback stages can be entered after an earlier provider failed
196
+ // and the review was interrupted; never invoke the provider then.
197
+ throwIfAborted(signal);
198
+ if (!contextLoaded) {
199
+ const messages = await withTimeout(ctx.session.context({ sessionID: event.sessionID }, { signal }), 3_000, signal);
200
+ throwIfAborted(signal);
201
+ const context = extractReviewContext(messages);
202
+ request.transcript = context.transcript;
203
+ contextComplete = context.complete;
204
+ contextReason = context.reason ?? contextReason;
205
+ contextLoaded = true;
206
+ }
207
+ if (!contextComplete) {
208
+ errorKind ??= "context";
209
+ return { decision: "ask", reason: contextReason, errorKind: "context" };
210
+ }
211
+ throwIfAborted(signal);
212
+ const policy = defaultPolicy(stage.policy);
213
+ const attemptStartedAt = Date.now();
214
+ let attempt: ClassifierAttempt | undefined;
215
+ if (trace !== undefined) {
216
+ // Register the child span before awaiting the provider so a
217
+ // deadline can export it even when the provider never settles.
218
+ attempt = {
219
+ index: options.classifiers.indexOf(stage), type: stage.type,
220
+ model: stage.model ?? (info?.model ? `${info.model.providerID}/${info.model.id}` : undefined),
221
+ startedAt: attemptStartedAt, durationMs: 0,
222
+ result: { decision: "abstain", reason: "Review attempt pending", errorKind: "cancelled" },
223
+ };
224
+ trace.attempts.push(attempt);
225
+ pendingAttempt = attempt;
226
+ }
227
+ let outcome: StageResult;
228
+ try {
229
+ outcome = stage.type === "llm" ? await llmDecision(ctx, request, policy, signal, stage.model, info?.model, lookupError) :
230
+ // A typed provider may ignore its abort signal. Bound the stage
231
+ // here so an interruption or unload settles as cancelled right
232
+ // away instead of waiting for the overall review deadline.
233
+ await withTimeout(decisionStage(stage, request, policy, undefined, signal),
234
+ stage.timeoutMs ?? DEFAULT_DECISION_TIMEOUT_MS, signal);
235
+ } catch (error) {
236
+ const kind = expired ? "deadline" : errorKindOf(error);
237
+ outcome = { decision: "abstain", reason: `${stage.type} reviewer unavailable`, errorKind: kind };
238
+ }
239
+ if (outcome.errorKind !== undefined) errorKind ??= outcome.errorKind;
240
+ // A deadline or cancellation owns the final cause. Leave the span
241
+ // pending for the finalizer instead of letting the aborted
242
+ // provider overwrite an outcome that was already exported.
243
+ if (attempt !== undefined && pendingAttempt === attempt && !expired && !signal.aborted) {
244
+ pendingAttempt = undefined;
245
+ attempt.durationMs = Date.now() - attemptStartedAt;
246
+ attempt.result = outcome;
247
+ }
248
+ return outcome;
249
+ });
250
+ } catch (error) {
251
+ failure = error instanceof Error ? error.message : String(error);
252
+ errorKind ??= errorKindOf(error);
253
+ return { decision: "ask", reason: "Auto-mode review failed; please decide yourself", stage: "error" };
78
254
  }
79
- trace?.attempts.push({
80
- index: options.classifiers.indexOf(stage), type: stage.type,
81
- model: stage.model ?? (info?.model ? `${info.model.providerID}/${info.model.id}` : undefined),
82
- startedAt: attemptStartedAt, durationMs: Date.now() - attemptStartedAt, result: outcome,
83
- });
84
- return outcome;
255
+ })();
256
+
257
+ const deadline = new Promise<ReviewResult>((resolve) => {
258
+ deadlineTimer = setTimeout(() => {
259
+ expired = true;
260
+ controller.abort();
261
+ resolve({ decision: "ask", reason: "Auto-mode review deadline exceeded; please decide yourself", stage: "deadline" });
262
+ }, reviewTimeoutMs);
263
+ deadlineTimer.unref?.();
85
264
  });
86
- } catch (error) {
87
- failure = error instanceof Error ? error.message : String(error);
88
- result = { decision: "ask", reason: "Auto-mode review failed; please decide yourself", stage: "error" };
89
- }
90
- event.effect = result.decision;
91
- event.message = result.decision === "deny" ? withDenySuffix(result.reason) : result.reason;
92
- if (result.decision === "deny") {
93
- const count = (counts.get(event.sessionID) ?? 0) + 1;
94
- if (counts.size >= 256 && !counts.has(event.sessionID)) counts.delete(counts.keys().next().value!);
95
- counts.set(event.sessionID, count);
96
- log.log(`Action denied (${count} in session)`);
97
- }
98
- const auditRecord = {
99
- ts: new Date().toISOString(), reviewID, sessionID: event.sessionID, agent: event.agent,
100
- action: event.action, resources: [...event.resources], hostEffect,
101
- decision: result.decision, stage: result.stage ?? "unknown", reason: result.reason,
102
- durationMs: Date.now() - startedAt, ...(failure !== undefined ? { error: true, errorDetail: failure } : {}),
103
- ...(trace ? { traceID: trace.traceID, spanID: trace.spanID } : {}),
104
- };
105
- trace?.finish(auditRecord);
106
- for (const sink of sinks) sink.write(auditRecord);
107
- recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: event.effect, reason: "", at: Date.now() });
108
- });
109
- const toolRegistration = await ctx.tool.hook("execute.after", (event) => {
110
- try {
111
- if (event.status !== "completed") return;
112
- const result = event.result as { content?: unknown };
113
- if (result?.content !== undefined && scanResultContent(result.content))
114
- result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
115
- } catch { log.log("Output screening failed"); }
116
- }).catch(async (error) => {
117
- await permissionRegistration.dispose();
118
- throw error;
119
- });
265
+
266
+ let result: ReviewResult;
267
+ try {
268
+ result = await Promise.race([review, deadline]);
269
+ } finally {
270
+ if (deadlineTimer !== undefined) clearTimeout(deadlineTimer);
271
+ const controllers = running.get(event.sessionID);
272
+ if (controllers !== undefined) {
273
+ controllers.delete(controller);
274
+ if (controllers.size === 0) running.delete(event.sessionID);
275
+ }
276
+ }
277
+
278
+ // The deadline terminated the review even if a provider ignored the abort
279
+ // and recorded a cancellation or an earlier stage failed first.
280
+ if (expired) errorKind = "deadline";
281
+ let decision = result.decision;
282
+ let reason = result.reason;
283
+ // A cancelled review may resolve from a provider that ignored the abort,
284
+ // but it must never approve or deny: only the user can decide now. A
285
+ // cancellation is the termination cause even if a provider failed first.
286
+ if (controller.signal.aborted && !expired) {
287
+ decision = "ask";
288
+ reason = INTERRUPTED_REASON;
289
+ errorKind = "cancelled";
290
+ }
291
+ // Finalize a child span whose provider is still running so the exported
292
+ // trace reports the termination cause without waiting for it.
293
+ if (pendingAttempt !== undefined) {
294
+ const attempt = pendingAttempt;
295
+ pendingAttempt = undefined;
296
+ attempt.durationMs = Date.now() - attempt.startedAt;
297
+ attempt.result = {
298
+ decision: "abstain",
299
+ reason: expired ? "Review deadline exceeded" : "Review cancelled",
300
+ errorKind: expired ? "deadline" : "cancelled",
301
+ };
302
+ }
303
+ // Release any provider request still running after a local timeout.
304
+ // This runs after cancellation normalization so a completed allow/deny
305
+ // is delivered unchanged.
306
+ controller.abort();
307
+ event.effect = decision;
308
+ event.message = decision === "deny" ? withDenySuffix(reason) : reason;
309
+ if (decision === "deny") {
310
+ const count = (counts.get(event.sessionID) ?? 0) + 1;
311
+ if (counts.size >= 256 && !counts.has(event.sessionID)) counts.delete(counts.keys().next().value!);
312
+ counts.set(event.sessionID, count);
313
+ log.log(`Action denied (${count} in session)`);
314
+ }
315
+ const auditRecord = {
316
+ ts: new Date().toISOString(), reviewID, sessionID: event.sessionID, agent: event.agent,
317
+ action: event.action, resources: [...event.resources], hostEffect,
318
+ decision, stage: result.stage ?? "unknown", reason,
319
+ durationMs: Date.now() - startedAt,
320
+ ...(errorKind === undefined ? {} : { error: true, errorKind }),
321
+ ...(failure !== undefined ? { errorDetail: failure } : {}),
322
+ ...(trace ? { traceID: trace.traceID, spanID: trace.spanID } : {}),
323
+ };
324
+ trace?.finish(auditRecord);
325
+ for (const sink of sinks) sink.write(auditRecord);
326
+ recordDecision({ requestID: reviewID, sessionID: event.sessionID, decision: event.effect, reason: "", at: Date.now() });
327
+ const tracked: AutoModeDecision = status.finish({
328
+ reviewID, sessionID: event.sessionID, action: auditRecord.action, decision,
329
+ stage: auditRecord.stage, reason, durationMs: auditRecord.durationMs, at: Date.now(),
330
+ ...(errorKind === undefined ? {} : { errorKind }),
331
+ });
332
+ // Clients also poll `status`; a missed event only costs freshness.
333
+ const payload: Record<string, unknown> = {
334
+ reviewID: tracked.reviewID, sessionID: tracked.sessionID, action: tracked.action,
335
+ decision: tracked.decision, stage: tracked.stage, reason: tracked.reason,
336
+ durationMs: tracked.durationMs, at: tracked.at,
337
+ };
338
+ if (tracked.errorKind !== undefined) payload.errorKind = tracked.errorKind;
339
+ publish(payload);
340
+ }),
341
+ abandonSetup,
342
+ );
343
+ const toolRegistration = await registerHook(
344
+ () => ctx.tool.hook("execute.after", (event) => {
345
+ try {
346
+ if (event.status !== "completed") return;
347
+ const result = event.result as { content?: unknown };
348
+ if (result?.content !== undefined && scanResultContent(result.content))
349
+ result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
350
+ } catch { log.log("Output screening failed"); }
351
+ }),
352
+ async () => {
353
+ // Begin abandoning before awaiting either cleanup: a stalled permission
354
+ // disposal or status RPC must not leave the event subscription and
355
+ // active reviews running, where an in-flight review could deliver an
356
+ // allow instead of the cancellation that normalizes to `ask`. Both
357
+ // cleanups start here and settle together; their failures must not
358
+ // replace the original registration error.
359
+ await abandonSetup(() => permissionRegistration.dispose());
360
+ },
361
+ );
120
362
  log.log("Auto-mode loaded");
121
363
  return async () => {
122
- try { await permissionRegistration.dispose(); }
123
- finally { await toolRegistration.dispose(); counts.clear(); log.log("Auto-mode unloaded"); }
364
+ // Start abandonment and both hook disposals together rather than gating
365
+ // them on the optional status service: a stalled RPC disposal must never
366
+ // leave a hook registered after the interruption subscription was
367
+ // aborted, where the host could still start reviews.
368
+ await abandonSetup(
369
+ () => permissionRegistration.dispose(),
370
+ () => toolRegistration.dispose(),
371
+ );
372
+ counts.clear();
373
+ log.log("Auto-mode unloaded");
124
374
  };
125
375
  },
126
376
  });
127
377
 
378
+ /** Registers a hook, cleaning up partial setup when the host throws or rejects. */
379
+ async function registerHook<T>(register: () => Promise<T>, undo: () => Promise<void>): Promise<T> {
380
+ try {
381
+ return await register();
382
+ } catch (error) {
383
+ // A cleanup failure or rejection must not mask the registration error the
384
+ // caller has to see, and must not be reported as an unhandled rejection.
385
+ await Promise.allSettled([undo()]);
386
+ throw error;
387
+ }
388
+ }
389
+
128
390
  interface ReviewerContext {
129
- generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }): Promise<{ text: string }> };
391
+ generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }, request?: { signal?: AbortSignal }): Promise<{ text: string }> };
130
392
  }
131
393
 
132
- async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, model?: string, sessionModel?: ReviewerModelRef): Promise<StageResult> {
394
+ function throwIfAborted(signal: AbortSignal): void {
395
+ if (signal.aborted) throw new ReviewFailure("cancelled", "Review cancelled");
396
+ }
397
+
398
+ async function llmDecision(ctx: ReviewerContext, request: PermissionRequest, policy: ReturnType<typeof defaultPolicy>, signal: AbortSignal, model?: string, sessionModel?: ReviewerModelRef, sessionLookupError?: ReviewErrorKind): Promise<StageResult> {
133
399
  const ref = model ? parseReviewerModel(model) : sessionModel;
134
- if (!ref) return { decision: "abstain", reason: "Session model is unavailable; configure a reviewer model" };
400
+ if (!ref) return {
401
+ decision: "abstain",
402
+ reason: "Session model is unavailable; configure a reviewer model",
403
+ // Only a failed lookup explains the missing model; an explicit reviewer or
404
+ // a static stage is unaffected and must not inherit the lookup's category.
405
+ ...(sessionLookupError === undefined ? {} : { errorKind: sessionLookupError }),
406
+ };
135
407
  const prompt = buildStage1Prompt(request, policy);
136
408
  if (Buffer.byteLength(prompt) > 65_536) return { decision: "ask", reason: "Request too large for automatic review" };
137
- const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }), 30_000);
409
+ throwIfAborted(signal);
410
+ const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }, { signal }), 30_000, signal);
138
411
  if (parseStage1(triage.text) === "allow") return { decision: "allow", reason: "LLM triage approved" };
139
- const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: ref }), 30_000);
412
+ // The triage call may settle after an interruption; do not start stage two.
413
+ throwIfAborted(signal);
414
+ const response = await withTimeout(ctx.generate.text({ prompt: buildStage2Prompt(request, policy), model: ref }, { signal }), 30_000, signal);
415
+ // parseDecision reports malformed output structurally, so a valid verdict
416
+ // is never reclassified by comparing its reason text.
140
417
  return parseDecision(response.text);
141
418
  }
142
-
143
- function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
144
- return new Promise<T>((resolve, reject) => {
145
- const timer = setTimeout(() => reject(new Error("Review timed out")), ms);
146
- promise.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
147
- });
148
- }
package/src/otlp.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AuditLog, AuditRecord } from "./audit.js";
2
+ import { sanitizeRecord } from "./audit.js";
2
3
 
3
4
  export interface OtlpOptions { endpoint?: string; headers?: Record<string, string>; timeoutMs?: number }
4
5
 
@@ -26,25 +27,27 @@ const attr = (key: string, value: string | number | boolean) => ({
26
27
  });
27
28
 
28
29
  export function toLogRecord(record: AuditRecord) {
29
- const severity = record.decision === "deny" ? { severityNumber: 13, severityText: "WARN" } : { severityNumber: 9, severityText: "INFO" };
30
+ const safe = sanitizeRecord(record);
31
+ const severity = safe.decision === "deny" ? { severityNumber: 13, severityText: "WARN" } : { severityNumber: 9, severityText: "INFO" };
30
32
  return {
31
- timeUnixNano: String(BigInt(Date.parse(record.ts)) * 1_000_000n),
32
- ...(record.traceID === undefined ? {} : { traceId: record.traceID, spanId: record.spanID }),
33
+ timeUnixNano: String(BigInt(Date.parse(safe.ts)) * 1_000_000n),
34
+ ...(safe.traceID === undefined ? {} : { traceId: safe.traceID, spanId: safe.spanID }),
33
35
  ...severity,
34
- body: { stringValue: record.reason },
36
+ body: { stringValue: safe.reason },
35
37
  attributes: [
36
38
  attr("event.name", "auto_mode.decision"),
37
- attr("session.id", record.sessionID),
38
- attr("review.id", record.reviewID),
39
- attr("agent", record.agent ?? ""),
40
- attr("action", record.action),
41
- attr("resources", record.resources.join(" ; ")),
42
- attr("host_effect", record.hostEffect),
43
- attr("decision", record.decision),
44
- attr("stage", record.stage),
45
- attr("duration_ms", record.durationMs),
46
- attr("error", record.error === true),
47
- ...(record.errorDetail === undefined ? [] : [attr("error.detail", record.errorDetail)]),
39
+ attr("session.id", safe.sessionID),
40
+ attr("review.id", safe.reviewID),
41
+ attr("agent", safe.agent ?? ""),
42
+ attr("action", safe.action),
43
+ attr("resources", safe.resources.join(" ; ")),
44
+ attr("host_effect", safe.hostEffect),
45
+ attr("decision", safe.decision),
46
+ attr("stage", safe.stage),
47
+ attr("duration_ms", safe.durationMs),
48
+ attr("error", safe.error === true),
49
+ ...(safe.errorKind === undefined ? [] : [attr("error.kind", safe.errorKind)]),
50
+ ...(safe.errorDetail === undefined ? [] : [attr("error.detail", safe.errorDetail)]),
48
51
  ],
49
52
  };
50
53
  }
package/src/reviewer.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReviewPolicy } from "./policy.js";
2
+ import type { ReviewErrorKind } from "./errors.js";
2
3
 
3
4
  interface PermissionRequest {
4
5
  action: string;
@@ -18,12 +19,14 @@ export interface StageResult {
18
19
  decision: StageDecision;
19
20
  reason: string;
20
21
  stage?: string;
22
+ errorKind?: ReviewErrorKind;
21
23
  }
22
24
 
23
25
  export interface ReviewResult {
24
26
  decision: Decision;
25
27
  reason: string;
26
28
  stage?: string;
29
+ errorKind?: ReviewErrorKind;
27
30
  }
28
31
 
29
32
  export const DENY_SUFFIX = "Find a safer path and do not try to route around this block.";
@@ -98,6 +101,8 @@ export function parseStage1(text: string): "block" | "allow" {
98
101
  return /^ALLOW$/i.test(text.trim()) ? "allow" : "block";
99
102
  }
100
103
 
104
+ export const UNCLEAR_RESPONSE = "Reviewer response unclear";
105
+
101
106
  export function parseDecision(text: string): ReviewResult {
102
107
  const trimmed = text.trim();
103
108
  const match = trimmed.match(/^(ALLOW|DENY|ASK):[ \t]*([^\r\n]+)$/i);
@@ -105,9 +110,14 @@ export function parseDecision(text: string): ReviewResult {
105
110
  return { decision: match[1].toLowerCase() as Decision, reason: match[2].trim() };
106
111
  }
107
112
 
113
+ // Only a response that breaks the verdict contract is malformed. A valid
114
+ // verdict keeps the reason the reviewer chose, even when it reads like this
115
+ // fallback text, so the category travels with the parse result instead of
116
+ // being guessed from the reason later.
108
117
  return {
109
118
  decision: "ask",
110
- reason: "Reviewer response unclear",
119
+ reason: UNCLEAR_RESPONSE,
120
+ errorKind: "invalid-output",
111
121
  };
112
122
  }
113
123