@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/README.md +140 -10
- package/dist/gui-experimental.cjs +1 -0
- package/package.json +23 -13
- package/src/audit.ts +24 -7
- package/src/classifier.ts +13 -2
- package/src/config.ts +10 -1
- package/src/decisions.ts +38 -15
- package/src/errors.ts +39 -0
- package/src/gui-state.ts +160 -0
- package/src/gui.tsx +129 -0
- package/src/index.ts +367 -97
- package/src/otlp.ts +18 -15
- package/src/reviewer.ts +11 -1
- package/src/rpc.ts +117 -0
- package/src/shell-policy.ts +1337 -0
- package/src/shell.ts +272 -209
- package/src/status.ts +72 -0
- package/src/tiers.ts +2 -22
- package/src/timeout.ts +30 -0
- package/src/traces.ts +13 -7
- package/src/transcript.ts +17 -21
- package/src/tui-state.ts +7 -1
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
32
|
-
...(
|
|
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:
|
|
36
|
+
body: { stringValue: safe.reason },
|
|
35
37
|
attributes: [
|
|
36
38
|
attr("event.name", "auto_mode.decision"),
|
|
37
|
-
attr("session.id",
|
|
38
|
-
attr("review.id",
|
|
39
|
-
attr("agent",
|
|
40
|
-
attr("action",
|
|
41
|
-
attr("resources",
|
|
42
|
-
attr("host_effect",
|
|
43
|
-
attr("decision",
|
|
44
|
-
attr("stage",
|
|
45
|
-
attr("duration_ms",
|
|
46
|
-
attr("error",
|
|
47
|
-
...(
|
|
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:
|
|
119
|
+
reason: UNCLEAR_RESPONSE,
|
|
120
|
+
errorKind: "invalid-output",
|
|
111
121
|
};
|
|
112
122
|
}
|
|
113
123
|
|