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.
Files changed (39) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +6 -0
  3. package/README.md +304 -0
  4. package/THIRD_PARTY_NOTICES.md +70 -0
  5. package/docs/implementation-plan.md +311 -0
  6. package/docs/invariants.md +555 -0
  7. package/package.json +59 -0
  8. package/src/canonical.ts +314 -0
  9. package/src/commands/index.ts +362 -0
  10. package/src/domain.ts +198 -0
  11. package/src/extension.ts +430 -0
  12. package/src/guardian/circuit-breaker.ts +102 -0
  13. package/src/guardian/index.ts +74 -0
  14. package/src/guardian/policy.ts +135 -0
  15. package/src/guardian/prompt.ts +379 -0
  16. package/src/guardian/reviewer.ts +599 -0
  17. package/src/guardian/types.ts +149 -0
  18. package/src/guardian/verdict.ts +211 -0
  19. package/src/pi/index.ts +13 -0
  20. package/src/pi/model-reviewer.ts +235 -0
  21. package/src/pi/transcript.ts +524 -0
  22. package/src/policy/dangerous-command.ts +312 -0
  23. package/src/policy/path-policy.ts +501 -0
  24. package/src/runtime/index.ts +1 -0
  25. package/src/runtime/permission-engine.ts +474 -0
  26. package/src/sandbox/config.ts +188 -0
  27. package/src/sandbox/controller.ts +354 -0
  28. package/src/sandbox/index.ts +26 -0
  29. package/src/sandbox/runtime.ts +28 -0
  30. package/src/sandbox/types.ts +125 -0
  31. package/src/state/config-store.ts +580 -0
  32. package/src/state/index.ts +2 -0
  33. package/src/tools/bash.ts +101 -0
  34. package/src/tools/gate.ts +74 -0
  35. package/src/tools/index.ts +2 -0
  36. package/vendor/openai-codex/NOTICE +6 -0
  37. package/vendor/openai-codex/README.md +17 -0
  38. package/vendor/openai-codex/guardian/policy.md +42 -0
  39. package/vendor/openai-codex/guardian/policy_template.md +58 -0
