pi-auto-permissions 0.1.0
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/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +304 -0
- package/THIRD_PARTY_NOTICES.md +70 -0
- package/docs/implementation-plan.md +311 -0
- package/docs/invariants.md +555 -0
- package/package.json +59 -0
- package/src/canonical.ts +314 -0
- package/src/commands/index.ts +362 -0
- package/src/domain.ts +198 -0
- package/src/extension.ts +430 -0
- package/src/guardian/circuit-breaker.ts +102 -0
- package/src/guardian/index.ts +74 -0
- package/src/guardian/policy.ts +135 -0
- package/src/guardian/prompt.ts +379 -0
- package/src/guardian/reviewer.ts +599 -0
- package/src/guardian/types.ts +149 -0
- package/src/guardian/verdict.ts +211 -0
- package/src/pi/index.ts +13 -0
- package/src/pi/model-reviewer.ts +235 -0
- package/src/pi/transcript.ts +524 -0
- package/src/policy/dangerous-command.ts +312 -0
- package/src/policy/path-policy.ts +501 -0
- package/src/runtime/index.ts +1 -0
- package/src/runtime/permission-engine.ts +474 -0
- package/src/sandbox/config.ts +188 -0
- package/src/sandbox/controller.ts +354 -0
- package/src/sandbox/index.ts +26 -0
- package/src/sandbox/runtime.ts +28 -0
- package/src/sandbox/types.ts +125 -0
- package/src/state/config-store.ts +580 -0
- package/src/state/index.ts +2 -0
- package/src/tools/bash.ts +101 -0
- package/src/tools/gate.ts +74 -0
- package/src/tools/index.ts +2 -0
- package/vendor/openai-codex/NOTICE +6 -0
- package/vendor/openai-codex/README.md +17 -0
- package/vendor/openai-codex/guardian/policy.md +42 -0
- package/vendor/openai-codex/guardian/policy_template.md +58 -0
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Adapted and modified from OpenAI Codex
|
|
3
|
+
* codex-rs/core/src/guardian/review.rs and review_session.rs at commit
|
|
4
|
+
* 0fb559f0f6e231a88ac02ea002d3ecd248e2b515; Apache-2.0.
|
|
5
|
+
*/
|
|
6
|
+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
GuardianDenialCircuitBreaker,
|
|
10
|
+
type GuardianCircuitBreakerSnapshot,
|
|
11
|
+
} from "./circuit-breaker.js";
|
|
12
|
+
import { buildGuardianPrompt, type GuardianPrompt } from "./prompt.js";
|
|
13
|
+
import type {
|
|
14
|
+
GuardianAllowResult,
|
|
15
|
+
GuardianDenialReason,
|
|
16
|
+
GuardianDenyResult,
|
|
17
|
+
GuardianModelCall,
|
|
18
|
+
GuardianModelRequest,
|
|
19
|
+
GuardianReviewBinding,
|
|
20
|
+
GuardianReviewInput,
|
|
21
|
+
GuardianReviewResult,
|
|
22
|
+
GuardianReviewerSelection,
|
|
23
|
+
GuardianVerdict,
|
|
24
|
+
} from "./types.js";
|
|
25
|
+
import { GUARDIAN_OUTPUT_SCHEMA, GuardianVerdictError, parseGuardianVerdict } from "./verdict.js";
|
|
26
|
+
|
|
27
|
+
export const GUARDIAN_DENIAL_MESSAGE =
|
|
28
|
+
"Permission denied. This action was not executed. No override will be requested. Choose a materially safer action.";
|
|
29
|
+
export const GUARDIAN_REVIEW_DEADLINE_MS = 90_000;
|
|
30
|
+
export const GUARDIAN_REVIEW_MAX_ATTEMPTS = 3;
|
|
31
|
+
export const GUARDIAN_DEFAULT_MAX_CONCURRENT_REVIEWS = 4;
|
|
32
|
+
export const GUARDIAN_DEFAULT_MAX_QUEUED_REVIEWS = 32;
|
|
33
|
+
const DEFAULT_RETRY_DELAYS_MS = [200, 400] as const;
|
|
34
|
+
const SUPPORTED_THINKING_LEVELS = new Set<ModelThinkingLevel>([
|
|
35
|
+
"off",
|
|
36
|
+
"minimal",
|
|
37
|
+
"low",
|
|
38
|
+
"medium",
|
|
39
|
+
"high",
|
|
40
|
+
"xhigh",
|
|
41
|
+
"max",
|
|
42
|
+
]);
|
|
43
|
+
const NO_TOOLS = Object.freeze([]) as readonly [];
|
|
44
|
+
|
|
45
|
+
class GuardianDeadlineError extends Error {
|
|
46
|
+
constructor() {
|
|
47
|
+
super("Guardian aggregate deadline elapsed");
|
|
48
|
+
this.name = "GuardianDeadlineError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
class GuardianCancellationError extends Error {
|
|
53
|
+
constructor() {
|
|
54
|
+
super("Guardian review was cancelled");
|
|
55
|
+
this.name = "GuardianCancellationError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Provider adapters use this error to opt a failure into the bounded retry set. */
|
|
60
|
+
export class GuardianModelError extends Error {
|
|
61
|
+
readonly retryable: boolean;
|
|
62
|
+
|
|
63
|
+
constructor(message: string, options: { readonly retryable: boolean; readonly cause?: unknown }) {
|
|
64
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
65
|
+
this.name = "GuardianModelError";
|
|
66
|
+
this.retryable = options.retryable;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GuardianReviewEngineOptions {
|
|
71
|
+
readonly callModel: GuardianModelCall;
|
|
72
|
+
readonly circuitBreaker?: GuardianDenialCircuitBreaker;
|
|
73
|
+
/** Test seams may shorten, but never lengthen, the normative 90 second deadline. */
|
|
74
|
+
readonly deadlineMs?: number;
|
|
75
|
+
/** Test seams may reduce, but never exceed, the normative three attempts. */
|
|
76
|
+
readonly maxAttempts?: number;
|
|
77
|
+
readonly retryDelaysMs?: readonly number[];
|
|
78
|
+
readonly maxConcurrentReviews?: number;
|
|
79
|
+
readonly maxQueuedReviews?: number;
|
|
80
|
+
readonly now?: () => number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface PreparedReview {
|
|
84
|
+
readonly turnId: string;
|
|
85
|
+
readonly binding: GuardianReviewBinding;
|
|
86
|
+
readonly prompt: GuardianPrompt;
|
|
87
|
+
readonly signal: AbortSignal | undefined;
|
|
88
|
+
readonly getCurrentBinding: GuardianReviewInput["getCurrentBinding"];
|
|
89
|
+
readonly deadlineAt: number;
|
|
90
|
+
readonly resolve: (result: GuardianReviewResult) => void;
|
|
91
|
+
settled: boolean;
|
|
92
|
+
queueTimer: ReturnType<typeof setTimeout> | undefined;
|
|
93
|
+
queueAbortListener: (() => void) | undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function snapshotReviewer(reviewer: GuardianReviewerSelection): GuardianReviewerSelection {
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
provider: reviewer.provider,
|
|
99
|
+
modelId: reviewer.modelId,
|
|
100
|
+
thinkingLevel: reviewer.thinkingLevel,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function snapshotBinding(binding: GuardianReviewBinding): GuardianReviewBinding {
|
|
105
|
+
return Object.freeze({
|
|
106
|
+
canonicalAction: binding.canonicalAction,
|
|
107
|
+
globalRevision: binding.globalRevision,
|
|
108
|
+
sessionRevision: binding.sessionRevision,
|
|
109
|
+
backend: binding.backend,
|
|
110
|
+
sessionId: binding.sessionId,
|
|
111
|
+
reviewer: snapshotReviewer(binding.reviewer),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function guardianReviewBindingsEqual(
|
|
116
|
+
left: GuardianReviewBinding,
|
|
117
|
+
right: GuardianReviewBinding,
|
|
118
|
+
): boolean {
|
|
119
|
+
return (
|
|
120
|
+
left.canonicalAction === right.canonicalAction &&
|
|
121
|
+
left.globalRevision === right.globalRevision &&
|
|
122
|
+
left.sessionRevision === right.sessionRevision &&
|
|
123
|
+
left.backend === right.backend &&
|
|
124
|
+
left.sessionId === right.sessionId &&
|
|
125
|
+
left.reviewer.provider === right.reviewer.provider &&
|
|
126
|
+
left.reviewer.modelId === right.reviewer.modelId &&
|
|
127
|
+
left.reviewer.thinkingLevel === right.reviewer.thinkingLevel
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function assertPositiveInteger(value: number, label: string, maximum?: number): void {
|
|
132
|
+
if (!Number.isSafeInteger(value) || value <= 0 || (maximum !== undefined && value > maximum)) {
|
|
133
|
+
throw new RangeError(
|
|
134
|
+
maximum === undefined
|
|
135
|
+
? `${label} must be a positive safe integer`
|
|
136
|
+
: `${label} must be a positive safe integer no greater than ${maximum}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertNonNegativeInteger(value: number, label: string): void {
|
|
142
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
143
|
+
throw new TypeError(`${label} must be a non-negative safe integer`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function assertBinding(binding: GuardianReviewBinding): void {
|
|
148
|
+
assertNonNegativeInteger(binding.globalRevision, "globalRevision");
|
|
149
|
+
assertNonNegativeInteger(binding.sessionRevision, "sessionRevision");
|
|
150
|
+
if (
|
|
151
|
+
binding.backend !== "sandboxed" &&
|
|
152
|
+
binding.backend !== "review-only" &&
|
|
153
|
+
binding.backend !== "unavailable"
|
|
154
|
+
) {
|
|
155
|
+
throw new TypeError("Guardian binding has an invalid backend");
|
|
156
|
+
}
|
|
157
|
+
if (
|
|
158
|
+
typeof binding.sessionId !== "string" ||
|
|
159
|
+
binding.sessionId.length === 0 ||
|
|
160
|
+
binding.sessionId.length > 512 ||
|
|
161
|
+
/[\r\n]/u.test(binding.sessionId)
|
|
162
|
+
) {
|
|
163
|
+
throw new TypeError("Guardian binding has an invalid session id");
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
typeof binding.reviewer.provider !== "string" ||
|
|
167
|
+
binding.reviewer.provider.trim().length === 0 ||
|
|
168
|
+
typeof binding.reviewer.modelId !== "string" ||
|
|
169
|
+
binding.reviewer.modelId.trim().length === 0 ||
|
|
170
|
+
!SUPPORTED_THINKING_LEVELS.has(binding.reviewer.thinkingLevel)
|
|
171
|
+
) {
|
|
172
|
+
throw new TypeError("Guardian binding has an invalid reviewer selection");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isRetryableModelError(error: unknown): boolean {
|
|
177
|
+
return error instanceof GuardianModelError && error.retryable;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Owns all model-review liveness bounds. It never executes the reviewed action
|
|
182
|
+
* and has no UI dependency; the caller may execute only an `allow` result after
|
|
183
|
+
* one final binding comparison at the tool gate.
|
|
184
|
+
*/
|
|
185
|
+
export class GuardianReviewEngine {
|
|
186
|
+
readonly #callModel: GuardianModelCall;
|
|
187
|
+
readonly #circuitBreaker: GuardianDenialCircuitBreaker;
|
|
188
|
+
readonly #deadlineMs: number;
|
|
189
|
+
readonly #maxAttempts: number;
|
|
190
|
+
readonly #retryDelaysMs: readonly number[];
|
|
191
|
+
readonly #maxConcurrentReviews: number;
|
|
192
|
+
readonly #maxQueuedReviews: number;
|
|
193
|
+
readonly #now: () => number;
|
|
194
|
+
#activeReviews = 0;
|
|
195
|
+
readonly #queue: PreparedReview[] = [];
|
|
196
|
+
|
|
197
|
+
constructor(options: GuardianReviewEngineOptions) {
|
|
198
|
+
if (typeof options.callModel !== "function") {
|
|
199
|
+
throw new TypeError("GuardianReviewEngine requires a model-call function");
|
|
200
|
+
}
|
|
201
|
+
const deadlineMs = options.deadlineMs ?? GUARDIAN_REVIEW_DEADLINE_MS;
|
|
202
|
+
const maxAttempts = options.maxAttempts ?? GUARDIAN_REVIEW_MAX_ATTEMPTS;
|
|
203
|
+
const maxConcurrentReviews =
|
|
204
|
+
options.maxConcurrentReviews ?? GUARDIAN_DEFAULT_MAX_CONCURRENT_REVIEWS;
|
|
205
|
+
const maxQueuedReviews = options.maxQueuedReviews ?? GUARDIAN_DEFAULT_MAX_QUEUED_REVIEWS;
|
|
206
|
+
assertPositiveInteger(deadlineMs, "deadlineMs", GUARDIAN_REVIEW_DEADLINE_MS);
|
|
207
|
+
assertPositiveInteger(maxAttempts, "maxAttempts", GUARDIAN_REVIEW_MAX_ATTEMPTS);
|
|
208
|
+
assertPositiveInteger(maxConcurrentReviews, "maxConcurrentReviews");
|
|
209
|
+
if (!Number.isSafeInteger(maxQueuedReviews) || maxQueuedReviews < 0) {
|
|
210
|
+
throw new RangeError("maxQueuedReviews must be a non-negative safe integer");
|
|
211
|
+
}
|
|
212
|
+
const retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
213
|
+
if (
|
|
214
|
+
retryDelaysMs.some(
|
|
215
|
+
(delay) => !Number.isSafeInteger(delay) || delay < 0 || delay > GUARDIAN_REVIEW_DEADLINE_MS,
|
|
216
|
+
)
|
|
217
|
+
) {
|
|
218
|
+
throw new RangeError("Guardian retry delays must be bounded non-negative integers");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.#callModel = options.callModel;
|
|
222
|
+
this.#circuitBreaker = options.circuitBreaker ?? new GuardianDenialCircuitBreaker();
|
|
223
|
+
this.#deadlineMs = deadlineMs;
|
|
224
|
+
this.#maxAttempts = maxAttempts;
|
|
225
|
+
this.#retryDelaysMs = [...retryDelaysMs];
|
|
226
|
+
this.#maxConcurrentReviews = maxConcurrentReviews;
|
|
227
|
+
this.#maxQueuedReviews = maxQueuedReviews;
|
|
228
|
+
this.#now = options.now ?? Date.now;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
get load(): { readonly active: number; readonly queued: number } {
|
|
232
|
+
return { active: this.#activeReviews, queued: this.#queue.length };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
clearTurn(turnId: string): void {
|
|
236
|
+
this.#circuitBreaker.clearTurn(turnId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
isTurnInterrupted(turnId: string): boolean {
|
|
240
|
+
return this.#circuitBreaker.isInterrupted(turnId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Account for a permission denial decided before Guardian model I/O. */
|
|
244
|
+
recordPermissionDenial(turnId: string): GuardianCircuitBreakerSnapshot {
|
|
245
|
+
return this.#circuitBreaker.recordDenial(turnId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Account for a statically admitted Auto action between reviewed actions. */
|
|
249
|
+
recordPermissionNonDenial(turnId: string): GuardianCircuitBreakerSnapshot {
|
|
250
|
+
return this.#circuitBreaker.recordNonDenial(turnId);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
circuitBreakerSnapshot(turnId: string): GuardianCircuitBreakerSnapshot {
|
|
254
|
+
return this.#circuitBreaker.snapshot(turnId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
review(input: GuardianReviewInput): Promise<GuardianReviewResult> {
|
|
258
|
+
const deadlineAt = this.#now() + this.#deadlineMs;
|
|
259
|
+
if (
|
|
260
|
+
typeof input.turnId !== "string" ||
|
|
261
|
+
input.turnId.trim().length === 0 ||
|
|
262
|
+
input.turnId.length > 512
|
|
263
|
+
) {
|
|
264
|
+
return Promise.resolve(this.#deny(input.turnId, "invalid_input", 0, false));
|
|
265
|
+
}
|
|
266
|
+
if (this.#circuitBreaker.isInterrupted(input.turnId)) {
|
|
267
|
+
return Promise.resolve(this.#deny(input.turnId, "circuit_breaker", 0, false));
|
|
268
|
+
}
|
|
269
|
+
if (input.signal?.aborted === true) {
|
|
270
|
+
return Promise.resolve(this.#deny(input.turnId, "cancelled", 0));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let binding: GuardianReviewBinding;
|
|
274
|
+
let prompt: GuardianPrompt;
|
|
275
|
+
try {
|
|
276
|
+
binding = snapshotBinding(input.binding);
|
|
277
|
+
assertBinding(binding);
|
|
278
|
+
prompt = buildGuardianPrompt({
|
|
279
|
+
sessionId: binding.sessionId,
|
|
280
|
+
transcript: input.transcript,
|
|
281
|
+
canonicalAction: binding.canonicalAction,
|
|
282
|
+
...(input.retryReason === undefined ? {} : { retryReason: input.retryReason }),
|
|
283
|
+
});
|
|
284
|
+
} catch {
|
|
285
|
+
return Promise.resolve(this.#deny(input.turnId, "invalid_input", 0));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return new Promise<GuardianReviewResult>((resolve) => {
|
|
289
|
+
const prepared: PreparedReview = {
|
|
290
|
+
turnId: input.turnId,
|
|
291
|
+
binding,
|
|
292
|
+
prompt,
|
|
293
|
+
signal: input.signal,
|
|
294
|
+
getCurrentBinding: input.getCurrentBinding,
|
|
295
|
+
deadlineAt,
|
|
296
|
+
resolve,
|
|
297
|
+
settled: false,
|
|
298
|
+
queueTimer: undefined,
|
|
299
|
+
queueAbortListener: undefined,
|
|
300
|
+
};
|
|
301
|
+
if (this.#activeReviews < this.#maxConcurrentReviews) {
|
|
302
|
+
this.#begin(prepared);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (this.#queue.length >= this.#maxQueuedReviews) {
|
|
306
|
+
prepared.settled = true;
|
|
307
|
+
resolve(this.#deny(input.turnId, "queue_exhausted", 0));
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
this.#enqueue(prepared);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#enqueue(prepared: PreparedReview): void {
|
|
315
|
+
this.#queue.push(prepared);
|
|
316
|
+
const remaining = Math.max(0, prepared.deadlineAt - this.#now());
|
|
317
|
+
prepared.queueTimer = setTimeout(() => {
|
|
318
|
+
this.#removeQueued(prepared);
|
|
319
|
+
this.#settle(prepared, this.#deny(prepared.turnId, "timeout", 0));
|
|
320
|
+
}, remaining);
|
|
321
|
+
if (prepared.signal !== undefined) {
|
|
322
|
+
prepared.queueAbortListener = () => {
|
|
323
|
+
this.#removeQueued(prepared);
|
|
324
|
+
this.#settle(prepared, this.#deny(prepared.turnId, "cancelled", 0));
|
|
325
|
+
};
|
|
326
|
+
prepared.signal.addEventListener("abort", prepared.queueAbortListener, { once: true });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
#removeQueued(prepared: PreparedReview): void {
|
|
331
|
+
const index = this.#queue.indexOf(prepared);
|
|
332
|
+
if (index >= 0) this.#queue.splice(index, 1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
#cleanupQueueWait(prepared: PreparedReview): void {
|
|
336
|
+
if (prepared.queueTimer !== undefined) {
|
|
337
|
+
clearTimeout(prepared.queueTimer);
|
|
338
|
+
prepared.queueTimer = undefined;
|
|
339
|
+
}
|
|
340
|
+
if (prepared.signal !== undefined && prepared.queueAbortListener !== undefined) {
|
|
341
|
+
prepared.signal.removeEventListener("abort", prepared.queueAbortListener);
|
|
342
|
+
prepared.queueAbortListener = undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
#settle(prepared: PreparedReview, result: GuardianReviewResult): void {
|
|
347
|
+
if (prepared.settled) return;
|
|
348
|
+
prepared.settled = true;
|
|
349
|
+
this.#cleanupQueueWait(prepared);
|
|
350
|
+
prepared.resolve(result);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
#begin(prepared: PreparedReview): void {
|
|
354
|
+
if (prepared.settled) return;
|
|
355
|
+
this.#cleanupQueueWait(prepared);
|
|
356
|
+
if (prepared.signal?.aborted === true) {
|
|
357
|
+
this.#settle(prepared, this.#deny(prepared.turnId, "cancelled", 0));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (this.#now() >= prepared.deadlineAt) {
|
|
361
|
+
this.#settle(prepared, this.#deny(prepared.turnId, "timeout", 0));
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (this.#circuitBreaker.isInterrupted(prepared.turnId)) {
|
|
365
|
+
this.#settle(prepared, this.#deny(prepared.turnId, "circuit_breaker", 0, false));
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
this.#activeReviews += 1;
|
|
370
|
+
void this.#execute(prepared)
|
|
371
|
+
.then((result) => this.#settle(prepared, result))
|
|
372
|
+
.catch(() => this.#settle(prepared, this.#deny(prepared.turnId, "internal_error", 0)))
|
|
373
|
+
.finally(() => {
|
|
374
|
+
this.#activeReviews -= 1;
|
|
375
|
+
this.#drainQueue();
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#drainQueue(): void {
|
|
380
|
+
while (this.#activeReviews < this.#maxConcurrentReviews && this.#queue.length > 0) {
|
|
381
|
+
const prepared = this.#queue.shift();
|
|
382
|
+
if (prepared !== undefined && !prepared.settled) this.#begin(prepared);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async #execute(prepared: PreparedReview): Promise<GuardianReviewResult> {
|
|
387
|
+
let attempts = 0;
|
|
388
|
+
for (let attempt = 1; attempt <= this.#maxAttempts; attempt += 1) {
|
|
389
|
+
if (this.#circuitBreaker.isInterrupted(prepared.turnId)) {
|
|
390
|
+
return this.#deny(prepared.turnId, "circuit_breaker", attempts, false);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const preflight = await this.#bindingStatus(prepared);
|
|
394
|
+
if (preflight !== "current") {
|
|
395
|
+
return this.#deny(prepared.turnId, preflight, attempts);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
attempts = attempt;
|
|
399
|
+
let verdict: GuardianVerdict;
|
|
400
|
+
try {
|
|
401
|
+
const controller = new AbortController();
|
|
402
|
+
const request: GuardianModelRequest = Object.freeze({
|
|
403
|
+
provider: prepared.binding.reviewer.provider,
|
|
404
|
+
modelId: prepared.binding.reviewer.modelId,
|
|
405
|
+
reasoning:
|
|
406
|
+
prepared.binding.reviewer.thinkingLevel === "off"
|
|
407
|
+
? undefined
|
|
408
|
+
: prepared.binding.reviewer.thinkingLevel,
|
|
409
|
+
systemPrompt: prepared.prompt.systemPrompt,
|
|
410
|
+
userPrompt: prepared.prompt.userPrompt,
|
|
411
|
+
outputSchema: GUARDIAN_OUTPUT_SCHEMA,
|
|
412
|
+
tools: NO_TOOLS,
|
|
413
|
+
attempt,
|
|
414
|
+
});
|
|
415
|
+
const response = await this.#beforeDeadline(
|
|
416
|
+
Promise.resolve().then(() => this.#callModel(request, controller.signal)),
|
|
417
|
+
prepared.deadlineAt,
|
|
418
|
+
prepared.signal,
|
|
419
|
+
() => controller.abort(),
|
|
420
|
+
);
|
|
421
|
+
verdict = parseGuardianVerdict(response.text);
|
|
422
|
+
} catch (error) {
|
|
423
|
+
const terminalReason = this.#terminalErrorReason(error, prepared);
|
|
424
|
+
if (terminalReason !== null) {
|
|
425
|
+
return this.#deny(prepared.turnId, terminalReason, attempts);
|
|
426
|
+
}
|
|
427
|
+
const retryable = error instanceof GuardianVerdictError || isRetryableModelError(error);
|
|
428
|
+
if (!retryable || attempt >= this.#maxAttempts) {
|
|
429
|
+
return this.#deny(
|
|
430
|
+
prepared.turnId,
|
|
431
|
+
error instanceof GuardianVerdictError ? "malformed_verdict" : "model_error",
|
|
432
|
+
attempts,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
const delayResult = await this.#retryDelay(attempt, prepared);
|
|
436
|
+
if (delayResult !== null) {
|
|
437
|
+
return this.#deny(prepared.turnId, delayResult, attempts);
|
|
438
|
+
}
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (verdict.outcome === "deny") {
|
|
443
|
+
return this.#deny(prepared.turnId, "model_denied", attempts, true, verdict);
|
|
444
|
+
}
|
|
445
|
+
const postflight = await this.#bindingStatus(prepared);
|
|
446
|
+
if (postflight !== "current") {
|
|
447
|
+
return this.#deny(prepared.turnId, postflight, attempts);
|
|
448
|
+
}
|
|
449
|
+
if (this.#circuitBreaker.isInterrupted(prepared.turnId)) {
|
|
450
|
+
return this.#deny(prepared.turnId, "circuit_breaker", attempts, false);
|
|
451
|
+
}
|
|
452
|
+
return this.#allow(prepared.binding, verdict, attempts);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return this.#deny(prepared.turnId, "internal_error", attempts);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async #bindingStatus(
|
|
459
|
+
prepared: PreparedReview,
|
|
460
|
+
): Promise<"current" | "stale_binding" | "timeout" | "cancelled" | "internal_error"> {
|
|
461
|
+
try {
|
|
462
|
+
const current = await this.#beforeDeadline(
|
|
463
|
+
Promise.resolve().then(() => prepared.getCurrentBinding()),
|
|
464
|
+
prepared.deadlineAt,
|
|
465
|
+
prepared.signal,
|
|
466
|
+
);
|
|
467
|
+
return current !== null && guardianReviewBindingsEqual(prepared.binding, current)
|
|
468
|
+
? "current"
|
|
469
|
+
: "stale_binding";
|
|
470
|
+
} catch (error) {
|
|
471
|
+
return this.#terminalErrorReason(error, prepared) ?? "internal_error";
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
#terminalErrorReason(
|
|
476
|
+
error: unknown,
|
|
477
|
+
prepared: PreparedReview,
|
|
478
|
+
): "timeout" | "cancelled" | null {
|
|
479
|
+
if (error instanceof GuardianCancellationError || prepared.signal?.aborted === true) {
|
|
480
|
+
return "cancelled";
|
|
481
|
+
}
|
|
482
|
+
if (error instanceof GuardianDeadlineError || this.#now() >= prepared.deadlineAt) {
|
|
483
|
+
return "timeout";
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async #retryDelay(
|
|
489
|
+
attempt: number,
|
|
490
|
+
prepared: PreparedReview,
|
|
491
|
+
): Promise<"timeout" | "cancelled" | null> {
|
|
492
|
+
const delay = this.#retryDelaysMs[attempt - 1] ?? 0;
|
|
493
|
+
if (delay <= 0) return null;
|
|
494
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
495
|
+
try {
|
|
496
|
+
await this.#beforeDeadline(
|
|
497
|
+
new Promise<void>((resolve) => {
|
|
498
|
+
timer = setTimeout(resolve, delay);
|
|
499
|
+
}),
|
|
500
|
+
prepared.deadlineAt,
|
|
501
|
+
prepared.signal,
|
|
502
|
+
() => {
|
|
503
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
return null;
|
|
507
|
+
} catch (error) {
|
|
508
|
+
return this.#terminalErrorReason(error, prepared) ?? "timeout";
|
|
509
|
+
} finally {
|
|
510
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
#beforeDeadline<T>(
|
|
515
|
+
operation: Promise<T>,
|
|
516
|
+
deadlineAt: number,
|
|
517
|
+
externalSignal: AbortSignal | undefined,
|
|
518
|
+
onAbort?: () => void,
|
|
519
|
+
): Promise<T> {
|
|
520
|
+
if (externalSignal?.aborted === true) {
|
|
521
|
+
onAbort?.();
|
|
522
|
+
// The operation may already have been created; observe any late rejection.
|
|
523
|
+
void operation.catch(() => undefined);
|
|
524
|
+
return Promise.reject(new GuardianCancellationError());
|
|
525
|
+
}
|
|
526
|
+
const remaining = deadlineAt - this.#now();
|
|
527
|
+
if (remaining <= 0) {
|
|
528
|
+
onAbort?.();
|
|
529
|
+
void operation.catch(() => undefined);
|
|
530
|
+
return Promise.reject(new GuardianDeadlineError());
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
return new Promise<T>((resolve, reject) => {
|
|
534
|
+
let settled = false;
|
|
535
|
+
const finish = (callback: () => void): void => {
|
|
536
|
+
if (settled) return;
|
|
537
|
+
settled = true;
|
|
538
|
+
clearTimeout(timer);
|
|
539
|
+
if (externalSignal !== undefined) {
|
|
540
|
+
externalSignal.removeEventListener("abort", handleCancellation);
|
|
541
|
+
}
|
|
542
|
+
callback();
|
|
543
|
+
};
|
|
544
|
+
const handleCancellation = (): void => {
|
|
545
|
+
finish(() => {
|
|
546
|
+
onAbort?.();
|
|
547
|
+
reject(new GuardianCancellationError());
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
const timer = setTimeout(() => {
|
|
551
|
+
finish(() => {
|
|
552
|
+
onAbort?.();
|
|
553
|
+
reject(new GuardianDeadlineError());
|
|
554
|
+
});
|
|
555
|
+
}, remaining);
|
|
556
|
+
if (externalSignal !== undefined) {
|
|
557
|
+
externalSignal.addEventListener("abort", handleCancellation, { once: true });
|
|
558
|
+
}
|
|
559
|
+
operation.then(
|
|
560
|
+
(value) => finish(() => resolve(value)),
|
|
561
|
+
(error: unknown) => finish(() => reject(error)),
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#allow(
|
|
567
|
+
binding: GuardianReviewBinding,
|
|
568
|
+
verdict: GuardianVerdict,
|
|
569
|
+
attempts: number,
|
|
570
|
+
): GuardianAllowResult {
|
|
571
|
+
return {
|
|
572
|
+
outcome: "allow",
|
|
573
|
+
attempts,
|
|
574
|
+
binding,
|
|
575
|
+
verdict,
|
|
576
|
+
interruptTurn: false,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
#deny(
|
|
581
|
+
turnId: string,
|
|
582
|
+
reason: GuardianDenialReason,
|
|
583
|
+
attempts: number,
|
|
584
|
+
record = true,
|
|
585
|
+
verdict?: GuardianVerdict,
|
|
586
|
+
): GuardianDenyResult {
|
|
587
|
+
const breaker = record
|
|
588
|
+
? this.#circuitBreaker.recordDenial(turnId)
|
|
589
|
+
: this.#circuitBreaker.snapshot(turnId);
|
|
590
|
+
const result = {
|
|
591
|
+
outcome: "deny" as const,
|
|
592
|
+
attempts,
|
|
593
|
+
reason,
|
|
594
|
+
message: GUARDIAN_DENIAL_MESSAGE,
|
|
595
|
+
interruptTurn: breaker.interruptTurn,
|
|
596
|
+
};
|
|
597
|
+
return verdict === undefined ? result : { ...result, verdict };
|
|
598
|
+
}
|
|
599
|
+
}
|