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,314 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { EnforcementBackend } from "./domain.ts";
3
+ import { assertRevision } from "./domain.ts";
4
+
5
+ export const DEFAULT_CANONICAL_LIMITS: Readonly<CanonicalLimits> = Object.freeze({
6
+ maxBytes: 64 * 1024,
7
+ maxDepth: 32,
8
+ maxNodes: 10_000,
9
+ });
10
+
11
+ export interface CanonicalLimits {
12
+ maxBytes: number;
13
+ maxDepth: number;
14
+ maxNodes: number;
15
+ }
16
+
17
+ export type CanonicalErrorCode =
18
+ | "unsupported"
19
+ | "cycle"
20
+ | "depth"
21
+ | "nodes"
22
+ | "bytes"
23
+ | "invalid-limit";
24
+
25
+ export class CanonicalizationError extends Error {
26
+ readonly code: CanonicalErrorCode;
27
+ readonly path: string;
28
+
29
+ constructor(code: CanonicalErrorCode, message: string, path = "$") {
30
+ super(`${message} at ${path}`);
31
+ this.name = "CanonicalizationError";
32
+ this.code = code;
33
+ this.path = path;
34
+ }
35
+ }
36
+
37
+ export interface CanonicalDocument {
38
+ json: string;
39
+ utf8Bytes: number;
40
+ sha256: string;
41
+ }
42
+
43
+ export interface ActionInput {
44
+ toolName: string;
45
+ arguments: unknown;
46
+ cwd: string;
47
+ toolMetadata: unknown;
48
+ }
49
+
50
+ export interface CanonicalAction extends CanonicalDocument {
51
+ readonly kind: "canonical-action";
52
+ }
53
+
54
+ export interface ReviewBindingInput {
55
+ action: CanonicalAction;
56
+ globalRevision: number;
57
+ sessionRevision: number;
58
+ backend: EnforcementBackend;
59
+ sessionId: string;
60
+ }
61
+
62
+ export interface ReviewBinding extends CanonicalDocument {
63
+ readonly kind: "review-binding";
64
+ readonly actionSha256: string;
65
+ readonly globalRevision: number;
66
+ readonly sessionRevision: number;
67
+ readonly backend: EnforcementBackend;
68
+ readonly sessionId: string;
69
+ }
70
+
71
+ export function canonicalJson(value: unknown, limits: Partial<CanonicalLimits> = {}): CanonicalDocument {
72
+ const resolvedLimits = normalizeLimits(limits);
73
+ const state: EncodingState = {
74
+ limits: resolvedLimits,
75
+ active: new WeakSet<object>(),
76
+ nodes: 0,
77
+ bytes: 0,
78
+ chunks: [],
79
+ };
80
+
81
+ encode(value, "$", 0, state);
82
+ const json = state.chunks.join("");
83
+ return {
84
+ json,
85
+ utf8Bytes: state.bytes,
86
+ sha256: createHash("sha256").update(json, "utf8").digest("hex"),
87
+ };
88
+ }
89
+
90
+ export function canonicalizeAction(
91
+ input: Readonly<ActionInput>,
92
+ limits: Partial<CanonicalLimits> = {},
93
+ ): CanonicalAction {
94
+ if (typeof input.toolName !== "string" || input.toolName.length === 0) {
95
+ throw new CanonicalizationError("unsupported", "toolName must be a non-empty string", "$.toolName");
96
+ }
97
+ if (typeof input.cwd !== "string" || input.cwd.length === 0) {
98
+ throw new CanonicalizationError("unsupported", "cwd must be a non-empty string", "$.cwd");
99
+ }
100
+
101
+ const document = canonicalJson(
102
+ {
103
+ arguments: input.arguments,
104
+ cwd: input.cwd,
105
+ toolMetadata: input.toolMetadata,
106
+ toolName: input.toolName,
107
+ },
108
+ limits,
109
+ );
110
+
111
+ return { kind: "canonical-action", ...document };
112
+ }
113
+
114
+ export function createReviewBinding(
115
+ input: Readonly<ReviewBindingInput>,
116
+ limits: Partial<CanonicalLimits> = {},
117
+ ): ReviewBinding {
118
+ assertRevision(input.globalRevision, "global revision");
119
+ assertRevision(input.sessionRevision, "session revision");
120
+ if (typeof input.sessionId !== "string" || input.sessionId.length === 0) {
121
+ throw new CanonicalizationError("unsupported", "sessionId must be a non-empty string", "$.sessionId");
122
+ }
123
+
124
+ const document = canonicalJson(
125
+ {
126
+ actionSha256: input.action.sha256,
127
+ backend: input.backend,
128
+ globalRevision: input.globalRevision,
129
+ sessionId: input.sessionId,
130
+ sessionRevision: input.sessionRevision,
131
+ },
132
+ limits,
133
+ );
134
+
135
+ return {
136
+ kind: "review-binding",
137
+ ...document,
138
+ actionSha256: input.action.sha256,
139
+ globalRevision: input.globalRevision,
140
+ sessionRevision: input.sessionRevision,
141
+ backend: input.backend,
142
+ sessionId: input.sessionId,
143
+ };
144
+ }
145
+
146
+ export function reviewBindingMatches(
147
+ binding: Readonly<ReviewBinding>,
148
+ current: Readonly<ReviewBindingInput>,
149
+ ): boolean {
150
+ return (
151
+ binding.actionSha256 === current.action.sha256 &&
152
+ binding.globalRevision === current.globalRevision &&
153
+ binding.sessionRevision === current.sessionRevision &&
154
+ binding.backend === current.backend &&
155
+ binding.sessionId === current.sessionId
156
+ );
157
+ }
158
+
159
+ interface EncodingState {
160
+ limits: CanonicalLimits;
161
+ active: WeakSet<object>;
162
+ nodes: number;
163
+ bytes: number;
164
+ chunks: string[];
165
+ }
166
+
167
+ function encode(value: unknown, path: string, depth: number, state: EncodingState): void {
168
+ if (depth > state.limits.maxDepth) {
169
+ throw new CanonicalizationError("depth", `canonical value exceeds depth ${state.limits.maxDepth}`, path);
170
+ }
171
+ state.nodes += 1;
172
+ if (state.nodes > state.limits.maxNodes) {
173
+ throw new CanonicalizationError("nodes", `canonical value exceeds ${state.limits.maxNodes} nodes`, path);
174
+ }
175
+
176
+ if (value === null) return append("null", path, state);
177
+ if (typeof value === "string") {
178
+ // JSON string bytes are never fewer than the source's UTF-16 code units
179
+ // plus its quotes. Reject impossible fits before JSON.stringify can copy
180
+ // an adversarially large tool argument.
181
+ if (value.length + 2 > state.limits.maxBytes - state.bytes) {
182
+ throw new CanonicalizationError(
183
+ "bytes",
184
+ `canonical value exceeds ${state.limits.maxBytes} UTF-8 bytes`,
185
+ path,
186
+ );
187
+ }
188
+ return append(JSON.stringify(value), path, state);
189
+ }
190
+ if (typeof value === "boolean") return append(value ? "true" : "false", path, state);
191
+ if (typeof value === "number") {
192
+ if (!Number.isFinite(value)) {
193
+ throw new CanonicalizationError("unsupported", "non-finite numbers are unsupported", path);
194
+ }
195
+ return append(JSON.stringify(value), path, state);
196
+ }
197
+ if (typeof value !== "object") {
198
+ throw new CanonicalizationError("unsupported", `${typeof value} values are unsupported`, path);
199
+ }
200
+
201
+ if (state.active.has(value)) {
202
+ throw new CanonicalizationError("cycle", "cyclic values are unsupported", path);
203
+ }
204
+
205
+ state.active.add(value);
206
+ try {
207
+ if (Array.isArray(value)) {
208
+ encodeArray(value, path, depth, state);
209
+ } else {
210
+ encodeObject(value, path, depth, state);
211
+ }
212
+ } finally {
213
+ state.active.delete(value);
214
+ }
215
+ }
216
+
217
+ function encodeArray(value: unknown[], path: string, depth: number, state: EncodingState): void {
218
+ if (value.length > state.limits.maxNodes - state.nodes) {
219
+ throw new CanonicalizationError("nodes", `canonical value exceeds ${state.limits.maxNodes} nodes`, path);
220
+ }
221
+ const ownKeys = Reflect.ownKeys(value);
222
+ for (let index = 0; index < value.length; index += 1) {
223
+ if (!Object.hasOwn(value, index)) {
224
+ throw new CanonicalizationError("unsupported", "sparse arrays are unsupported", `${path}[${index}]`);
225
+ }
226
+ }
227
+ for (const key of ownKeys) {
228
+ if (key === "length") continue;
229
+ if (typeof key === "symbol" || !isCanonicalArrayIndex(key, value.length)) {
230
+ throw new CanonicalizationError("unsupported", "arrays with custom properties are unsupported", path);
231
+ }
232
+ }
233
+
234
+ append("[", path, state);
235
+ for (let index = 0; index < value.length; index += 1) {
236
+ if (index > 0) append(",", path, state);
237
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
238
+ if (descriptor === undefined || !("value" in descriptor)) {
239
+ throw new CanonicalizationError("unsupported", "array accessors are unsupported", `${path}[${index}]`);
240
+ }
241
+ encode(descriptor.value, `${path}[${index}]`, depth + 1, state);
242
+ }
243
+ append("]", path, state);
244
+ }
245
+
246
+ function encodeObject(value: object, path: string, depth: number, state: EncodingState): void {
247
+ const prototype = Object.getPrototypeOf(value);
248
+ if (prototype !== Object.prototype && prototype !== null) {
249
+ throw new CanonicalizationError("unsupported", "only plain objects are supported", path);
250
+ }
251
+ if (Object.getOwnPropertySymbols(value).length > 0) {
252
+ throw new CanonicalizationError("unsupported", "symbol keys are unsupported", path);
253
+ }
254
+
255
+ const keys = Object.keys(value);
256
+ if (keys.length > state.limits.maxNodes - state.nodes) {
257
+ throw new CanonicalizationError("nodes", `canonical value exceeds ${state.limits.maxNodes} nodes`, path);
258
+ }
259
+ // Bound total key material before sort/JSON escaping. Three is the minimum
260
+ // per-key structural cost for two quotes and a colon.
261
+ let minimumKeyBytes = 0;
262
+ for (const key of keys) {
263
+ minimumKeyBytes += key.length + 3;
264
+ if (minimumKeyBytes > state.limits.maxBytes - state.bytes) {
265
+ throw new CanonicalizationError(
266
+ "bytes",
267
+ `canonical value exceeds ${state.limits.maxBytes} UTF-8 bytes`,
268
+ path,
269
+ );
270
+ }
271
+ }
272
+ keys.sort();
273
+ append("{", path, state);
274
+ keys.forEach((key, index) => {
275
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
276
+ if (descriptor === undefined || !("value" in descriptor)) {
277
+ throw new CanonicalizationError("unsupported", "object accessors are unsupported", childPath(path, key));
278
+ }
279
+ if (index > 0) append(",", path, state);
280
+ append(JSON.stringify(key), childPath(path, key), state);
281
+ append(":", path, state);
282
+ encode(descriptor.value, childPath(path, key), depth + 1, state);
283
+ });
284
+ append("}", path, state);
285
+ }
286
+
287
+ function append(chunk: string, path: string, state: EncodingState): void {
288
+ const bytes = Buffer.byteLength(chunk, "utf8");
289
+ if (state.bytes + bytes > state.limits.maxBytes) {
290
+ throw new CanonicalizationError("bytes", `canonical value exceeds ${state.limits.maxBytes} UTF-8 bytes`, path);
291
+ }
292
+ state.bytes += bytes;
293
+ state.chunks.push(chunk);
294
+ }
295
+
296
+ function normalizeLimits(limits: Partial<CanonicalLimits>): CanonicalLimits {
297
+ const resolved = { ...DEFAULT_CANONICAL_LIMITS, ...limits };
298
+ for (const [name, value] of Object.entries(resolved)) {
299
+ if (!Number.isSafeInteger(value) || value < 1) {
300
+ throw new CanonicalizationError("invalid-limit", `${name} must be a positive safe integer`);
301
+ }
302
+ }
303
+ return resolved;
304
+ }
305
+
306
+ function isCanonicalArrayIndex(key: string, length: number): boolean {
307
+ if (!/^(0|[1-9]\d*)$/u.test(key)) return false;
308
+ const index = Number(key);
309
+ return Number.isSafeInteger(index) && index >= 0 && index < length && String(index) === key;
310
+ }
311
+
312
+ function childPath(parent: string, key: string): string {
313
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(key) ? `${parent}.${key}` : `${parent}[${JSON.stringify(key)}]`;
314
+ }
@@ -0,0 +1,362 @@
1
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
2
+ import type { Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ import {
9
+ isModelThinkingLevel,
10
+ type PermissionMode,
11
+ type ReviewerSelection,
12
+ } from "../domain.js";
13
+
14
+ type MaybePromise<T> = T | Promise<T>;
15
+
16
+ export type PermissionCommandSnapshot =
17
+ | { health: "healthy"; reviewer: ReviewerSelection | null }
18
+ | { health: "fault"; error: string };
19
+
20
+ /**
21
+ * The command layer deliberately knows nothing about files, sessions, or the
22
+ * enforcement runtime. In particular, setReviewerAndAuto is one host-level
23
+ * semantic operation: the command layer never exposes a partially selected
24
+ * reviewer tuple and never switches the session before that tuple is durable.
25
+ */
26
+ export interface PermissionCommandsHost {
27
+ readSnapshot(): MaybePromise<PermissionCommandSnapshot>;
28
+ setRequestedMode(mode: PermissionMode, ctx: ExtensionCommandContext): MaybePromise<void>;
29
+ /** Must repair faulted global state before committing the complete tuple. */
30
+ setReviewerAndAuto(selection: ReviewerSelection, ctx: ExtensionCommandContext): MaybePromise<void>;
31
+ /** Must remain callable in faulted global state so the command can repair it. */
32
+ setEnabled(enabled: boolean, ctx: ExtensionCommandContext): MaybePromise<void>;
33
+ updateStatus?(ctx: ExtensionCommandContext): MaybePromise<void>;
34
+ }
35
+
36
+ type CommandRegistrar = Pick<ExtensionAPI, "registerCommand">;
37
+
38
+ const AUTO = "Auto";
39
+ const UNRESTRICTED = "Unrestricted";
40
+ const AUTO_UNCONFIGURED = "Auto (unavailable — select /perm-auto-model first)";
41
+ const AUTO_FAULTED = "Auto (unavailable — repair permissions settings first)";
42
+
43
+ const PERM_USAGE = "Usage: /perm auto|unrestricted";
44
+ const MODEL_USAGE = "Usage: /perm-auto-model provider/model thinkingLevel";
45
+ const ENABLED_USAGE = "Usage: /perm-enabled on|off";
46
+
47
+ /** Register the complete, intentionally small user-facing command surface. */
48
+ export function registerPermissionCommands(
49
+ pi: CommandRegistrar,
50
+ host: PermissionCommandsHost,
51
+ ): void {
52
+ pi.registerCommand("perm", {
53
+ description: "Set permission mode to Auto or Unrestricted",
54
+ getArgumentCompletions: (prefix) => completeValues(prefix, ["auto", "unrestricted"]),
55
+ handler: async (rawArgs, ctx) => {
56
+ const args = rawArgs.trim();
57
+ if (args.length > 0) {
58
+ if (args !== "auto" && args !== "unrestricted") {
59
+ ctx.ui.notify(PERM_USAGE, "warning");
60
+ return;
61
+ }
62
+
63
+ if (args === "auto" && !(await autoIsAvailable(host, ctx))) return;
64
+ await mutate(
65
+ ctx,
66
+ host,
67
+ () => host.setRequestedMode(args, ctx),
68
+ `Permissions: ${args === "auto" ? AUTO : UNRESTRICTED}.`,
69
+ );
70
+ return;
71
+ }
72
+
73
+ if (!ctx.hasUI) {
74
+ ctx.ui.notify(PERM_USAGE, "warning");
75
+ return;
76
+ }
77
+
78
+ const snapshot = await readSnapshot(host, ctx);
79
+ if (snapshot === undefined) return;
80
+ const autoLabel =
81
+ snapshot.health === "fault"
82
+ ? AUTO_FAULTED
83
+ : snapshot.reviewer === null
84
+ ? AUTO_UNCONFIGURED
85
+ : AUTO;
86
+ const selected = await selectOption(ctx, "Permission mode", [autoLabel, UNRESTRICTED]);
87
+ if (selected === undefined) return;
88
+
89
+ if (selected === autoLabel) {
90
+ if (autoLabel !== AUTO) {
91
+ notifyAutoUnavailable(ctx, snapshot);
92
+ return;
93
+ }
94
+ await mutate(ctx, host, () => host.setRequestedMode("auto", ctx), "Permissions: Auto.");
95
+ return;
96
+ }
97
+
98
+ if (selected === UNRESTRICTED) {
99
+ await mutate(
100
+ ctx,
101
+ host,
102
+ () => host.setRequestedMode("unrestricted", ctx),
103
+ "Permissions: Unrestricted.",
104
+ );
105
+ }
106
+ },
107
+ });
108
+
109
+ pi.registerCommand("perm-auto-model", {
110
+ description: "Select the Auto reviewer model and thinking level",
111
+ handler: async (rawArgs, ctx) => {
112
+ const args = rawArgs.trim();
113
+ let candidate: ReviewerCandidate | undefined;
114
+
115
+ if (args.length === 0) {
116
+ if (!ctx.hasUI) {
117
+ ctx.ui.notify(MODEL_USAGE, "warning");
118
+ return;
119
+ }
120
+ candidate = await selectReviewerInteractively(ctx);
121
+ } else {
122
+ const parsed = parseReviewerArgs(args);
123
+ if (parsed === undefined) {
124
+ ctx.ui.notify(MODEL_USAGE, "warning");
125
+ return;
126
+ }
127
+ candidate = parsed;
128
+ }
129
+
130
+ if (candidate === undefined) return;
131
+ const validated = await validateReviewer(candidate, ctx);
132
+ if (validated === undefined) return;
133
+
134
+ await mutate(
135
+ ctx,
136
+ host,
137
+ () => host.setReviewerAndAuto(validated, ctx),
138
+ `Auto reviewer: ${validated.provider}/${validated.modelId} (thinking: ${validated.thinkingLevel}). Permissions: Auto.`,
139
+ );
140
+ },
141
+ });
142
+
143
+ pi.registerCommand("perm-enabled", {
144
+ description: "Enable or disable permission enforcement globally",
145
+ getArgumentCompletions: (prefix) => completeValues(prefix, ["on", "off"]),
146
+ handler: async (rawArgs, ctx) => {
147
+ const args = rawArgs.trim();
148
+ if (args !== "on" && args !== "off") {
149
+ ctx.ui.notify(ENABLED_USAGE, "warning");
150
+ return;
151
+ }
152
+
153
+ const enabled = args === "on";
154
+ await mutate(
155
+ ctx,
156
+ host,
157
+ () => host.setEnabled(enabled, ctx),
158
+ `Permissions ${enabled ? "enabled" : "disabled"} globally.`,
159
+ );
160
+ },
161
+ });
162
+ }
163
+
164
+ interface ReviewerCandidate {
165
+ provider: string;
166
+ modelId: string;
167
+ thinkingLevel: ModelThinkingLevel;
168
+ }
169
+
170
+ function parseReviewerArgs(args: string): ReviewerCandidate | undefined {
171
+ const parts = args.split(/\s+/u);
172
+ if (parts.length !== 2) return undefined;
173
+ const modelSpec = parts[0];
174
+ const thinkingLevel = parts[1];
175
+ if (modelSpec === undefined || thinkingLevel === undefined || !isModelThinkingLevel(thinkingLevel)) {
176
+ return undefined;
177
+ }
178
+
179
+ const slash = modelSpec.indexOf("/");
180
+ if (slash <= 0 || slash === modelSpec.length - 1) return undefined;
181
+ return {
182
+ provider: modelSpec.slice(0, slash),
183
+ modelId: modelSpec.slice(slash + 1),
184
+ thinkingLevel,
185
+ };
186
+ }
187
+
188
+ async function selectReviewerInteractively(
189
+ ctx: ExtensionCommandContext,
190
+ ): Promise<ReviewerCandidate | undefined> {
191
+ let available: Model<string>[];
192
+ try {
193
+ available = ctx.modelRegistry.getAvailable();
194
+ } catch (error) {
195
+ notifyFailure(ctx, "Could not list available reviewer models", error);
196
+ return undefined;
197
+ }
198
+
199
+ const byLabel = new Map<string, Model<string>>();
200
+ for (const model of available) byLabel.set(`${model.provider}/${model.id}`, model);
201
+ const labels = [...byLabel.keys()].sort();
202
+ if (labels.length === 0) {
203
+ ctx.ui.notify("No available reviewer models were found.", "warning");
204
+ return undefined;
205
+ }
206
+
207
+ const modelLabel = await selectOption(ctx, "Auto reviewer model", labels);
208
+ if (modelLabel === undefined) return undefined;
209
+ const model = byLabel.get(modelLabel);
210
+ if (model === undefined) {
211
+ ctx.ui.notify("The selected reviewer model is no longer available.", "warning");
212
+ return undefined;
213
+ }
214
+
215
+ const levels = supportedThinkingLevels(model);
216
+ if (levels.length === 0) {
217
+ ctx.ui.notify(`Reviewer model ${modelLabel} has no supported thinking levels.`, "warning");
218
+ return undefined;
219
+ }
220
+ const thinkingLevel = await selectOption(ctx, "Auto reviewer thinking level", levels);
221
+ if (thinkingLevel === undefined) return undefined;
222
+ if (!isModelThinkingLevel(thinkingLevel) || !levels.includes(thinkingLevel)) {
223
+ ctx.ui.notify("The selected thinking level is no longer supported.", "warning");
224
+ return undefined;
225
+ }
226
+
227
+ return { provider: model.provider, modelId: model.id, thinkingLevel };
228
+ }
229
+
230
+ async function validateReviewer(
231
+ candidate: ReviewerCandidate,
232
+ ctx: ExtensionCommandContext,
233
+ ): Promise<ReviewerSelection | undefined> {
234
+ let model: Model<string> | undefined;
235
+ let available: Model<string>[];
236
+ try {
237
+ model = ctx.modelRegistry.find(candidate.provider, candidate.modelId);
238
+ available = ctx.modelRegistry.getAvailable();
239
+ } catch (error) {
240
+ notifyFailure(ctx, "Could not resolve the reviewer model", error);
241
+ return undefined;
242
+ }
243
+
244
+ const display = `${candidate.provider}/${candidate.modelId}`;
245
+ if (model === undefined) {
246
+ ctx.ui.notify(`Reviewer model ${display} does not exist.`, "warning");
247
+ return undefined;
248
+ }
249
+ if (!available.some((item) => item.provider === candidate.provider && item.id === candidate.modelId)) {
250
+ ctx.ui.notify(`Reviewer model ${display} is not available.`, "warning");
251
+ return undefined;
252
+ }
253
+
254
+ const levels = supportedThinkingLevels(model);
255
+ if (!levels.includes(candidate.thinkingLevel)) {
256
+ ctx.ui.notify(
257
+ `Thinking level ${candidate.thinkingLevel} is not supported by ${display}. Supported: ${levels.join(", ") || "none"}.`,
258
+ "warning",
259
+ );
260
+ return undefined;
261
+ }
262
+
263
+ try {
264
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
265
+ if (!auth.ok) {
266
+ ctx.ui.notify(`Reviewer model ${display} has no usable authentication: ${auth.error}`, "warning");
267
+ return undefined;
268
+ }
269
+ } catch (error) {
270
+ notifyFailure(ctx, `Could not resolve authentication for ${display}`, error);
271
+ return undefined;
272
+ }
273
+
274
+ return { ...candidate };
275
+ }
276
+
277
+ function supportedThinkingLevels(model: Model<string>): ModelThinkingLevel[] {
278
+ return [...new Set(getSupportedThinkingLevels(model).filter(isModelThinkingLevel))];
279
+ }
280
+
281
+ async function autoIsAvailable(
282
+ host: PermissionCommandsHost,
283
+ ctx: ExtensionCommandContext,
284
+ ): Promise<boolean> {
285
+ const snapshot = await readSnapshot(host, ctx);
286
+ if (snapshot === undefined) return false;
287
+ if (snapshot.health === "healthy" && snapshot.reviewer !== null) return true;
288
+ notifyAutoUnavailable(ctx, snapshot);
289
+ return false;
290
+ }
291
+
292
+ function notifyAutoUnavailable(
293
+ ctx: ExtensionCommandContext,
294
+ snapshot: PermissionCommandSnapshot,
295
+ ): void {
296
+ if (snapshot.health === "fault") {
297
+ ctx.ui.notify(`Auto is unavailable because permission settings are invalid: ${snapshot.error}`, "warning");
298
+ } else {
299
+ ctx.ui.notify("Auto is unavailable until /perm-auto-model selects a reviewer and thinking level.", "warning");
300
+ }
301
+ }
302
+
303
+ async function readSnapshot(
304
+ host: PermissionCommandsHost,
305
+ ctx: ExtensionCommandContext,
306
+ ): Promise<PermissionCommandSnapshot | undefined> {
307
+ try {
308
+ return await host.readSnapshot();
309
+ } catch (error) {
310
+ notifyFailure(ctx, "Could not read permission settings", error);
311
+ return undefined;
312
+ }
313
+ }
314
+
315
+ async function mutate(
316
+ ctx: ExtensionCommandContext,
317
+ host: PermissionCommandsHost,
318
+ operation: () => MaybePromise<void>,
319
+ successMessage: string,
320
+ ): Promise<void> {
321
+ try {
322
+ await operation();
323
+ } catch (error) {
324
+ notifyFailure(ctx, "Permission settings were not changed", error);
325
+ return;
326
+ }
327
+
328
+ if (host.updateStatus !== undefined) {
329
+ try {
330
+ await host.updateStatus(ctx);
331
+ } catch (error) {
332
+ notifyFailure(ctx, "Permission settings changed, but status could not be refreshed", error);
333
+ }
334
+ }
335
+ ctx.ui.notify(successMessage, "info");
336
+ }
337
+
338
+ function notifyFailure(ctx: ExtensionCommandContext, prefix: string, error: unknown): void {
339
+ const detail = error instanceof Error ? error.message : String(error);
340
+ ctx.ui.notify(`${prefix}: ${detail}`, "error");
341
+ }
342
+
343
+ async function selectOption(
344
+ ctx: ExtensionCommandContext,
345
+ title: string,
346
+ options: string[],
347
+ ): Promise<string | undefined> {
348
+ try {
349
+ return await ctx.ui.select(title, options);
350
+ } catch (error) {
351
+ notifyFailure(ctx, "Could not open permission selection", error);
352
+ return undefined;
353
+ }
354
+ }
355
+
356
+ function completeValues(prefix: string, values: readonly string[]) {
357
+ const normalized = prefix.trimStart();
358
+ const filtered = values.filter((value) => value.startsWith(normalized));
359
+ return filtered.length === 0
360
+ ? null
361
+ : filtered.map((value) => ({ value, label: value }));
362
+ }