@mortz.prk/opencode-auto-mode 1.0.2 → 1.1.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/CONTRIBUTING.md +234 -0
- package/README.llm.md +320 -0
- package/README.md +180 -295
- package/dist/gui-experimental.cjs +1 -1
- package/package.json +9 -8
- package/src/audit.ts +130 -49
- package/src/classifier.ts +44 -47
- package/src/code-mode.ts +55 -0
- package/src/config.ts +102 -105
- package/src/debug-events.ts +223 -0
- package/src/decision.ts +72 -0
- package/src/gates.ts +16 -0
- package/src/global-options.ts +85 -0
- package/src/gui-state.ts +230 -121
- package/src/gui.tsx +56 -107
- package/src/index.ts +482 -373
- package/src/mode.ts +237 -0
- package/src/reviewer.ts +70 -94
- package/src/rpc.ts +48 -38
- package/src/score/files.ts +51 -0
- package/src/score/history.ts +141 -0
- package/src/score/index.ts +38 -0
- package/src/score/limits.ts +24 -0
- package/src/score/shell-argv.ts +74 -0
- package/src/score/shell-baseline.ts +113 -0
- package/src/score/shell-safety.ts +57 -0
- package/src/score/shell.ts +32 -0
- package/src/score/types.ts +14 -0
- package/src/score.ts +9 -0
- package/src/shell-policy.ts +672 -30
- package/src/shell.ts +276 -55
- package/src/status.ts +49 -36
- package/src/tiers.ts +292 -19
- package/src/tui.tsx +44 -34
- package/src/user-files.ts +75 -0
- package/src/worktree.ts +30 -0
- package/src/decisions.ts +0 -236
- package/src/logger.ts +0 -26
- package/src/otlp.ts +0 -77
- package/src/policy.ts +0 -36
- package/src/state.ts +0 -135
- package/src/storage.ts +0 -26
- package/src/traces.ts +0 -90
- package/src/transcript.ts +0 -204
- package/src/tui-state.ts +0 -13
package/src/index.ts
CHANGED
|
@@ -1,418 +1,527 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Plugin } from "@opencode/plugin";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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";
|
|
3
|
+
import { DEFAULT_AUDIT_MAX_BYTES, DEFAULT_REVIEW_TIMEOUT_MS, parseOptions } from "./config.js";
|
|
4
|
+
import { reviewRequest, type PermissionRequest, type ReviewResult } from "./reviewer.js";
|
|
5
|
+
import { decisionStage } from "./decision.js";
|
|
6
|
+
import { runClassifiers } from "./classifier.js";
|
|
7
|
+
import { scoreRequest } from "./score.js";
|
|
8
|
+
import { loadGlobalOptions } from "./global-options.js";
|
|
9
|
+
import { getWorktreeScope } from "./worktree.js";
|
|
10
|
+
import { createAuditLog, defaultAuditPath } from "./audit.js";
|
|
11
|
+
import { createEventLog, registerGlobalCapture } from "./debug-events.js";
|
|
12
|
+
import { createModeStore, type FinishDisposition, type FinishOutcome } from "./mode.js";
|
|
13
|
+
import { AutoMode, MAX_SESSION_ID_CHARS, type AutoModeDecision, type AutoModeErrorKind, type AutoModeMode } from "./rpc.js";
|
|
17
14
|
import { createStatusTracker } from "./status.js";
|
|
15
|
+
import { withTimeout } from "./timeout.js";
|
|
16
|
+
import { errorKindOf, ReviewFailure } from "./errors.js";
|
|
17
|
+
import { codeModePermission, codeModeReviewResources } from "./code-mode.js";
|
|
18
|
+
import { scanResultContent, withInjectionWarning } from "./probe.js";
|
|
18
19
|
|
|
19
|
-
export type { AutoModeOptions
|
|
20
|
-
|
|
21
|
-
/** Delivered whenever auto-mode abandons a review: only the user can decide now. */
|
|
20
|
+
export type { AutoModeOptions } from "./config.js";
|
|
22
21
|
const INTERRUPTED_REASON = "Auto-mode review was interrupted; please decide yourself";
|
|
22
|
+
const PAUSED_REASON = "Auto mode is paused; please decide yourself";
|
|
23
|
+
const INVALID_CONFIG_ACTION = "Auto-mode review is disabled because its configuration is invalid";
|
|
24
|
+
const NATIVE_DENY_REASON = "Native permission deny";
|
|
25
|
+
const REVIEW_ACTIONS = /^(?:read|edit|write|glob|grep|shell|execute|webfetch|websearch|question|todowrite|skill)$/;
|
|
26
|
+
const SAFER_ALTERNATIVE = "Continue with a safer alternative.";
|
|
27
|
+
function denialMessage(reason: string): string {
|
|
28
|
+
const trimmed = reason.trim().replace(/[\s.]+$/, "");
|
|
29
|
+
if (trimmed.endsWith(SAFER_ALTERNATIVE.slice(0, -1))) return `${trimmed}.`;
|
|
30
|
+
return trimmed ? `${trimmed}. ${SAFER_ALTERNATIVE}` : SAFER_ALTERNATIVE;
|
|
31
|
+
}
|
|
23
32
|
|
|
24
33
|
export default Plugin.define({
|
|
25
34
|
id: "opencode-auto-mode",
|
|
26
35
|
setup: async (ctx) => {
|
|
27
|
-
const log = createLogger("events.log");
|
|
28
|
-
const directory = ctx.location?.directory;
|
|
29
|
-
let configError: string | undefined;
|
|
30
36
|
let options: ReturnType<typeof parseOptions> | undefined;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
36
|
-
configError = detail;
|
|
37
|
-
log.log(`Invalid configuration; automatic approval disabled: ${detail}`);
|
|
38
|
-
if (directory !== undefined) recordConfigError(directory, detail);
|
|
39
|
-
}
|
|
37
|
+
let configError: string | undefined;
|
|
38
|
+
try { options = parseOptions(loadGlobalOptions(ctx.location.directory)); }
|
|
39
|
+
catch (error) { configError = error instanceof Error ? error.message : "Invalid auto-mode configuration"; }
|
|
40
|
+
const valid = options !== undefined;
|
|
40
41
|
const reviewTimeoutMs = options?.reviewTimeoutMs ?? DEFAULT_REVIEW_TIMEOUT_MS;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Audit and raw debug capture are one opt-in: fixed state path, both sinks,
|
|
43
|
+
// and no work of any kind when disabled.
|
|
44
|
+
const auditConfig = options?.audit;
|
|
45
|
+
const auditSetting = auditConfig?.enabled === true
|
|
46
|
+
? { path: defaultAuditPath(), maxBytes: auditConfig.maxBytes ?? DEFAULT_AUDIT_MAX_BYTES }
|
|
47
|
+
: undefined;
|
|
48
|
+
const audit = createAuditLog(auditSetting);
|
|
49
|
+
const events = createEventLog(auditSetting);
|
|
50
|
+
if (events) console.warn("Auto-mode debug logging is unsafe: full prompts, outputs and events may contain secrets. Local audit logging is enabled without redaction.");
|
|
51
|
+
const modes = createModeStore(ctx.storage, async (sessionID) => {
|
|
52
|
+
const info = await observe("session.get", { sessionID }, () => withTimeout(ctx.session.get({ sessionID }), reviewTimeoutMs), { sessionID });
|
|
53
|
+
return info.parentID;
|
|
54
|
+
});
|
|
55
|
+
type Correlation = { reviewID?: string; sessionID?: string; callID?: string; stageIndex?: number };
|
|
56
|
+
const instanceID = randomUUID();
|
|
57
|
+
const sourceLocation = structuredClone(ctx.location);
|
|
58
|
+
let callCounter = 0;
|
|
59
|
+
const debug = (type: string, data: unknown, correlation: Correlation = {}) => {
|
|
60
|
+
// Local-only, deliberately unredacted. Never forward this data through RPC.
|
|
61
|
+
if (!events) return;
|
|
62
|
+
try { events.write({ ts: new Date().toISOString(), type, instanceID, sourceLocation, ...correlation, payload: data }); } catch { /* best effort */ }
|
|
63
|
+
};
|
|
64
|
+
const observe = async <T>(type: string, input: unknown, work: () => Promise<T>, correlation: Correlation = {}): Promise<T> => {
|
|
65
|
+
const envelope = { ...correlation, callID: `call-${instanceID}-${callCounter++}` };
|
|
66
|
+
debug(`${type}.before`, input, envelope);
|
|
67
|
+
try { const result = await work(); debug(`${type}.after`, result, envelope); return result; }
|
|
68
|
+
catch (error) { debug(`${type}.error`, error, envelope); throw error; }
|
|
69
|
+
};
|
|
70
|
+
/** Bounded, observed SDK call: every host/provider lookup shares one deadline shape. */
|
|
71
|
+
const sdk = <T>(type: string, input: unknown, work: () => Promise<T>, signal?: AbortSignal, correlation: Correlation = {}) =>
|
|
72
|
+
observe(type, input, () => withTimeout(work(), reviewTimeoutMs, signal), correlation);
|
|
73
|
+
if (events) debug("configuration", { options, valid, configError, reviewTimeoutMs, captureScope: "SDK-exposed events and observed plugin calls only; invisible host activity is not captured" });
|
|
74
|
+
const status = createStatusTracker();
|
|
46
75
|
const running = new Map<string, Set<AbortController>>();
|
|
76
|
+
// A failed off write must not restore automation in this plugin instance.
|
|
77
|
+
const suspended = new Set<string>();
|
|
47
78
|
const subscriptions = new AbortController();
|
|
48
|
-
|
|
79
|
+
let stopping = false;
|
|
49
80
|
let reviewCounter = 0;
|
|
50
81
|
let rpc: Awaited<ReturnType<typeof ctx.rpc.register<typeof AutoMode>>> | undefined;
|
|
51
|
-
|
|
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.
|
|
82
|
+
const abortSession = (id: string) => { for (const controller of running.get(id) ?? []) controller.abort(); };
|
|
74
83
|
const publish = (payload: Record<string, unknown>) => {
|
|
84
|
+
try { void Promise.resolve(rpc?.events.emit("decision", payload)).catch(() => {}); } catch { /* best effort */ }
|
|
85
|
+
};
|
|
86
|
+
const decisionPayload = (entry: AutoModeDecision, tripped: boolean) => ({
|
|
87
|
+
reviewID: entry.reviewID, sessionID: entry.sessionID,
|
|
88
|
+
action: REVIEW_ACTIONS.test(entry.action) ? entry.action : "tool",
|
|
89
|
+
decision: entry.decision, stage: entry.stage,
|
|
90
|
+
// Reviewer reasons can quote sensitive evidence: keep those local.
|
|
91
|
+
reason: tripped ? "Auto mode paused (breaker)" : `Auto-mode decision: ${entry.decision}`,
|
|
92
|
+
durationMs: entry.durationMs, at: entry.at,
|
|
93
|
+
...(entry.errorKind === undefined ? {} : { errorKind: entry.errorKind }),
|
|
94
|
+
});
|
|
95
|
+
const snapshot = async (sessionID?: string) => {
|
|
96
|
+
let mode: AutoModeMode = valid ? "on" : "invalid";
|
|
97
|
+
let counts = { allow: 0, ask: 0, deny: 0 };
|
|
98
|
+
let breaker = false;
|
|
99
|
+
let error = configError;
|
|
100
|
+
let errorKind: AutoModeErrorKind | undefined = valid ? undefined : "config";
|
|
101
|
+
if (valid && error === undefined && sessionID) {
|
|
102
|
+
try {
|
|
103
|
+
const state = await withTimeout(modes.get(sessionID), reviewTimeoutMs);
|
|
104
|
+
mode = suspended.has(sessionID) ? "off" : state.mode;
|
|
105
|
+
counts = state.counts;
|
|
106
|
+
breaker = mode === "paused";
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
debug("status.error", cause, { sessionID });
|
|
109
|
+
mode = "invalid";
|
|
110
|
+
error = "Auto-mode session storage unavailable";
|
|
111
|
+
errorKind = "storage";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return status.snapshot({ enabled: valid && mode === "on", mode, counts, breaker, reviewTimeoutMs,
|
|
115
|
+
...(error === undefined ? {} : { configError: error }), ...(errorKind === undefined ? {} : { errorKind }),
|
|
116
|
+
...(sessionID === undefined ? {} : { sessionID }) });
|
|
117
|
+
};
|
|
118
|
+
const modeMutations = new Map<string, number>();
|
|
119
|
+
const setSessionMode = async (sessionID: string, mode: "on" | "off", commandSession = false) => {
|
|
120
|
+
if (stopping) throw new Error("Auto-mode plugin is stopping");
|
|
121
|
+
if (!sessionID || !["on", "off"].includes(mode)) throw new Error("Invalid auto-mode change");
|
|
122
|
+
// Invalid configuration can never enable review. An explicit off may
|
|
123
|
+
// still be persisted for when the configuration is repaired.
|
|
124
|
+
if (mode === "on" && !valid) throw new Error("Auto-mode configuration is invalid; review disabled");
|
|
125
|
+
const mutation = (modeMutations.get(sessionID) ?? 0) + 1;
|
|
126
|
+
modeMutations.set(sessionID, mutation);
|
|
127
|
+
// Off wins synchronously, before any await: a review already committing must
|
|
128
|
+
// see the latch and an in-flight review is aborted immediately.
|
|
129
|
+
const addedLatch = mode === "off" && !suspended.has(sessionID);
|
|
130
|
+
if (mode === "off") { suspended.add(sessionID); abortSession(sessionID); }
|
|
131
|
+
const revertOff = () => {
|
|
132
|
+
// A failed validation may undo only the latch this request created, and
|
|
133
|
+
// only when no newer request won. A second failed off must not release
|
|
134
|
+
// an earlier fail-safe latch whose write already failed.
|
|
135
|
+
if (addedLatch && modeMutations.get(sessionID) === mutation) suspended.delete(sessionID);
|
|
136
|
+
};
|
|
137
|
+
let info: Awaited<ReturnType<typeof ctx.session.get>>;
|
|
138
|
+
try {
|
|
139
|
+
info = await observe("session.get", { sessionID }, () => withTimeout(ctx.session.get({ sessionID }), reviewTimeoutMs), { sessionID });
|
|
140
|
+
} catch (error) { revertOff(); throw error; }
|
|
75
141
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
142
|
+
const requested = getWorktreeScope(info.location.directory).directory;
|
|
143
|
+
if (!commandSession && requested !== getWorktreeScope(ctx.location.directory).directory)
|
|
144
|
+
throw new Error("Session location does not match this auto-mode instance");
|
|
145
|
+
} catch (error) { revertOff(); throw error; }
|
|
146
|
+
// An older request whose validation finished late must not overwrite a newer mode.
|
|
147
|
+
if (modeMutations.get(sessionID) !== mutation) return snapshot(sessionID);
|
|
148
|
+
try {
|
|
149
|
+
await withTimeout(modes.set(sessionID, mode), reviewTimeoutMs);
|
|
150
|
+
if (mode === "on" && modeMutations.get(sessionID) === mutation) suspended.delete(sessionID);
|
|
151
|
+
return snapshot(sessionID);
|
|
152
|
+
} catch (error) { debug("mode.set.error", error, { sessionID }); throw error; }
|
|
78
153
|
};
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
154
|
+
const statusInput = (input: unknown): string | undefined => {
|
|
155
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => key !== "sessionID"))
|
|
156
|
+
throw new Error("Invalid auto-mode status request");
|
|
157
|
+
const { sessionID } = input as { sessionID?: unknown };
|
|
158
|
+
if (sessionID !== undefined && (typeof sessionID !== "string" || sessionID.length < 1 || sessionID.length > MAX_SESSION_ID_CHARS))
|
|
159
|
+
throw new Error("Invalid auto-mode status request");
|
|
160
|
+
return sessionID;
|
|
84
161
|
};
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
162
|
+
try { rpc = await ctx.rpc.register(AutoMode, {
|
|
163
|
+
status: async (input) => snapshot(statusInput(input)),
|
|
164
|
+
setMode: async (input) => {
|
|
165
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some((key) => !["sessionID", "mode"].includes(key)))
|
|
166
|
+
throw new Error("Invalid auto-mode change");
|
|
167
|
+
const { sessionID, mode } = input as { sessionID: string; mode: "on" | "off" | "resume" };
|
|
168
|
+
if (typeof sessionID !== "string" || !sessionID || sessionID.length > MAX_SESSION_ID_CHARS || !["on", "off", "resume"].includes(mode)) throw new Error("Invalid auto-mode change");
|
|
169
|
+
return setSessionMode(sessionID, mode === "resume" ? "on" : mode);
|
|
170
|
+
},
|
|
171
|
+
}); }
|
|
172
|
+
catch { /* RPC is an optional UX surface */ }
|
|
173
|
+
// Cancellation remains local to every loaded instance, independent of audit ownership.
|
|
174
|
+
void (async () => {
|
|
175
|
+
try {
|
|
176
|
+
for await (const event of ctx.event.subscribe({ types: ["session.execution.interrupted"], signal: subscriptions.signal })) {
|
|
177
|
+
abortSession(event.data.sessionID);
|
|
178
|
+
}
|
|
179
|
+
} catch (error) { if (!subscriptions.signal.aborted) debug("interrupt.subscription.error", error); }
|
|
180
|
+
})();
|
|
181
|
+
const releaseCapture = events ? registerGlobalCapture(events.sinkPath, (signal, isOwner, handoff, markFailure) => {
|
|
182
|
+
debug(handoff ? "capture.handoff" : "capture.started", {
|
|
183
|
+
scope: "process-wide server-global stream per canonical sink path",
|
|
184
|
+
...(handoff ? { reason: "Previous owner unloaded, stream failed, or legacy registry retired; potential SDK subscription setup/dispose gap; no replay guarantee" } : {}),
|
|
185
|
+
});
|
|
90
186
|
void (async () => {
|
|
91
187
|
try {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const sessionID = data?.sessionID;
|
|
96
|
-
if (sessionID !== undefined) abortSession(String(sessionID));
|
|
188
|
+
for await (const event of ctx.event.subscribe({ signal })) {
|
|
189
|
+
// A queued event from the retiring subscriber must not duplicate the new owner.
|
|
190
|
+
if (isOwner()) debug("event", event);
|
|
97
191
|
}
|
|
98
|
-
|
|
192
|
+
debug(signal.aborted ? "capture.stopped" : "capture.interrupted", { reason: signal.aborted ? "cleanup or ownership transfer" : "Event subscription ended unexpectedly; capture gap; trying eligible standby owners, or waiting for a new registration" });
|
|
193
|
+
} catch (error) {
|
|
194
|
+
if (!signal.aborted) debug("capture.error", error);
|
|
195
|
+
debug(signal.aborted ? "capture.stopped" : "capture.interrupted", { reason: signal.aborted ? "cleanup or ownership transfer" : "Event subscription failed; capture gap; trying eligible standby owners, or waiting for a new registration" });
|
|
196
|
+
} finally {
|
|
197
|
+
if (!signal.aborted) markFailure();
|
|
198
|
+
}
|
|
99
199
|
})();
|
|
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`.
|
|
200
|
+
}) : undefined;
|
|
111
201
|
const abandon = () => {
|
|
112
202
|
stopping = true;
|
|
113
203
|
subscriptions.abort();
|
|
114
|
-
|
|
115
|
-
|
|
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); }
|
|
204
|
+
releaseCapture?.();
|
|
205
|
+
for (const id of running.keys()) abortSession(id);
|
|
125
206
|
};
|
|
126
|
-
|
|
127
|
-
|
|
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)[]) => {
|
|
207
|
+
const registrations: { dispose(): unknown }[] = [];
|
|
208
|
+
const cleanup = async () => {
|
|
131
209
|
abandon();
|
|
132
|
-
await Promise.allSettled([
|
|
210
|
+
await Promise.allSettled([rpc, ...registrations].map(async (registration) => registration?.dispose()));
|
|
133
211
|
};
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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());
|
|
212
|
+
try {
|
|
213
|
+
registrations.push(await ctx.permission.hook("evaluate", async (event) => {
|
|
214
|
+
// Invalid configuration acts disabled: the native effect and message are
|
|
215
|
+
// byte-for-byte untouched, with no mode storage, context, model, audit or
|
|
216
|
+
// debug-capture access and no fail-closed ask.
|
|
217
|
+
const settings = options;
|
|
218
|
+
if (!valid || settings === undefined) return;
|
|
154
219
|
const startedAt = Date.now();
|
|
155
|
-
const
|
|
220
|
+
const reviewID = `review-${instanceID}-${startedAt}-${reviewCounter++}`;
|
|
221
|
+
const correlation = { reviewID, sessionID: event.sessionID };
|
|
222
|
+
debug("permission.evaluate.before", event, correlation);
|
|
156
223
|
const hostEffect = event.effect;
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
try {
|
|
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;
|
|
224
|
+
const originalMessage = event.message;
|
|
225
|
+
const restoreHostMessage = () => {
|
|
226
|
+
if (originalMessage === undefined) delete event.message;
|
|
227
|
+
else event.message = originalMessage;
|
|
228
|
+
};
|
|
229
|
+
try {
|
|
230
|
+
// A runtime value outside the permission union is not a native allow,
|
|
231
|
+
// ask or deny. Leave it completely untouched and never enter review.
|
|
232
|
+
if (hostEffect !== "allow" && hostEffect !== "ask" && hostEffect !== "deny") return;
|
|
233
|
+
// A native deny is final: passive local audit only; no state/context/model access.
|
|
234
|
+
if (hostEffect === "deny") {
|
|
174
235
|
try {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
236
|
+
audit?.write({ ts: new Date().toISOString(), reviewID, sessionID: event.sessionID,
|
|
237
|
+
...(event.agent === undefined ? {} : { agent: event.agent }),
|
|
238
|
+
action: event.action, resources: [...event.resources], hostEffect: "deny", decision: "deny",
|
|
239
|
+
stage: "native", reason: event.message ?? NATIVE_DENY_REASON, durationMs: 0 });
|
|
240
|
+
} catch { /* local auditing is best effort */ }
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const reviewsNativeAllow = hostEffect === "allow";
|
|
244
|
+
// Explicit off wins synchronously over every later unload/failure path.
|
|
245
|
+
if (suspended.has(event.sessionID)) return;
|
|
246
|
+
if (stopping) {
|
|
247
|
+
event.effect = "ask"; event.message = INTERRUPTED_REASON; return;
|
|
248
|
+
}
|
|
249
|
+
const controller = new AbortController();
|
|
250
|
+
const active = running.get(event.sessionID) ?? new Set<AbortController>();
|
|
251
|
+
active.add(controller);
|
|
252
|
+
running.set(event.sessionID, active);
|
|
253
|
+
const signal = controller.signal;
|
|
254
|
+
const release = () => {
|
|
255
|
+
controller.abort();
|
|
256
|
+
active.delete(controller);
|
|
257
|
+
if (active.size === 0) running.delete(event.sessionID);
|
|
258
|
+
};
|
|
259
|
+
debug("review.setup", { options: settings, startedAt, deadline: startedAt + reviewTimeoutMs, reviewTimeoutMs }, correlation);
|
|
260
|
+
let tracked = false;
|
|
261
|
+
let score: string | undefined;
|
|
262
|
+
let result: ReviewResult;
|
|
263
|
+
let tripped = false;
|
|
264
|
+
let finalDisposition: FinishDisposition = "cancelled";
|
|
265
|
+
let hostCancelled = false;
|
|
266
|
+
let abandoned = false;
|
|
267
|
+
// One predicate for host interruption: our own cleanup abort is not one.
|
|
268
|
+
const cancelledNow = () => hostCancelled || stopping || (!abandoned && signal.aborted);
|
|
269
|
+
try {
|
|
270
|
+
type WorkOutcome =
|
|
271
|
+
| { kind: "passthrough" }
|
|
272
|
+
| { kind: "cancelled" }
|
|
273
|
+
| { kind: "reviewed"; result: ReviewResult };
|
|
274
|
+
const work = async (): Promise<WorkOutcome> => {
|
|
275
|
+
const state = await observe("mode.get", { sessionID: event.sessionID }, () => modes.get(event.sessionID), correlation);
|
|
276
|
+
// Explicit off and persisted off return control to the native host.
|
|
277
|
+
if (suspended.has(event.sessionID) || state.mode === "off") return { kind: "passthrough" };
|
|
278
|
+
if (state.mode === "paused") {
|
|
279
|
+
// A paused breaker asks for host allows. An existing ask already
|
|
280
|
+
// has its native prompt and needs no synthetic counter.
|
|
281
|
+
if (!reviewsNativeAllow) return { kind: "passthrough" };
|
|
282
|
+
return { kind: "reviewed", result: { decision: "ask", reason: PAUSED_REASON, stage: "mode" } };
|
|
210
283
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
284
|
+
if (cancelledNow()) return { kind: "cancelled" };
|
|
285
|
+
status.start(event.sessionID, reviewID, startedAt);
|
|
286
|
+
tracked = true;
|
|
287
|
+
// Reject oversized host payloads without passing raw metadata/source to a model.
|
|
288
|
+
if (Buffer.byteLength(JSON.stringify({ action: event.action, resources: event.resources, metadata: event.metadata, source: event.source })) > 65_536)
|
|
289
|
+
return { kind: "reviewed", result: { decision: "ask", reason: "Request too large for automatic review", stage: "context" } };
|
|
290
|
+
const info = await sdk("session.get", { sessionID: event.sessionID }, () => ctx.session.get({ sessionID: event.sessionID }, { signal }), signal, correlation);
|
|
291
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
292
|
+
// Same-model reviewer identity is intentionally not restricted or warned:
|
|
293
|
+
// the user chooses the reviewer, and this plugin adds no independence gate.
|
|
294
|
+
const scope = await observe("worktree.scope", info.location, async () => getWorktreeScope(info.location.directory), correlation);
|
|
295
|
+
const location = { directory: scope.directory };
|
|
296
|
+
const vcs = await sdk("vcs.get", { location }, () => ctx.vcs.get({ location }, { signal }), signal, correlation);
|
|
297
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
298
|
+
const tree = await sdk("vcs.status", { location }, () => ctx.vcs.status({ location }, { signal }), signal, correlation);
|
|
299
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
300
|
+
let reviewResources: string[];
|
|
301
|
+
try { reviewResources = codeModeReviewResources(event) ?? [...event.resources]; }
|
|
302
|
+
catch (error) { throw new ReviewFailure("context", "Code Mode evidence does not match its permission identity", { cause: error }); }
|
|
303
|
+
const request: PermissionRequest = {
|
|
304
|
+
action: event.action, resources: reviewResources, agent: event.agent,
|
|
305
|
+
projectDir: scope.directory, worktreeRoot: scope.worktreeRoot,
|
|
306
|
+
gitState: { branch: vcs.data.branch.current, defaultBranch: vcs.data.branch.default, clean: tree.data.length === 0 },
|
|
307
|
+
};
|
|
308
|
+
if (signal.aborted) return { kind: "cancelled" };
|
|
309
|
+
const scored = await observe("score", request, async () => scoreRequest(request, settings), correlation);
|
|
310
|
+
score = scored.band;
|
|
311
|
+
if ("ask" in scored && scored.ask) return { kind: "reviewed", result: { decision: "ask", reason: scored.reason, stage: "score" } };
|
|
312
|
+
if (scored.deny) return { kind: "reviewed", result: { decision: "deny", reason: scored.reason, stage: "score" } };
|
|
313
|
+
let transcript: Promise<string> | undefined;
|
|
314
|
+
const authorize = async () => {
|
|
315
|
+
if (cancelledNow()) throw new Error(INTERRUPTED_REASON);
|
|
316
|
+
transcript ??= (async () => {
|
|
317
|
+
// Classifier context is user-only; local debug retains the full response.
|
|
318
|
+
const messages = await sdk("session.context", { sessionID: event.sessionID }, () => ctx.session.context({ sessionID: event.sessionID }, { signal }), signal, correlation);
|
|
319
|
+
const users = messages.filter((message) => message.type === "user").map((message) => message.text);
|
|
320
|
+
if (users.length === 0 || users.some((text) => typeof text !== "string") || Buffer.byteLength(users.join("\n")) > 12_000)
|
|
321
|
+
throw new ReviewFailure("context", "User authorization context is unavailable or oversized");
|
|
322
|
+
return users.join("\n");
|
|
323
|
+
})();
|
|
324
|
+
request.transcript = await transcript;
|
|
325
|
+
if (cancelledNow()) throw new Error(INTERRUPTED_REASON);
|
|
326
|
+
};
|
|
327
|
+
// Observe the native stateless generation boundary, including exact prompts,
|
|
328
|
+
// complete raw responses and errors. This wrapper never modifies model input.
|
|
329
|
+
const native = (stageIndex: number) => ({
|
|
330
|
+
experimental: { generate: {
|
|
331
|
+
text: (input: Parameters<typeof ctx.experimental.generate.text>[0], generation: { signal: AbortSignal }) =>
|
|
332
|
+
sdk("generate.text", input, () => ctx.experimental.generate.text(input, generation), generation.signal, { ...correlation, stageIndex }),
|
|
333
|
+
decisions: (input: Parameters<Parameters<typeof decisionStage>[0]["experimental"]["generate"]["decisions"]>[0], generation: { signal: AbortSignal }) =>
|
|
334
|
+
sdk("generate.decisions", input, () => ctx.experimental.generate.decisions(input as Parameters<typeof ctx.experimental.generate.decisions>[0], generation), generation.signal, { ...correlation, stageIndex }),
|
|
335
|
+
} },
|
|
336
|
+
});
|
|
337
|
+
const stageCall = async <T>(stage: typeof settings.classifiers[number], work: (stageIndex: number) => Promise<T>) => {
|
|
338
|
+
const stageIndex = settings.classifiers.indexOf(stage);
|
|
339
|
+
const envelope = { ...correlation, stageIndex };
|
|
340
|
+
debug("classifier.start", stage, envelope);
|
|
341
|
+
try { await authorize(); const outcome = await work(stageIndex); debug("classifier.end", outcome, envelope); return outcome; }
|
|
342
|
+
catch (error) { debug("classifier.error", error, envelope); debug("classifier.end", { error }, envelope); throw error; }
|
|
343
|
+
};
|
|
344
|
+
const verdict = await runClassifiers(settings.classifiers, request, scored, {
|
|
345
|
+
signal,
|
|
346
|
+
decision: (stage) => stageCall(stage, (index) => decisionStage(native(index), stage, request, signal, (error) => debug("classifier.validation.error", error, { ...correlation, stageIndex: index }))),
|
|
347
|
+
llm: (stage) => stageCall(stage, (index) => reviewRequest(native(index), request, stage.model, signal, stage.timeoutMs ?? reviewTimeoutMs, (error) => debug("classifier.validation.error", error, { ...correlation, stageIndex: index }))),
|
|
348
|
+
onResult: (stage, outcome, stageIndex) => {
|
|
349
|
+
if (stage.type === "static") { debug("classifier.start", stage, { ...correlation, stageIndex }); debug("classifier.end", outcome, { ...correlation, stageIndex }); }
|
|
350
|
+
debug("classifier", { stage, outcome }, { ...correlation, stageIndex });
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return { kind: "reviewed", result: verdict };
|
|
354
|
+
};
|
|
355
|
+
const proposed = await withTimeout(work(), reviewTimeoutMs, signal);
|
|
356
|
+
if (proposed.kind !== "reviewed") {
|
|
357
|
+
release();
|
|
358
|
+
// Explicit off restores the host effect. Other cancellation/unload
|
|
359
|
+
// paths fail closed to ask.
|
|
360
|
+
const off = suspended.has(event.sessionID);
|
|
361
|
+
finalDisposition = off ? "off" : "cancelled";
|
|
362
|
+
if (proposed.kind === "cancelled" && !off) {
|
|
363
|
+
event.effect = "ask";
|
|
364
|
+
event.message = INTERRUPTED_REASON;
|
|
226
365
|
}
|
|
227
|
-
|
|
366
|
+
if (tracked) status.finish({ reviewID, sessionID: event.sessionID, action: event.action,
|
|
367
|
+
decision: off ? hostEffect : "ask",
|
|
368
|
+
stage: off ? "off" : "cancelled",
|
|
369
|
+
reason: off ? "Auto mode is off; native permission effect preserved" : INTERRUPTED_REASON,
|
|
370
|
+
durationMs: Date.now() - startedAt, at: Date.now() });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
result = proposed.result;
|
|
374
|
+
} catch (error) {
|
|
375
|
+
debug("review.error", error, correlation);
|
|
376
|
+
const category = errorKindOf(error);
|
|
377
|
+
// Capture host cancellation before our own cleanup aborts the controller.
|
|
378
|
+
hostCancelled = signal.aborted || stopping;
|
|
379
|
+
result = { decision: "ask", reason: signal.aborted ? INTERRUPTED_REASON : "Auto-mode review failed or timed out; please decide yourself", stage: "error",
|
|
380
|
+
errorKind: signal.aborted ? "cancelled" : category === "timeout" ? "deadline" : category };
|
|
381
|
+
// The total deadline bounds host lookups too. Stop the abandoned work
|
|
382
|
+
// before waiting for the state commit; it must not start another stage.
|
|
383
|
+
abandoned = true;
|
|
384
|
+
controller.abort();
|
|
385
|
+
}
|
|
386
|
+
let finishPromise: Promise<FinishOutcome> | undefined;
|
|
387
|
+
try {
|
|
388
|
+
// Mode/breaker checks and counters commit atomically. Explicit off wins
|
|
389
|
+
// over every stale proposal; interruption and timeouts fail closed ask.
|
|
390
|
+
// The finish commit repairs a cancelled proposal under its own lock.
|
|
391
|
+
// A signal that aborts while storage is committing settles the host
|
|
392
|
+
// promptly as an ask; the durable repair still completes in order.
|
|
393
|
+
finishPromise = modes.finish(event.sessionID, result.decision, settings.breaker,
|
|
394
|
+
cancelledNow, () => suspended.has(event.sessionID));
|
|
395
|
+
const final = await withTimeout(finishPromise, reviewTimeoutMs, signal.aborted ? undefined : signal);
|
|
396
|
+
const cancelled = cancelledNow();
|
|
397
|
+
const explicitOff = final.disposition === "off" || suspended.has(event.sessionID);
|
|
398
|
+
// The durable commit may have won the race against a cancellation or
|
|
399
|
+
// off observed only after finish resolved. Repair that commit before
|
|
400
|
+
// exposing its counts; explicit off restores the exact pre-review state.
|
|
401
|
+
if (final.commit !== undefined && (explicitOff || (cancelled && final.disposition === "enforced"))) {
|
|
228
402
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
403
|
+
await withTimeout(modes.revertCancelled(event.sessionID, final, explicitOff ? "off" : "cancelled"), reviewTimeoutMs);
|
|
404
|
+
} catch (error) { debug("mode.revert.error", error, correlation); }
|
|
405
|
+
}
|
|
406
|
+
finalDisposition = explicitOff ? "off" : cancelled ? "cancelled" : final.disposition;
|
|
407
|
+
tripped = !cancelled && !explicitOff && final.tripped;
|
|
408
|
+
// Recheck after storage settles, immediately before touching the host.
|
|
409
|
+
const applyHost = (): { effect: "allow" | "ask" | "deny"; message: string | undefined } => {
|
|
410
|
+
if (explicitOff) return { effect: hostEffect, message: originalMessage };
|
|
411
|
+
if (cancelled || final.disposition === "cancelled") return { effect: "ask", message: INTERRUPTED_REASON };
|
|
412
|
+
if (final.decision === "deny") return { effect: final.decision, message: denialMessage(result.reason) };
|
|
413
|
+
if (final.disposition === "paused") return hostEffect === "allow"
|
|
414
|
+
? { effect: final.decision, message: PAUSED_REASON }
|
|
415
|
+
: { effect: final.decision, message: originalMessage };
|
|
416
|
+
if (final.decision === "allow" && hostEffect === "allow") return { effect: final.decision, message: originalMessage };
|
|
417
|
+
return { effect: final.decision, message: result.reason };
|
|
418
|
+
};
|
|
419
|
+
const applied = applyHost();
|
|
420
|
+
event.effect = applied.effect;
|
|
421
|
+
if (applied.message === undefined) delete event.message;
|
|
422
|
+
else event.message = applied.message;
|
|
423
|
+
if (tripped) abortSession(event.sessionID);
|
|
250
424
|
} catch (error) {
|
|
251
|
-
|
|
252
|
-
errorKind
|
|
253
|
-
|
|
425
|
+
debug("mode.finish.error", error, correlation);
|
|
426
|
+
result = { ...result, errorKind: errorKindOf(error) };
|
|
427
|
+
if (suspended.has(event.sessionID)) {
|
|
428
|
+
finalDisposition = "off";
|
|
429
|
+
event.effect = hostEffect;
|
|
430
|
+
restoreHostMessage();
|
|
431
|
+
} else {
|
|
432
|
+
finalDisposition = "cancelled";
|
|
433
|
+
event.effect = "ask";
|
|
434
|
+
event.message = cancelledNow()
|
|
435
|
+
? INTERRUPTED_REASON
|
|
436
|
+
: "Auto-mode session storage unavailable; please decide yourself";
|
|
437
|
+
}
|
|
438
|
+
// The host settles promptly on interruption, but a durable commit may
|
|
439
|
+
// still complete later. Repair it under the session queue once known.
|
|
440
|
+
void finishPromise?.then(async (outcome) => {
|
|
441
|
+
if (outcome.disposition === "off" || outcome.commit === undefined) return;
|
|
442
|
+
const off = suspended.has(event.sessionID);
|
|
443
|
+
// A paused/cancelled outcome already counted the ask this path applies.
|
|
444
|
+
if (!off && outcome.disposition !== "enforced") return;
|
|
445
|
+
try { await modes.revertCancelled(event.sessionID, outcome, off ? "off" : "cancelled"); }
|
|
446
|
+
catch (cause) { debug("mode.revert.error", cause, correlation); }
|
|
447
|
+
}).catch(() => {});
|
|
254
448
|
}
|
|
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?.();
|
|
264
|
-
});
|
|
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);
|
|
449
|
+
finally {
|
|
450
|
+
release();
|
|
275
451
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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",
|
|
452
|
+
const record = {
|
|
453
|
+
ts: new Date().toISOString(), reviewID, sessionID: event.sessionID,
|
|
454
|
+
...(event.agent === undefined ? {} : { agent: event.agent }),
|
|
455
|
+
action: event.action, resources: [...event.resources], hostEffect, decision: event.effect,
|
|
456
|
+
proposedDecision: result.decision, reviewedNativeAllow: reviewsNativeAllow, disposition: finalDisposition,
|
|
457
|
+
breaker: tripped,
|
|
458
|
+
stage: tripped ? "breaker" : result.stage ?? "unknown",
|
|
459
|
+
...(score === undefined ? {} : { score }),
|
|
460
|
+
reason: result.reason, durationMs: Date.now() - startedAt,
|
|
461
|
+
...(event.message === undefined ? {} : { appliedMessage: event.message }),
|
|
462
|
+
...(result.errorKind === undefined ? {} : { errorKind: result.errorKind }),
|
|
301
463
|
};
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
...(
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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) => {
|
|
464
|
+
try { audit?.write(record); } catch { /* local auditing is best effort */ }
|
|
465
|
+
if (tracked) {
|
|
466
|
+
const entry = status.finish({ ...record, at: Date.now() });
|
|
467
|
+
// Explicit off is a native passthrough, not a plugin decision event.
|
|
468
|
+
if (finalDisposition !== "off") publish(decisionPayload(entry, tripped));
|
|
469
|
+
}
|
|
470
|
+
} catch (error) { debug("permission.evaluate.error", error, correlation); throw error; }
|
|
471
|
+
finally { debug("permission.evaluate.after", event, correlation); }
|
|
472
|
+
}));
|
|
473
|
+
registrations.push(await ctx.tool.hook("execute.before", async (event) => {
|
|
474
|
+
debug("tool.execute.before", event);
|
|
475
|
+
try {
|
|
476
|
+
if (event.tool !== "execute") return;
|
|
477
|
+
// Invalid configuration leaves native execute behavior byte-for-byte untouched.
|
|
478
|
+
if (!valid) return;
|
|
479
|
+
if (stopping) throw new Error(INTERRUPTED_REASON);
|
|
480
|
+
// Hash identities prevent JavaScript '*'/'?' from widening new saved grants.
|
|
481
|
+
// Actual source remains bound to the identity in metadata for review/display.
|
|
482
|
+
// Existing saved rules are untouched; legacy source-specific grants may re-ask.
|
|
483
|
+
await ctx.permission.assert({ ...codeModePermission(event.input), sessionID: event.sessionID, agent: event.agent,
|
|
484
|
+
source: { type: "tool", messageID: event.messageID, id: event.id } });
|
|
485
|
+
if (stopping) throw new Error(INTERRUPTED_REASON);
|
|
486
|
+
} catch (error) { debug("tool.execute.before.error", { event, error }); throw error; }
|
|
487
|
+
}));
|
|
488
|
+
registrations.push(await ctx.tool.hook("execute.after", (event) => {
|
|
489
|
+
// Preserve the complete host output/error before adding any UX injection hint.
|
|
490
|
+
debug("tool.execute.after", event);
|
|
345
491
|
try {
|
|
346
492
|
if (event.status !== "completed") return;
|
|
347
493
|
const result = event.result as { content?: unknown };
|
|
348
494
|
if (result?.content !== undefined && scanResultContent(result.content))
|
|
349
495
|
result.content = withInjectionWarning(result.content as string | ReadonlyArray<{ type: string; text?: string }>);
|
|
350
|
-
} catch {
|
|
351
|
-
})
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
496
|
+
} catch { /* UX hint only */ }
|
|
497
|
+
}));
|
|
498
|
+
registrations.push(await ctx.command.transform((editor) => editor.add({
|
|
499
|
+
name: "auto-mode", description: "Toggle auto mode or show its status", execute: async ({ sessionID, prompt }) => {
|
|
500
|
+
const argument = prompt.text.trim().replace(/^\/?auto-mode\b\s*/, "").trim();
|
|
501
|
+
if (!["", "on", "off", "status"].includes(argument)) {
|
|
502
|
+
await ctx.session.synthetic({ sessionID, text: "Usage: /auto-mode [on|off|status]" });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
if (argument !== "status") {
|
|
507
|
+
const current = argument ? undefined : await snapshot(sessionID);
|
|
508
|
+
const target = argument || (current?.mode === "on" ? "off" : "on");
|
|
509
|
+
if (!valid && target === "on") {
|
|
510
|
+
const detail = configError ?? "invalid configuration";
|
|
511
|
+
await ctx.session.synthetic({ sessionID, text: `${INVALID_CONFIG_ACTION}: ${detail}` });
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await setSessionMode(sessionID, target as "on" | "off", true);
|
|
515
|
+
}
|
|
516
|
+
const now = await snapshot(sessionID);
|
|
517
|
+
const detail = now.mode === "invalid" && now.configError !== undefined
|
|
518
|
+
? now.errorKind === "config" ? ` Review disabled: ${now.configError}` : ` ${now.configError}`
|
|
519
|
+
: "";
|
|
520
|
+
await ctx.session.synthetic({ sessionID, text: `Auto mode: ${now.mode}. Reviewing: ${now.reviewing.length}. Today: ${now.counts.allow} allow, ${now.counts.ask} ask, ${now.counts.deny} deny. Breaker: ${now.breaker ? "paused" : "ready"}.${detail}` });
|
|
521
|
+
} catch (error) { debug("command.error", error, { sessionID }); await ctx.session.synthetic({ sessionID, text: "Could not change auto mode; check /auto-mode status." }); }
|
|
522
|
+
},
|
|
523
|
+
})));
|
|
524
|
+
} catch (error) { await cleanup(); throw error; }
|
|
525
|
+
return cleanup;
|
|
375
526
|
},
|
|
376
527
|
});
|
|
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
|
-
|
|
390
|
-
interface ReviewerContext {
|
|
391
|
-
generate: { text(input: { prompt: string; model?: ReviewerModelRef | null }, request?: { signal?: AbortSignal }): Promise<{ text: string }> };
|
|
392
|
-
}
|
|
393
|
-
|
|
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> {
|
|
399
|
-
const ref = model ? parseReviewerModel(model) : sessionModel;
|
|
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
|
-
};
|
|
407
|
-
const prompt = buildStage1Prompt(request, policy);
|
|
408
|
-
if (Buffer.byteLength(prompt) > 65_536) return { decision: "ask", reason: "Request too large for automatic review" };
|
|
409
|
-
throwIfAborted(signal);
|
|
410
|
-
const triage = await withTimeout(ctx.generate.text({ prompt, model: ref }, { signal }), 30_000, signal);
|
|
411
|
-
if (parseStage1(triage.text) === "allow") return { decision: "allow", reason: "LLM triage approved" };
|
|
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.
|
|
417
|
-
return parseDecision(response.text);
|
|
418
|
-
}
|