@@ -0,0 +1,149 @@
1
+ /*
2
+ * Guardian assessment types are adapted and modified from OpenAI Codex
3
+ * codex-rs/core/src/guardian/mod.rs and prompt.rs at commit
4
+ * 0fb559f0f6e231a88ac02ea002d3ecd248e2b515; Apache-2.0.
5
+ */
6
+ import type { ModelThinkingLevel, ThinkingLevel } from "@earendil-works/pi-ai";
7
+
8
+ export type GuardianRiskLevel = "low" | "medium" | "high" | "critical";
9
+
10
+ export type GuardianUserAuthorization = "unknown" | "low" | "medium" | "high";
11
+
12
+ export type GuardianVerdictOutcome = "allow" | "deny";
13
+
14
+ export interface GuardianVerdict {
15
+ readonly outcome: GuardianVerdictOutcome;
16
+ readonly riskLevel: GuardianRiskLevel;
17
+ readonly userAuthorization: GuardianUserAuthorization;
18
+ readonly rationale: string;
19
+ }
20
+
21
+ /**
22
+ * The exact model selection committed in global state. `thinkingLevel` is Pi's
23
+ * native type so the permission extension cannot invent a parallel setting.
24
+ */
25
+ export interface GuardianReviewerSelection {
26
+ readonly provider: string;
27
+ readonly modelId: string;
28
+ readonly thinkingLevel: ModelThinkingLevel;
29
+ }
30
+
31
+ /**
32
+ * The backend is part of the approval identity even for non-shell tools. An
33
+ * unavailable supported-platform sandbox still permits Auto to review direct
34
+ * file and trusted custom tools, so it must be representable here rather than
35
+ * being aliased to another backend.
36
+ */
37
+ export type GuardianBackend = "sandboxed" | "review-only" | "unavailable";
38
+
39
+ /**
40
+ * An immutable snapshot of every value to which an allow verdict is bound.
41
+ * Returning `null` from `getCurrentBinding` means that the session is no longer
42
+ * alive or the action is no longer eligible to execute.
43
+ */
44
+ export interface GuardianReviewBinding {
45
+ readonly canonicalAction: string;
46
+ readonly globalRevision: number;
47
+ readonly sessionRevision: number;
48
+ readonly backend: GuardianBackend;
49
+ readonly sessionId: string;
50
+ readonly reviewer: GuardianReviewerSelection;
51
+ }
52
+
53
+ export type GuardianTranscriptItem =
54
+ | {
55
+ readonly kind: "user";
56
+ readonly text: string;
57
+ /** Synthetic context masquerading as a user message never grants authorization. */
58
+ readonly contextual?: boolean;
59
+ }
60
+ | { readonly kind: "assistant"; readonly text: string }
61
+ | { readonly kind: "tool_call"; readonly toolName: string; readonly text: string }
62
+ | { readonly kind: "tool_result"; readonly toolName?: string; readonly text: string }
63
+ | { readonly kind: "developer" | "system"; readonly text: string };
64
+
65
+ export interface GuardianModelRequest {
66
+ readonly provider: string;
67
+ readonly modelId: string;
68
+ /** Pi provider option: an explicit `off` selection is represented as undefined. */
69
+ readonly reasoning: ThinkingLevel | undefined;
70
+ readonly systemPrompt: string;
71
+ readonly userPrompt: string;
72
+ readonly outputSchema: GuardianOutputSchema;
73
+ /** The reviewer is deliberately independent and receives no tools. */
74
+ readonly tools: readonly [];
75
+ readonly attempt: number;
76
+ }
77
+
78
+ export interface GuardianModelResponse {
79
+ readonly text: string;
80
+ }
81
+
82
+ export type GuardianModelCall = (
83
+ request: GuardianModelRequest,
84
+ signal: AbortSignal,
85
+ ) => Promise<GuardianModelResponse>;
86
+
87
+ export interface GuardianOutputSchema {
88
+ readonly type: "object";
89
+ readonly additionalProperties: false;
90
+ readonly properties: {
91
+ readonly risk_level: {
92
+ readonly type: "string";
93
+ readonly enum: readonly GuardianRiskLevel[];
94
+ };
95
+ readonly user_authorization: {
96
+ readonly type: "string";
97
+ readonly enum: readonly GuardianUserAuthorization[];
98
+ };
99
+ readonly outcome: {
100
+ readonly type: "string";
101
+ readonly enum: readonly GuardianVerdictOutcome[];
102
+ };
103
+ readonly rationale: { readonly type: "string" };
104
+ };
105
+ readonly required: readonly ["outcome"];
106
+ }
107
+
108
+ export type GuardianDenialReason =
109
+ | "model_denied"
110
+ | "invalid_input"
111
+ | "malformed_verdict"
112
+ | "model_error"
113
+ | "internal_error"
114
+ | "timeout"
115
+ | "cancelled"
116
+ | "stale_binding"
117
+ | "queue_exhausted"
118
+ | "circuit_breaker";
119
+
120
+ export interface GuardianAllowResult {
121
+ readonly outcome: "allow";
122
+ readonly attempts: number;
123
+ readonly binding: GuardianReviewBinding;
124
+ readonly verdict: GuardianVerdict;
125
+ readonly interruptTurn: false;
126
+ }
127
+
128
+ export interface GuardianDenyResult {
129
+ readonly outcome: "deny";
130
+ readonly attempts: number;
131
+ readonly reason: GuardianDenialReason;
132
+ readonly message: string;
133
+ readonly interruptTurn: boolean;
134
+ readonly verdict?: GuardianVerdict;
135
+ }
136
+
137
+ export type GuardianReviewResult = GuardianAllowResult | GuardianDenyResult;
138
+
139
+ export interface GuardianReviewInput {
140
+ readonly turnId: string;
141
+ readonly binding: GuardianReviewBinding;
142
+ readonly transcript: readonly GuardianTranscriptItem[];
143
+ readonly retryReason?: string;
144
+ readonly signal?: AbortSignal;
145
+ readonly getCurrentBinding: () =>
146
+ | GuardianReviewBinding
147
+ | null
148
+ | Promise<GuardianReviewBinding | null>;
149
+ }
@@ -0,0 +1,211 @@
1
+ /*
2
+ * Adapted and modified from OpenAI Codex
3
+ * codex-rs/core/src/guardian/prompt.rs at commit
4
+ * 0fb559f0f6e231a88ac02ea002d3ecd248e2b515; Apache-2.0.
5
+ */
6
+ import { Buffer } from "node:buffer";
7
+
8
+ import type {
9
+ GuardianOutputSchema,
10
+ GuardianRiskLevel,
11
+ GuardianUserAuthorization,
12
+ GuardianVerdict,
13
+ GuardianVerdictOutcome,
14
+ } from "./types.js";
15
+
16
+ export const GUARDIAN_MAX_VERDICT_BYTES = 16_384;
17
+
18
+ const RISK_LEVELS = Object.freeze(["low", "medium", "high", "critical"] as const);
19
+ const AUTHORIZATION_LEVELS = Object.freeze(["unknown", "low", "medium", "high"] as const);
20
+ const OUTCOMES = Object.freeze(["allow", "deny"] as const);
21
+ const ALLOWED_KEYS = new Set(["risk_level", "user_authorization", "outcome", "rationale"]);
22
+
23
+ export const GUARDIAN_OUTPUT_SCHEMA: GuardianOutputSchema = Object.freeze({
24
+ type: "object",
25
+ additionalProperties: false,
26
+ properties: Object.freeze({
27
+ risk_level: Object.freeze({ type: "string", enum: RISK_LEVELS }),
28
+ user_authorization: Object.freeze({ type: "string", enum: AUTHORIZATION_LEVELS }),
29
+ outcome: Object.freeze({ type: "string", enum: OUTCOMES }),
30
+ rationale: Object.freeze({ type: "string" }),
31
+ }),
32
+ required: Object.freeze(["outcome"] as const),
33
+ });
34
+
35
+ export type GuardianVerdictErrorCode =
36
+ | "empty"
37
+ | "oversized"
38
+ | "invalid_json"
39
+ | "invalid_shape"
40
+ | "duplicate_field"
41
+ | "unknown_field"
42
+ | "missing_outcome"
43
+ | "invalid_outcome"
44
+ | "invalid_risk_level"
45
+ | "invalid_authorization"
46
+ | "invalid_rationale";
47
+
48
+ export class GuardianVerdictError extends Error {
49
+ readonly code: GuardianVerdictErrorCode;
50
+
51
+ constructor(code: GuardianVerdictErrorCode, message: string) {
52
+ super(message);
53
+ this.name = "GuardianVerdictError";
54
+ this.code = code;
55
+ }
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return value !== null && typeof value === "object" && !Array.isArray(value);
60
+ }
61
+
62
+ function isRiskLevel(value: unknown): value is GuardianRiskLevel {
63
+ return typeof value === "string" && (RISK_LEVELS as readonly string[]).includes(value);
64
+ }
65
+
66
+ function isAuthorization(value: unknown): value is GuardianUserAuthorization {
67
+ return typeof value === "string" && (AUTHORIZATION_LEVELS as readonly string[]).includes(value);
68
+ }
69
+
70
+ function isOutcome(value: unknown): value is GuardianVerdictOutcome {
71
+ return typeof value === "string" && (OUTCOMES as readonly string[]).includes(value);
72
+ }
73
+
74
+ function skipWhitespace(text: string, start: number): number {
75
+ let index = start;
76
+ while (index < text.length && /\s/u.test(text[index] ?? "")) index += 1;
77
+ return index;
78
+ }
79
+
80
+ function jsonStringEnd(text: string, start: number): number {
81
+ let escaped = false;
82
+ for (let index = start + 1; index < text.length; index += 1) {
83
+ const character = text[index];
84
+ if (escaped) {
85
+ escaped = false;
86
+ continue;
87
+ }
88
+ if (character === "\\") {
89
+ escaped = true;
90
+ continue;
91
+ }
92
+ if (character === '"') return index + 1;
93
+ }
94
+ return text.length;
95
+ }
96
+
97
+ /** JSON.parse accepts duplicate members and silently keeps the last one. */
98
+ function assertNoDuplicateTopLevelFields(text: string): void {
99
+ let index = skipWhitespace(text, 0);
100
+ if (text[index] !== "{") return;
101
+ index = skipWhitespace(text, index + 1);
102
+ const fields = new Set<string>();
103
+ while (index < text.length && text[index] !== "}") {
104
+ const keyEnd = jsonStringEnd(text, index);
105
+ const field = JSON.parse(text.slice(index, keyEnd)) as string;
106
+ if (fields.has(field)) {
107
+ throw new GuardianVerdictError(
108
+ "duplicate_field",
109
+ `Guardian verdict repeats field: ${field}`,
110
+ );
111
+ }
112
+ fields.add(field);
113
+ index = skipWhitespace(text, keyEnd);
114
+ if (text[index] !== ":") return;
115
+ index += 1;
116
+
117
+ let nestedDepth = 0;
118
+ let inString = false;
119
+ let escaped = false;
120
+ for (; index < text.length; index += 1) {
121
+ const character = text[index];
122
+ if (inString) {
123
+ if (escaped) escaped = false;
124
+ else if (character === "\\") escaped = true;
125
+ else if (character === '"') inString = false;
126
+ continue;
127
+ }
128
+ if (character === '"') {
129
+ inString = true;
130
+ continue;
131
+ }
132
+ if (character === "{" || character === "[") {
133
+ nestedDepth += 1;
134
+ continue;
135
+ }
136
+ if (character === "]") {
137
+ nestedDepth -= 1;
138
+ continue;
139
+ }
140
+ if (character === "}" && nestedDepth > 0) {
141
+ nestedDepth -= 1;
142
+ continue;
143
+ }
144
+ if (nestedDepth === 0 && (character === "," || character === "}")) break;
145
+ }
146
+ if (text[index] === ",") index = skipWhitespace(text, index + 1);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Parses one complete JSON value and applies the pinned Guardian schema. There
152
+ * is intentionally no fence/prose extraction and no inference of an allow.
153
+ */
154
+ export function parseGuardianVerdict(text: string): GuardianVerdict {
155
+ if (typeof text !== "string" || text.trim().length === 0) {
156
+ throw new GuardianVerdictError("empty", "Guardian verdict was empty");
157
+ }
158
+ if (Buffer.byteLength(text, "utf8") > GUARDIAN_MAX_VERDICT_BYTES) {
159
+ throw new GuardianVerdictError("oversized", "Guardian verdict exceeded its size limit");
160
+ }
161
+
162
+ let parsed: unknown;
163
+ try {
164
+ parsed = JSON.parse(text);
165
+ } catch {
166
+ throw new GuardianVerdictError("invalid_json", "Guardian verdict was not one JSON value");
167
+ }
168
+ if (!isRecord(parsed)) {
169
+ throw new GuardianVerdictError("invalid_shape", "Guardian verdict must be a JSON object");
170
+ }
171
+ assertNoDuplicateTopLevelFields(text);
172
+
173
+ for (const key of Object.keys(parsed)) {
174
+ if (!ALLOWED_KEYS.has(key)) {
175
+ throw new GuardianVerdictError("unknown_field", `Guardian verdict has unknown field: ${key}`);
176
+ }
177
+ }
178
+
179
+ if (!Object.hasOwn(parsed, "outcome")) {
180
+ throw new GuardianVerdictError("missing_outcome", "Guardian verdict omitted outcome");
181
+ }
182
+ if (!isOutcome(parsed.outcome)) {
183
+ throw new GuardianVerdictError("invalid_outcome", "Guardian outcome must be allow or deny");
184
+ }
185
+ const outcome = parsed.outcome;
186
+
187
+ if (parsed.risk_level !== undefined && !isRiskLevel(parsed.risk_level)) {
188
+ throw new GuardianVerdictError("invalid_risk_level", "Guardian risk_level was invalid");
189
+ }
190
+ if (parsed.user_authorization !== undefined && !isAuthorization(parsed.user_authorization)) {
191
+ throw new GuardianVerdictError(
192
+ "invalid_authorization",
193
+ "Guardian user_authorization was invalid",
194
+ );
195
+ }
196
+ if (parsed.rationale !== undefined && typeof parsed.rationale !== "string") {
197
+ throw new GuardianVerdictError("invalid_rationale", "Guardian rationale must be a string");
198
+ }
199
+
200
+ const riskLevel = parsed.risk_level ?? (outcome === "allow" ? "low" : "high");
201
+ const userAuthorization = parsed.user_authorization ?? "unknown";
202
+ const suppliedRationale = parsed.rationale?.trim();
203
+ const rationale =
204
+ suppliedRationale === undefined || suppliedRationale.length === 0
205
+ ? outcome === "allow"
206
+ ? "Auto-review returned a low-risk allow decision."
207
+ : "Auto-review returned a deny decision without a rationale."
208
+ : suppliedRationale;
209
+
210
+ return { outcome, riskLevel, userAuthorization, rationale };
211
+ }
@@ -0,0 +1,13 @@
1
+ export {
2
+ PI_GUARDIAN_MAX_OUTPUT_TOKENS,
3
+ PI_GUARDIAN_SCHEMA_PREAMBLE,
4
+ PI_MODEL_RUNTIME_COMPATIBILITY_VERSION,
5
+ createPiGuardianModelCall,
6
+ type PiGuardianModelCallOptions,
7
+ } from "./model-reviewer.js";
8
+ export {
9
+ PI_GUARDIAN_MAX_TRANSCRIPT_BYTES,
10
+ PI_GUARDIAN_MAX_TRANSCRIPT_ITEMS,
11
+ PI_GUARDIAN_TRANSCRIPT_OMISSION_MARKER,
12
+ guardianTranscriptFromSession,
13
+ } from "./transcript.js";
@@ -0,0 +1,235 @@
1
+ import type {
2
+ Api,
3
+ Context,
4
+ Model,
5
+ ModelThinkingLevel,
6
+ ThinkingLevel,
7
+ } from "@earendil-works/pi-ai";
8
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
9
+ import {
10
+ type ModelRegistry,
11
+ type ModelRuntime,
12
+ VERSION as PI_CODING_AGENT_VERSION,
13
+ } from "@earendil-works/pi-coding-agent";
14
+
15
+ import {
16
+ GuardianModelError,
17
+ type GuardianModelCall,
18
+ type GuardianModelRequest,
19
+ type GuardianModelResponse,
20
+ } from "../guardian/index.js";
21
+
22
+ /**
23
+ * Pi 0.80.10 exposes model lookup and auth through ModelRegistry, but not model
24
+ * invocation. Its own-property `runtime` is therefore the one deliberately
25
+ * isolated compatibility seam in this adapter. Refuse unknown layouts and Pi
26
+ * versions instead of bypassing extension/custom providers through pi-ai's
27
+ * legacy global compatibility dispatcher.
28
+ */
29
+ export const PI_MODEL_RUNTIME_COMPATIBILITY_VERSION = "0.80.10";
30
+ export const PI_GUARDIAN_MAX_OUTPUT_TOKENS = 8_192;
31
+
32
+ export const PI_GUARDIAN_SCHEMA_PREAMBLE =
33
+ "The final response must validate against this exact JSON Schema (the caller will reject any non-conforming response):";
34
+
35
+ interface CompatibleModelRuntime {
36
+ completeSimple: ModelRuntime["completeSimple"];
37
+ }
38
+
39
+ export interface PiGuardianModelCallOptions {
40
+ /** Test seam only; request timestamps carry no authorization semantics. */
41
+ readonly now?: () => number;
42
+ }
43
+
44
+ function permanentModelError(message: string, cause?: unknown): GuardianModelError {
45
+ return new GuardianModelError(message, {
46
+ retryable: false,
47
+ ...(cause === undefined ? {} : { cause }),
48
+ });
49
+ }
50
+
51
+ function transientModelError(message: string, cause?: unknown): GuardianModelError {
52
+ return new GuardianModelError(message, {
53
+ retryable: true,
54
+ ...(cause === undefined ? {} : { cause }),
55
+ });
56
+ }
57
+
58
+ function exactRuntime(registry: ModelRegistry): CompatibleModelRuntime {
59
+ if (PI_CODING_AGENT_VERSION !== PI_MODEL_RUNTIME_COMPATIBILITY_VERSION) {
60
+ throw permanentModelError(
61
+ `Pi ${PI_CODING_AGENT_VERSION} is not supported by the Guardian model-runtime adapter`,
62
+ );
63
+ }
64
+
65
+ const descriptor = Object.getOwnPropertyDescriptor(registry, "runtime");
66
+ if (descriptor === undefined || !("value" in descriptor)) {
67
+ throw permanentModelError("Pi model runtime is unavailable");
68
+ }
69
+ const candidate: unknown = descriptor.value;
70
+ if (
71
+ candidate === null ||
72
+ typeof candidate !== "object" ||
73
+ typeof (candidate as Partial<CompatibleModelRuntime>).completeSimple !== "function"
74
+ ) {
75
+ throw permanentModelError("Pi model runtime is incompatible");
76
+ }
77
+ return candidate as CompatibleModelRuntime;
78
+ }
79
+
80
+ function assertReviewerRequest(request: GuardianModelRequest): void {
81
+ if (
82
+ typeof request.provider !== "string" ||
83
+ request.provider.length === 0 ||
84
+ typeof request.modelId !== "string" ||
85
+ request.modelId.length === 0 ||
86
+ typeof request.systemPrompt !== "string" ||
87
+ typeof request.userPrompt !== "string" ||
88
+ !Array.isArray(request.tools) ||
89
+ request.tools.length !== 0
90
+ ) {
91
+ throw permanentModelError("Guardian model request is invalid");
92
+ }
93
+ }
94
+
95
+ function requestedThinkingLevel(request: GuardianModelRequest): ModelThinkingLevel {
96
+ return request.reasoning ?? "off";
97
+ }
98
+
99
+ function assertThinkingSupported(model: Model<Api>, request: GuardianModelRequest): void {
100
+ const level = requestedThinkingLevel(request);
101
+ if (!getSupportedThinkingLevels(model).includes(level)) {
102
+ throw permanentModelError(
103
+ `Reviewer ${request.provider}/${request.modelId} does not support thinking level ${level}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ function structuredSystemPrompt(request: GuardianModelRequest): string {
109
+ let schema: string | undefined;
110
+ try {
111
+ schema = JSON.stringify(request.outputSchema);
112
+ } catch (error) {
113
+ throw permanentModelError("Guardian output schema is not serializable", error);
114
+ }
115
+ if (typeof schema !== "string" || schema.length === 0) {
116
+ throw permanentModelError("Guardian output schema is unavailable");
117
+ }
118
+ return `${request.systemPrompt.trimEnd()}\n\n${PI_GUARDIAN_SCHEMA_PREAMBLE}\n${schema}\n`;
119
+ }
120
+
121
+ function extractResponseText(
122
+ message: Awaited<ReturnType<ModelRuntime["completeSimple"]>>,
123
+ request: GuardianModelRequest,
124
+ ): GuardianModelResponse {
125
+ if (message === null || typeof message !== "object") {
126
+ throw transientModelError("Pi reviewer returned a malformed response");
127
+ }
128
+ if (message.provider !== request.provider || message.model !== request.modelId) {
129
+ throw permanentModelError("Pi reviewer returned a response for a different model");
130
+ }
131
+ if (message.stopReason === "aborted") {
132
+ throw permanentModelError("Pi reviewer request was aborted");
133
+ }
134
+ if (message.stopReason === "error") {
135
+ throw transientModelError("Pi reviewer request failed");
136
+ }
137
+ if (!Array.isArray(message.content)) {
138
+ throw transientModelError("Pi reviewer returned malformed content");
139
+ }
140
+
141
+ const text: string[] = [];
142
+ for (const block of message.content) {
143
+ if (block.type === "text") {
144
+ text.push(block.text);
145
+ continue;
146
+ }
147
+ if (block.type === "thinking") continue;
148
+ // A reviewer has no tools. A tool call is invalid output even if a provider
149
+ // fabricates one, and is never executed by this adapter.
150
+ throw transientModelError("Pi reviewer attempted an unavailable tool call");
151
+ }
152
+ return { text: text.join("") };
153
+ }
154
+
155
+ /**
156
+ * Adapt Guardian's independent model-call contract to Pi's exact model catalog,
157
+ * auth stack, and provider runtime. All setup mismatches are permanent,
158
+ * fail-closed errors. Provider execution failures are eligible only for
159
+ * Guardian's separate bounded retry policy.
160
+ */
161
+ export function createPiGuardianModelCall(
162
+ modelRegistry: ModelRegistry,
163
+ options: PiGuardianModelCallOptions = {},
164
+ ): GuardianModelCall {
165
+ const now = options.now ?? Date.now;
166
+ return async (
167
+ request: GuardianModelRequest,
168
+ signal: AbortSignal,
169
+ ): Promise<GuardianModelResponse> => {
170
+ assertReviewerRequest(request);
171
+ if (signal.aborted) throw permanentModelError("Pi reviewer request was aborted");
172
+
173
+ const model = modelRegistry.find(request.provider, request.modelId);
174
+ if (
175
+ model === undefined ||
176
+ model.provider !== request.provider ||
177
+ model.id !== request.modelId
178
+ ) {
179
+ throw permanentModelError(
180
+ `Reviewer model ${request.provider}/${request.modelId} is unavailable`,
181
+ );
182
+ }
183
+ assertThinkingSupported(model, request);
184
+
185
+ let auth: Awaited<ReturnType<ModelRegistry["getApiKeyAndHeaders"]>>;
186
+ try {
187
+ auth = await modelRegistry.getApiKeyAndHeaders(model);
188
+ } catch (error) {
189
+ throw permanentModelError(
190
+ `Reviewer authentication is unavailable for ${request.provider}`,
191
+ error,
192
+ );
193
+ }
194
+ if (!auth.ok) {
195
+ throw permanentModelError(
196
+ `Reviewer authentication is unavailable for ${request.provider}`,
197
+ );
198
+ }
199
+ // Auth resolution (notably OAuth refresh) has no AbortSignal in Pi's
200
+ // extension facade. Re-check before provider dispatch so an aggregate
201
+ // Guardian timeout cannot start a late model request after denial.
202
+ if (signal.aborted) throw permanentModelError("Pi reviewer request was aborted");
203
+
204
+ const runtime = exactRuntime(modelRegistry);
205
+ const context: Context = {
206
+ systemPrompt: structuredSystemPrompt(request),
207
+ messages: [
208
+ {
209
+ role: "user",
210
+ content: request.userPrompt,
211
+ timestamp: now(),
212
+ },
213
+ ],
214
+ tools: [],
215
+ };
216
+
217
+ let message: Awaited<ReturnType<ModelRuntime["completeSimple"]>>;
218
+ try {
219
+ message = await runtime.completeSimple(model, context, {
220
+ signal,
221
+ maxTokens: Math.min(model.maxTokens, PI_GUARDIAN_MAX_OUTPUT_TOKENS),
222
+ maxRetries: 0,
223
+ ...(request.reasoning === undefined
224
+ ? {}
225
+ : { reasoning: request.reasoning as ThinkingLevel }),
226
+ });
227
+ } catch (error) {
228
+ if (signal.aborted) {
229
+ throw permanentModelError("Pi reviewer request was aborted", error);
230
+ }
231
+ throw transientModelError("Pi reviewer request failed", error);
232
+ }
233
+ return extractResponseText(message, request);
234
+ };
235
+ }