@erichll/pi-auto-review 0.22.1 → 0.23.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/CHANGELOG.md CHANGED
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.23.0 - 2026-10-02
4
+
5
+ - Add opt-in `model: "current"` to review approval requests with the active Pi
6
+ session model and provider, reflecting model changes on subsequent reviews.
7
+ - Decide, authorize, and hard-deny on the command that will actually execute,
8
+ not only the rule-matched unit. The permission-system ask payload is now
9
+ normalized into `executedUnit` (what a sudo/env/xargs/inline-shell wrapper
10
+ runs), `fullCommand` (the whole program), and `matchedPattern` (the rule or a
11
+ sentinel such as `<opaque-bash-wrapper>`), and those facts flow into the
12
+ reviewer context and the public `BoundaryRequest`.
13
+ - Split deterministic hard denies by family. Structural rules (recursive forced
14
+ wipes of `/`, `~`, or `$HOME`, and TLS/certificate weakening) scan a
15
+ skeleton with quoted literals masked, plus nested shell payloads (`bash -c`,
16
+ `eval`), including shells with options before `-c`. Literal path components
17
+ retain placeholders so named cleanup targets cannot become root/home wipes.
18
+ Other interpreter payloads (`python -c`, `node -e`, `perl -e`, `ruby -e`) and
19
+ heredoc bodies stay raw because shell quoting cannot establish whether their
20
+ strings execute. Inert mentions in shell commit messages and `echo` arguments
21
+ remain excluded. Hard denies scan the full program, outer unit, and executed
22
+ unit independently, preserving wrapper environment settings. Credential and
23
+ redirect rules keep scanning raw text, including quoted credential operands.
24
+ - Bound nested interpreter expansion to 32 payloads and 32 KiB of cumulative
25
+ payload text per command fact, shared across recursion levels. Lazy
26
+ extraction stops repeated `eval` chains from producing megabytes of
27
+ overlapping suffixes. On exhaustion, raw-command hard denies retain priority;
28
+ otherwise the request defers to a human without calling the model, including
29
+ on a trusted reviewer retry.
30
+ - Bind grants and the exact-retry match to the execution text, so two different
31
+ heredoc bodies gated by the same wrapper unit no longer share one request
32
+ hash. Denial labels and surface inference (`git-push`, `delete`) use the same
33
+ text.
34
+ - Bound the reviewer view: `command`, `executedUnit`, and `fullCommand` are each
35
+ cut to 10 KiB, and any cut sets `fullCommandTruncated`. An incomplete program
36
+ now deterministically defers to a human before the model is called; a trusted
37
+ exact-retry override still reaches the reviewer, and local hard denies keep
38
+ priority. Hashing, hard denies, retry matching, and denial labels always use
39
+ the untruncated text.
40
+
3
41
  ## 0.22.1 - 2026-10-01
4
42
 
5
43
  - Fix permission-policy auditing being disabled on Bun with
package/README.md CHANGED
@@ -95,6 +95,24 @@ Deterministic hard denies cover recursive forced wipes of `/`, `~`, and
95
95
  `$HOME`. A named path under `/home/...` is reviewed by the model as high-risk,
96
96
  not treated as a home-directory wipe.
97
97
 
98
+ Review and authorization use the command that will actually execute, not only
99
+ the unit the matched rule names. When a wrapper (`sudo`, `env`, `xargs`,
100
+ `bash -c`, `eval`, or an interpreter payload) hides the real program, the
101
+ permission-system ask payload supplies the executed unit, the complete command,
102
+ and the matched rule, and those facts are what get hashed, reviewed, and
103
+ hard-denied. Hard denies check the complete command, outer unit, and executed
104
+ unit independently. Structural rules mask inert shell literals without turning
105
+ named paths into root/home targets, and follow nested shell payloads. Other
106
+ interpreter payloads and heredoc bodies are conservatively scanned as raw text;
107
+ credential rules also keep scanning raw text. When a command fact exceeds the
108
+ reviewer bound (10 KiB), the ask defers to a human instead of being reviewed from
109
+ a truncated program.
110
+
111
+ Nested interpreter expansion is limited to 32 payloads and 32 KiB of cumulative
112
+ payload text per command fact, across all recursion levels. Exceeding either
113
+ budget keeps the raw-command hard-deny checks and otherwise defers to a human
114
+ without calling the model. A reviewer-retry override does not bypass this limit.
115
+
98
116
  ## Configuration
99
117
 
100
118
  Configuration is resolved in this order:
@@ -113,6 +131,29 @@ Use the user-global file for normal customization. It may set any legal key:
113
131
  }
114
132
  ```
115
133
 
134
+ To use the model currently selected in the Pi session, set the trusted
135
+ user-global config to:
136
+
137
+ ```json
138
+ {
139
+ "model": "current"
140
+ }
141
+ ```
142
+
143
+ Each review reads the active session model, including its provider, so changing
144
+ models during a session affects subsequent approval reviews. Retries within
145
+ one review use the same selected model. Reviewer reasoning, token limits, and
146
+ timeouts still come from this extension's configuration.
147
+
148
+ The reviewer makes a separate provider request without tools and uses Pi's
149
+ authentication resolution. If no session model is selected, authentication
150
+ fails, or the provider cannot serve this auxiliary request, the existing
151
+ `failureMode` applies (`"deny"` by default, or `"defer"` to the human terminal).
152
+ The default remains `"codex-auto-review"`; explicit model IDs and
153
+ `"provider/reviewer-model"` values continue to select a dedicated reviewer.
154
+ The bare value `"current"` is reserved for this mode; use `"provider/current"`
155
+ to select a model whose ID is literally `current`.
156
+
116
157
  For a complete `@gotgenes/pi-permission-system` config that wires
117
158
  `pi-auto-review` into the authorizer chain — a copyable baseline covering
118
159
  read/write/edit, a read-only bash allowlist, an MCP discovery policy, and a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erichll/pi-auto-review",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Fail-closed, model-backed approval broker for Pi: deterministically hard-denies dangerous operations, reviews dangerous boundaries with a reviewer model, and issues one-shot expiring grants that OS sandbox adapters must consume before retrying.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,9 @@ export function boundaryRequestHash(request: BoundaryRequest): string {
22
22
  operation: request.operation,
23
23
  cwd: request.cwd,
24
24
  command: request.command,
25
+ executedUnit: request.executedUnit,
26
+ fullCommand: request.fullCommand,
27
+ matchedPattern: request.matchedPattern,
25
28
  path: request.path,
26
29
  resolvedPath: request.resolvedPath,
27
30
  destination: request.destination,
@@ -19,6 +19,17 @@ export type BoundaryRequest = {
19
19
  operation: string;
20
20
  cwd: string;
21
21
  command?: string;
22
+ /** Command the rule-matched unit actually runs after unwrapping a wrapper. */
23
+ executedUnit?: string;
24
+ /** The complete program that will execute, when it differs from `command`. */
25
+ fullCommand?: string;
26
+ /** Rule (or synthetic sentinel) that produced this ask. */
27
+ matchedPattern?: string;
28
+ /**
29
+ * Set only on the bounded reviewer view when a command fact was truncated;
30
+ * never set on the stored request used for hashing or hard denies.
31
+ */
32
+ fullCommandTruncated?: boolean;
22
33
  path?: string;
23
34
  resolvedPath?: string;
24
35
  destination?: string;
package/src/index.ts CHANGED
@@ -41,7 +41,7 @@ export {
41
41
  buildUserReviewStatus,
42
42
  } from "./user-feedback.ts";
43
43
 
44
- export { parseDecision } from "./policy.ts";
44
+ export { executionText, parseDecision } from "./policy.ts";
45
45
  export * from "./broker/index.ts";
46
46
  export {
47
47
  sandboxTrapToBoundaryRequest,
@@ -353,6 +353,9 @@ export function createPiAutoReviewExtension(
353
353
  deterministicHardDeny({
354
354
  surface: "bash_escalated",
355
355
  command: request.command,
356
+ executedUnit: request.executedUnit,
357
+ fullCommand: request.fullCommand,
358
+ matchedPattern: request.matchedPattern,
356
359
  path: request.path,
357
360
  target: request.destination,
358
361
  toolName: request.toolName,
package/src/policy.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import { isPathSurface } from "./path-surfaces.ts";
2
+ import {
3
+ shellExpansionLimitExceeded,
4
+ structuralScanText,
5
+ } from "./review/shell-text.ts";
2
6
 
3
7
  export type RiskLevel = "low" | "medium" | "high" | "critical";
4
8
 
@@ -21,12 +25,21 @@ export type PermissionDetailsLike = {
21
25
  toolInputPreview?: unknown;
22
26
  accessIntent?: unknown;
23
27
  forwarding?: unknown;
28
+ /** Structured permission-system ask; the only source of the execution facts. */
29
+ payload?: unknown;
30
+ /** Pre-projected execution facts (broker hard-deny entry). */
31
+ executedUnit?: unknown;
32
+ fullCommand?: unknown;
33
+ matchedPattern?: unknown;
24
34
  };
25
35
 
26
36
  export type NormalizedPermissionEvidence = {
27
37
  surface: string;
28
38
  value?: string;
29
39
  command?: string;
40
+ executedUnit?: string;
41
+ fullCommand?: string;
42
+ matchedPattern?: string;
30
43
  path?: string;
31
44
  resolvedPath?: string;
32
45
  destination?: string;
@@ -155,6 +168,9 @@ export type RelevantBoundaryRequest = {
155
168
  operation?: string;
156
169
  cwd?: string;
157
170
  command?: string;
171
+ executedUnit?: string;
172
+ fullCommand?: string;
173
+ matchedPattern?: string;
158
174
  path?: string;
159
175
  resolvedPath?: string;
160
176
  destination?: string;
@@ -310,6 +326,20 @@ function parsedPreviewCommand(value: unknown): string | undefined {
310
326
  }
311
327
  }
312
328
 
329
+ function payloadFullCommand(
330
+ payload: Record<string, unknown> | undefined,
331
+ ): string | undefined {
332
+ const evidence = payload?.evidence;
333
+ if (!Array.isArray(evidence)) return undefined;
334
+ for (const entry of evidence) {
335
+ const candidate = record(entry);
336
+ if (candidate?.label !== "full command") continue;
337
+ const text = nonEmptyString(candidate.text);
338
+ if (text) return text;
339
+ }
340
+ return undefined;
341
+ }
342
+
313
343
  /** Normalize direct and forwarded permission-system evidence without trusting it. */
314
344
  export function normalizePermissionEvidence(
315
345
  details: PermissionDetailsLike,
@@ -355,10 +385,33 @@ export function normalizePermissionEvidence(
355
385
  nonEmptyString(details.agentName) ??
356
386
  nonEmptyString(forwarding?.requesterAgentName);
357
387
  const sessionId = nonEmptyString(forwarding?.requesterSessionId);
388
+ const payload = record(details.payload);
389
+ const payloadRequest = record(payload?.request);
390
+ const matchedPattern =
391
+ nonEmptyString(payloadRequest?.matchedPattern) ??
392
+ nonEmptyString(details.matchedPattern);
393
+ const executedUnitCandidate =
394
+ nonEmptyString(payloadRequest?.executedUnit) ??
395
+ nonEmptyString(details.executedUnit);
396
+ const fullCommandCandidate =
397
+ payloadFullCommand(payload) ?? nonEmptyString(details.fullCommand);
398
+ // Suppress facts that add nothing over the gated unit: duplicating the same
399
+ // text would only widen the reviewer view and add hash noise.
400
+ const executedUnit =
401
+ executedUnitCandidate && executedUnitCandidate !== value
402
+ ? executedUnitCandidate
403
+ : undefined;
404
+ const fullCommand =
405
+ fullCommandCandidate && fullCommandCandidate !== command
406
+ ? fullCommandCandidate
407
+ : undefined;
358
408
  return {
359
409
  surface,
360
410
  value,
361
411
  command,
412
+ ...(executedUnit ? { executedUnit } : {}),
413
+ ...(fullCommand ? { fullCommand } : {}),
414
+ ...(matchedPattern ? { matchedPattern } : {}),
362
415
  path,
363
416
  resolvedPath: accessIntent?.boundaryValue,
364
417
  destination,
@@ -368,6 +421,22 @@ export function normalizePermissionEvidence(
368
421
  };
369
422
  }
370
423
 
424
+ /**
425
+ * The text that will actually execute, most complete fact first.
426
+ *
427
+ * Used for transcript matching, denial labels, and surface inference. Keep
428
+ * the outer command ahead of its extracted payload: wrapper options and
429
+ * environment assignments are part of execution too. Hard denies scan all
430
+ * three facts independently, and grant hashes bind all three.
431
+ */
432
+ export function executionText(source: {
433
+ command?: string;
434
+ executedUnit?: string;
435
+ fullCommand?: string;
436
+ }): string | undefined {
437
+ return source.fullCommand ?? source.command ?? source.executedUnit;
438
+ }
439
+
371
440
  export function effectiveCommand(details: PermissionDetailsLike): string | undefined {
372
441
  return normalizePermissionEvidence(details).command;
373
442
  }
@@ -377,6 +446,20 @@ export type HardDeny = {
377
446
  reason: string;
378
447
  };
379
448
 
449
+ function executionCommands(details: PermissionDetailsLike): string[] {
450
+ const evidence = normalizePermissionEvidence(details);
451
+ return [...new Set([
452
+ evidence.fullCommand,
453
+ evidence.command,
454
+ evidence.executedUnit,
455
+ ].flatMap((command) => command?.trim() ? [command.trim()] : []))];
456
+ }
457
+
458
+ /** Hard denies run first; incomplete expansion then requires a human decision. */
459
+ export function commandExpansionLimitExceeded(details: PermissionDetailsLike): boolean {
460
+ return executionCommands(details).some(shellExpansionLimitExceeded);
461
+ }
462
+
380
463
  /**
381
464
  * Keep this list narrow: these checks are terminal and cannot be overridden by
382
465
  * the model or the user prompt. Ambiguous or merely high-risk actions belong in
@@ -385,10 +468,26 @@ export type HardDeny = {
385
468
  export function deterministicHardDeny(
386
469
  details: PermissionDetailsLike,
387
470
  ): HardDeny | undefined {
388
- const command = effectiveCommand(details)?.trim();
389
- if (!command) return undefined;
471
+ // These facts are complementary. Unwrapping can omit an env assignment,
472
+ // while the full program can contain syntax our shell scanner cannot peel.
473
+ // Scan independently so unrelated projections cannot form a synthetic pipe
474
+ // or staged credential upload when concatenated.
475
+ for (const command of executionCommands(details)) {
476
+ const denial = hardDenyCommand(command);
477
+ if (denial) return denial;
478
+ }
479
+ return undefined;
480
+ }
390
481
 
391
- for (const segment of command.split(/&&|\|\||;|\n/)) {
482
+ function hardDenyCommand(command: string): HardDeny | undefined {
483
+ // Structural rules run on the literal-masked skeleton so a literal mention
484
+ // inside a commit message or an `echo` argument is not a wipe, while nested
485
+ // interpreter payloads are still scanned. Credential/data rules below stay
486
+ // on the raw text: a quoted `"$HOME/.aws/credentials"` is still the operand
487
+ // of a real upload.
488
+ const structural = structuralScanText(command);
489
+
490
+ for (const segment of structural.split(/&&|\|\||;|\n/)) {
392
491
  const isRm = /(?:^|\s)(?:\/[^\s/]+)*\/?rm(?:\s|$)/i.test(segment);
393
492
  const recursive =
394
493
  /(?:^|\s)--recursive(?:\s|$)/i.test(segment) ||
@@ -411,12 +510,12 @@ export function deterministicHardDeny(
411
510
 
412
511
  if (
413
512
  /\bcurl\b[^;\n]*(?:--insecure\b|-[A-Za-z]*k[A-Za-z]*(?:\s|$))/i.test(
414
- command,
513
+ structural,
415
514
  ) ||
416
- /\bwget\b[^;\n]*--no-check-certificate\b/i.test(command) ||
417
- /\bgit\s+config\b[^;\n]*http\.sslverify\s+false\b/i.test(command) ||
418
- /\bnpm\s+config\s+set\s+strict-ssl\s+false\b/i.test(command) ||
419
- /\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0\b/i.test(command)
515
+ /\bwget\b[^;\n]*--no-check-certificate\b/i.test(structural) ||
516
+ /\bgit\s+config\b[^;\n]*http\.sslverify\s+false\b/i.test(structural) ||
517
+ /\bnpm\s+config\s+set\s+strict-ssl\s+false\b/i.test(structural) ||
518
+ /\bNODE_TLS_REJECT_UNAUTHORIZED\s*=\s*0\b/i.test(structural)
420
519
  ) {
421
520
  return {
422
521
  rule: "transport-security-weakening",
@@ -840,7 +939,7 @@ function relevanceReason(
840
939
  | "provider-branch-protection"
841
940
  | undefined {
842
941
  if (request.toolCallId && call.id === request.toolCallId) return "same-tool";
843
- const currentCommand = request.command || "";
942
+ const currentCommand = executionText(request) || "";
844
943
  const priorCommand = commandArgument(call);
845
944
  const pushedBranch = explicitPushBranch(currentCommand);
846
945
  const providerQuery = providerBranchQuery(priorCommand);
@@ -997,8 +1096,9 @@ function surfaceProfile(
997
1096
  ): EvidenceSurfaceProfile {
998
1097
  if (request.agentName || request.requesterSessionId) return "forwarded";
999
1098
  if (request.surface === "network" || request.destination) return "network";
1000
- if (/\bgit\b[\s\S]*\bpush\b/i.test(request.command ?? "")) return "git-push";
1001
- if (/\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(request.command ?? "")) {
1099
+ const command = executionText(request) ?? "";
1100
+ if (/\bgit\b[\s\S]*\bpush\b/i.test(command)) return "git-push";
1101
+ if (/\b(?:rm|rmdir|unlink|trash|delete)\b/i.test(command)) {
1002
1102
  return "delete";
1003
1103
  }
1004
1104
  return "generic";
@@ -1043,7 +1143,7 @@ function exactStructuredMatch(
1043
1143
  }
1044
1144
  const args = call.arguments as Record<string, unknown>;
1045
1145
  const pairs: Array<[unknown, unknown]> = [
1046
- [args.command, request.command],
1146
+ [args.command, executionText(request)],
1047
1147
  [args.path, request.path],
1048
1148
  [args.resolvedPath, request.resolvedPath],
1049
1149
  [args.destination, request.destination],
@@ -1100,7 +1200,7 @@ function toolArgumentCoveredByRequest(
1100
1200
  return sameReviewerField(actual, request.destination ?? request.path);
1101
1201
  }
1102
1202
  if (key === "value") {
1103
- return [request.command, request.path, request.destination].some(
1203
+ return [executionText(request), request.path, request.destination].some(
1104
1204
  (expected) => sameReviewerField(actual, expected),
1105
1205
  );
1106
1206
  }
@@ -1112,7 +1212,7 @@ function toolArgumentCoveredByRequest(
1112
1212
  });
1113
1213
  }
1114
1214
  const expected = ({
1115
- command: request.command,
1215
+ command: executionText(request),
1116
1216
  path: request.path,
1117
1217
  resolvedPath: request.resolvedPath,
1118
1218
  destination: request.destination,
@@ -4,6 +4,8 @@ import {
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import {
6
6
  buildClassifierTranscript,
7
+ commandExpansionLimitExceeded,
8
+ executionText,
7
9
  parseDecision,
8
10
  type ModelDecision,
9
11
  } from "../policy.ts";
@@ -29,6 +31,7 @@ import {
29
31
  applyReviewerInputBudget,
30
32
  preflightPart,
31
33
  reviewPreflight,
34
+ reviewerRequestView,
32
35
  sharedReviewContext,
33
36
  textFromAssistant,
34
37
  } from "./input.ts";
@@ -87,6 +90,37 @@ export async function complete(
87
90
  sharedContext,
88
91
  config.maxReviewerInputTokens,
89
92
  );
93
+ // Hard denies already ran on the raw text plus bounded payloads. An
94
+ // incomplete expansion must reach the human terminal, even with a trusted
95
+ // reviewer-retry override. The older display-truncation fallback can still
96
+ // be lifted by that override.
97
+ const deferralReason = commandExpansionLimitExceeded(request)
98
+ ? "Nested command expansion exceeds the local scan budget; a human must decide."
99
+ : reviewerRequestView(request).fullCommandTruncated && !reviewerContext?.userOverride
100
+ ? "The complete command exceeds the reviewer input bound and was truncated; a human must decide."
101
+ : undefined;
102
+ if (deferralReason) {
103
+ const deferredSummary: ReviewExecutionSummary = {
104
+ attempts: [],
105
+ errorCounts: {},
106
+ durationMs: Date.now() - started,
107
+ transcript,
108
+ preflight,
109
+ };
110
+ return {
111
+ decision: {
112
+ outcome: "defer",
113
+ risk_level: "high",
114
+ user_authorization: "unknown",
115
+ rationale: deferralReason,
116
+ },
117
+ attempts: 0,
118
+ retryErrors: [],
119
+ durationMs: deferredSummary.durationMs,
120
+ transcript,
121
+ summary: deferredSummary,
122
+ };
123
+ }
90
124
  const deadlineAt = started + config.timeoutMs;
91
125
  const controller = new AbortController();
92
126
  const onSessionAbort = () => controller.abort();
@@ -338,10 +372,9 @@ export function denialLabel(
338
372
  denial.request.resolvedPath ??
339
373
  denial.request.path ??
340
374
  denial.request.destination ??
341
- denial.request.command ??
375
+ executionText(denial.request) ??
342
376
  denial.request.toolName ??
343
377
  denial.request.operation;
344
378
  const compact = String(target).replace(/\s+/g, " ").slice(0, 90);
345
379
  return `${index + 1}. ${denial.request.surface}: ${compact} — ${denial.review.rationale.slice(0, 70)}`;
346
380
  }
347
-
@@ -70,7 +70,7 @@ export function validateConfig(value: unknown, source: string): Config {
70
70
  config.model.split("/").some((segment) => !segment.trim())
71
71
  ) {
72
72
  throw new Error(
73
- `${EXTENSION_NAME}: model must be a model id or provider/model`,
73
+ `${EXTENSION_NAME}: model must be current, a model id, or provider/model`,
74
74
  );
75
75
  }
76
76
  if (
@@ -27,6 +27,10 @@ import {
27
27
  REVIEWER_FRAMING_RESERVE_TOKENS,
28
28
  } from "./consts.ts";
29
29
  import { REVIEWER_SYSTEM_PROMPT } from "./prompts.ts";
30
+ import {
31
+ MAX_REVIEWER_COMMAND_BYTES,
32
+ truncateUtf8,
33
+ } from "./shell-text.ts";
30
34
  import { applyProjectConfig } from "./config.ts";
31
35
  import { assertTrustedInstallation } from "./guards.ts";
32
36
 
@@ -110,6 +114,11 @@ export function boundaryRequest(
110
114
  operation: details.source || surface,
111
115
  cwd: ctx.cwd,
112
116
  command: evidence.command,
117
+ ...(evidence.executedUnit ? { executedUnit: evidence.executedUnit } : {}),
118
+ ...(evidence.fullCommand ? { fullCommand: evidence.fullCommand } : {}),
119
+ ...(evidence.matchedPattern
120
+ ? { matchedPattern: evidence.matchedPattern }
121
+ : {}),
113
122
  path: evidence.path,
114
123
  resolvedPath: evidence.resolvedPath,
115
124
  destination: evidence.destination,
@@ -184,10 +193,43 @@ export function sharedReviewContext(
184
193
  }
185
194
  : {}),
186
195
  profile: transcript.surfaceProfile,
187
- request,
196
+ request: reviewerRequestView(request),
188
197
  });
189
198
  }
190
199
 
200
+ /**
201
+ * The bounded projection of a request that the reviewer model sees.
202
+ *
203
+ * `command`, `executedUnit`, and `fullCommand` are each cut to
204
+ * {@link MAX_REVIEWER_COMMAND_BYTES}; any cut sets `fullCommandTruncated` so the
205
+ * caller can fail closed instead of reviewing an incomplete program. Hashing,
206
+ * hard denies, retry matching, and denial labels keep using the untruncated
207
+ * request.
208
+ */
209
+ export function reviewerRequestView(request: BoundaryRequest): BoundaryRequest {
210
+ let truncated = false;
211
+ const cut = (value: string | undefined): string | undefined => {
212
+ if (value === undefined) return undefined;
213
+ if (Buffer.byteLength(value, "utf8") <= MAX_REVIEWER_COMMAND_BYTES) {
214
+ return value;
215
+ }
216
+ truncated = true;
217
+ return truncateUtf8(value, MAX_REVIEWER_COMMAND_BYTES);
218
+ };
219
+ const view: BoundaryRequest = { ...request };
220
+ const command = cut(request.command);
221
+ const executedUnit = cut(request.executedUnit);
222
+ const fullCommand = cut(request.fullCommand);
223
+ if (command === undefined) delete view.command;
224
+ else view.command = command;
225
+ if (executedUnit === undefined) delete view.executedUnit;
226
+ else view.executedUnit = executedUnit;
227
+ if (fullCommand === undefined) delete view.fullCommand;
228
+ else view.fullCommand = fullCommand;
229
+ if (truncated) view.fullCommandTruncated = true;
230
+ return view;
231
+ }
232
+
191
233
  /**
192
234
  * Conservative token estimate for reviewer prompt sizing.
193
235
  *
@@ -255,7 +297,9 @@ export function reviewPreflight(
255
297
  maxReviewerInputTokens: number,
256
298
  ): ReviewPreflight {
257
299
  const fixedPrompt = preflightPart(REVIEWER_SYSTEM_PROMPT);
258
- const canonicalRequest = preflightPart(canonicalReviewerJson(request));
300
+ const canonicalRequest = preflightPart(
301
+ canonicalReviewerJson(reviewerRequestView(request)),
302
+ );
259
303
  const override = preflightPart(
260
304
  reviewerContext?.userOverride
261
305
  ? canonicalReviewerJson(reviewerContext.userOverride)
@@ -19,6 +19,12 @@ Policy:
19
19
  a sandbox boundary.
20
20
  - Defer when a human must decide or evidence is missing, conflicting,
21
21
  truncated, or materially uncertain. User intent cannot override a hard deny.
22
+ - Request facts: command is the rule-matched unit; executedUnit is what that
23
+ unit runs after unwrapping sudo/env/xargs/inline shells; fullCommand is the
24
+ whole program; matchedPattern names the rule or a sentinel such as
25
+ <opaque-bash-wrapper>, <indirection-bash-wrapper>, or <unparsed-bash-subtree>.
26
+ When fullCommandTruncated is true or a wrapper payload is unresolved, defer;
27
+ never assume unseen text is safe.
22
28
 
23
29
  Return exactly one JSON object and no markdown:
24
30
  {"outcome":"allow|deny|defer","risk_level":"low|medium|high|critical","user_authorization":"unknown|low|medium|high","rationale":"short concrete reason"}
@@ -469,11 +469,11 @@ export function reviewerSessionId(
469
469
  return `pi-auto-review-${fingerprint}`;
470
470
  }
471
471
 
472
- export async function resolveReviewerMeta(
472
+ function resolveConfiguredModel(
473
473
  ctx: ExtensionContext,
474
- config: Config,
475
- ): Promise<ReviewerMeta> {
476
- const { provider, modelId } = parseModelRef(config.model);
474
+ modelRef: string,
475
+ ): ReviewerMeta["model"] {
476
+ const { provider, modelId } = parseModelRef(modelRef);
477
477
  const available = ctx.modelRegistry.getAvailable();
478
478
  const registeredModel = provider
479
479
  ? ctx.modelRegistry.find(provider, modelId)
@@ -492,10 +492,23 @@ export async function resolveReviewerMeta(
492
492
  if (!model) {
493
493
  throw new Error(
494
494
  provider
495
- ? `provider ${provider} is unavailable for custom model ${config.model}`
496
- : `model ${config.model} is unavailable`,
495
+ ? `provider ${provider} is unavailable for custom model ${modelRef}`
496
+ : `model ${modelRef} is unavailable`,
497
497
  );
498
498
  }
499
+ return model;
500
+ }
501
+
502
+ export async function resolveReviewerMeta(
503
+ ctx: ExtensionContext,
504
+ config: Config,
505
+ ): Promise<ReviewerMeta> {
506
+ // Pi exposes model as a live getter, including on the retained session
507
+ // context. Read it once per review and keep retries on that same model.
508
+ const model = config.model === "current"
509
+ ? ctx.model
510
+ : resolveConfiguredModel(ctx, config.model);
511
+ if (!model) throw new Error("current Pi session model is unavailable");
499
512
 
500
513
  const registered = (
501
514
  ctx.modelRegistry as ExtensionContext["modelRegistry"] & {
@@ -513,7 +526,7 @@ export async function resolveReviewerMeta(
513
526
  return {
514
527
  model,
515
528
  streamSimple:
516
- registered?.api === model.api ? registered.streamSimple : undefined,
529
+ registered && registered.api === model.api ? registered.streamSimple : undefined,
517
530
  };
518
531
  }
519
532
 
@@ -572,6 +585,8 @@ export async function modelCall(
572
585
  metadata.retryAfterMs = parseRetryAfterMs(response.headers);
573
586
  },
574
587
  };
588
+ // Call the provider directly with no tools, outside Pi's agent/tool loop,
589
+ // so using the session model cannot re-enter permission review.
575
590
  // 0.86 moved the provider-facing request to a normalized transcript: the
576
591
  // system prompt and tool declarations now travel as the transcript's leading
577
592
  // system message. Providers registered by extensions receive that shape, so
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Shell-text helpers for the deterministic hard-deny scan.
3
+ *
4
+ * `@gotgenes/pi-permission-system` decides one *command unit* at a time, so a
5
+ * wrapper such as `bash -c "…"`, `sudo …`, or `python3 - <<'PY'` reports only
6
+ * the wrapper as the gated command while the payload carries the program that
7
+ * actually runs. The reviewer and the deterministic rules need a bounded view
8
+ * of that real execution text.
9
+ *
10
+ * `structuralScanText` builds the "skeleton" used by structural rules
11
+ * (destructive deletion, transport weakening): quoted literals that merely
12
+ * *mention* a dangerous command are inert data and are masked, while shell
13
+ * expansions (`$HOME`, `$(…)`, backticks) and target-significant characters
14
+ * (`/`, `*`, `~`) are preserved because a quoted value can still be the target
15
+ * of a real command. Literal placeholders keep named paths from collapsing
16
+ * into root/home targets. Nested shell payloads are scanned the same way;
17
+ * other interpreter payloads stay raw because shell quoting rules cannot
18
+ * establish which of their strings execute.
19
+ * Nested payloads are generated lazily and share count and byte budgets. If
20
+ * either budget is exhausted, retain the original text for hard denies and
21
+ * require human review instead of treating the partial expansion as safe.
22
+ *
23
+ * Fail direction: an unterminated quote, an unparsable payload, or a heredoc
24
+ * we cannot delimit all fall back to scanning the original text, which can
25
+ * only add denials.
26
+ */
27
+
28
+ /** Reviewer-visible bound for one command string. */
29
+ export const MAX_REVIEWER_COMMAND_BYTES = 10_240;
30
+
31
+ /** Shared by all nested payloads of one command, across both recursion levels. */
32
+ export const MAX_NESTED_PAYLOAD_COUNT = 32;
33
+ export const MAX_NESTED_PAYLOAD_BYTES = 32_768;
34
+
35
+ const TARGET_CHARACTERS = "/*~";
36
+
37
+ /** Truncate to a UTF-8 byte budget without splitting a code point. */
38
+ export function truncateUtf8(value: string, maxBytes: number): string {
39
+ if (maxBytes <= 0) return "";
40
+ let bytes = 0;
41
+ let result = "";
42
+ for (const character of value) {
43
+ const size = Buffer.byteLength(character, "utf8");
44
+ if (bytes + size > maxBytes) break;
45
+ result += character;
46
+ bytes += size;
47
+ }
48
+ return result;
49
+ }
50
+
51
+ type QuotedBody = { body: string; end: number };
52
+
53
+ function readPlainQuote(
54
+ text: string,
55
+ start: number,
56
+ quote: "'" | '"',
57
+ ): QuotedBody | undefined {
58
+ let index = start + 1;
59
+ let body = "";
60
+ while (index < text.length) {
61
+ const character = text[index]!;
62
+ if (quote === '"' && character === "\\") {
63
+ const next = text[index + 1];
64
+ if (next === undefined) return undefined;
65
+ body += text.slice(index, index + 2);
66
+ index += 2;
67
+ continue;
68
+ }
69
+ if (character === quote) return { body, end: index + 1 };
70
+ body += character;
71
+ index++;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function readAnsiCQuote(text: string, start: number): QuotedBody | undefined {
77
+ // `start` points at the `$` of `$'…'`.
78
+ let index = start + 2;
79
+ let body = "";
80
+ while (index < text.length) {
81
+ const character = text[index]!;
82
+ if (character === "\\") {
83
+ const next = text[index + 1];
84
+ if (next === undefined) return undefined;
85
+ body += text.slice(index, index + 2);
86
+ index += 2;
87
+ continue;
88
+ }
89
+ if (character === "'") return { body, end: index + 1 };
90
+ body += character;
91
+ index++;
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function readExpansion(text: string, start: number): string | undefined {
97
+ const next = text[start + 1];
98
+ if (next === undefined) return undefined;
99
+ if (next === "{") {
100
+ const close = text.indexOf("}", start + 2);
101
+ return close < 0 ? undefined : text.slice(start, close + 1);
102
+ }
103
+ if (next === "(") {
104
+ let depth = 1;
105
+ let index = start + 2;
106
+ while (index < text.length) {
107
+ const character = text[index]!;
108
+ if (character === "(") depth++;
109
+ else if (character === ")") {
110
+ depth--;
111
+ if (depth === 0) return text.slice(start, index + 1);
112
+ }
113
+ index++;
114
+ }
115
+ return undefined;
116
+ }
117
+ if (/[A-Za-z_]/.test(next)) {
118
+ const match = /^\$[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(start));
119
+ return match?.[0];
120
+ }
121
+ if (/[0-9@*#?$!\-]/.test(next)) return text.slice(start, start + 2);
122
+ return undefined;
123
+ }
124
+
125
+ /** Mask literal text without erasing components of a path or shell word. */
126
+ function dynamicQuotedBody(body: string): string {
127
+ let result = "";
128
+ let index = 0;
129
+ while (index < body.length) {
130
+ const character = body[index]!;
131
+ if (character === "\\") {
132
+ // An escaped expansion/quote is literal text, but still part of the word.
133
+ result += "x";
134
+ index += 2;
135
+ continue;
136
+ }
137
+ if (character === "`") {
138
+ const close = body.indexOf("`", index + 1);
139
+ if (close < 0) {
140
+ result += body.slice(index);
141
+ break;
142
+ }
143
+ result += body.slice(index, close + 1);
144
+ index = close + 1;
145
+ continue;
146
+ }
147
+ if (character === "$") {
148
+ const expansion = readExpansion(body, index);
149
+ if (expansion) {
150
+ result += expansion;
151
+ index += expansion.length;
152
+ continue;
153
+ }
154
+ result += "x";
155
+ index++;
156
+ continue;
157
+ }
158
+ result += TARGET_CHARACTERS.includes(character) ? character : "x";
159
+ index++;
160
+ }
161
+ return result;
162
+ }
163
+
164
+ function literalQuotedBody(body: string): string {
165
+ let result = "";
166
+ for (const character of body) {
167
+ result += TARGET_CHARACTERS.includes(character) ? character : "x";
168
+ }
169
+ return result;
170
+ }
171
+
172
+ function readHeredoc(
173
+ text: string,
174
+ start: number,
175
+ ): { end: number } | undefined {
176
+ if (text.startsWith("<<<", start)) return undefined;
177
+ const match = /^<<(-?)(['"]?)([A-Za-z_][A-Za-z0-9_]*)\2/.exec(
178
+ text.slice(start),
179
+ );
180
+ if (!match) return undefined;
181
+ const stripTabs = match[1] === "-";
182
+ const delimiter = match[3]!;
183
+ const headerEnd = start + match[0].length;
184
+ let lineEnd = text.indexOf("\n", headerEnd);
185
+ if (lineEnd < 0) return { end: text.length };
186
+ let position = lineEnd + 1;
187
+ while (position <= text.length) {
188
+ const next = text.indexOf("\n", position);
189
+ const line = text.slice(
190
+ position,
191
+ next < 0 ? text.length : next,
192
+ );
193
+ const compare = stripTabs ? line.replace(/^\t+/, "") : line;
194
+ if (compare === delimiter) {
195
+ return { end: next < 0 ? text.length : next };
196
+ }
197
+ if (next < 0) return { end: text.length };
198
+ position = next + 1;
199
+ }
200
+ return { end: text.length };
201
+ }
202
+
203
+ /**
204
+ * Replace quoted literal text with its structural skeleton.
205
+ *
206
+ * Quoted literals are inert data for structural rules, so their text is
207
+ * masked — except shell expansions and path/glob/tilde characters, which can
208
+ * still be the target of a real command. Masking must not erase named path
209
+ * components: `"./dist"` must never become `"/"`. Heredoc bodies and command
210
+ * substitutions are preserved verbatim. An unterminated quote returns the
211
+ * original text unchanged (fail-closed).
212
+ */
213
+ export function stripQuotedLiterals(text: string): string {
214
+ let result = "";
215
+ let index = 0;
216
+ while (index < text.length) {
217
+ const character = text[index]!;
218
+ if (character === "'" || character === '"') {
219
+ const quoted = readPlainQuote(text, index, character);
220
+ if (!quoted) return text;
221
+ result +=
222
+ character +
223
+ (character === "'"
224
+ ? literalQuotedBody(quoted.body)
225
+ : dynamicQuotedBody(quoted.body)) +
226
+ character;
227
+ index = quoted.end;
228
+ continue;
229
+ }
230
+ if (character === "$" && text[index + 1] === "'") {
231
+ const quoted = readAnsiCQuote(text, index);
232
+ if (!quoted) return text;
233
+ result += `$'${literalQuotedBody(quoted.body)}'`;
234
+ index = quoted.end;
235
+ continue;
236
+ }
237
+ if (character === "`") {
238
+ const close = text.indexOf("`", index + 1);
239
+ if (close < 0) return text;
240
+ result += text.slice(index, close + 1);
241
+ index = close + 1;
242
+ continue;
243
+ }
244
+ if (character === "<" && text[index + 1] === "<") {
245
+ const heredoc = readHeredoc(text, index);
246
+ if (heredoc) {
247
+ result += text.slice(index, heredoc.end);
248
+ index = heredoc.end;
249
+ continue;
250
+ }
251
+ }
252
+ result += character;
253
+ index++;
254
+ }
255
+ return result;
256
+ }
257
+
258
+ type ShellToken = {
259
+ value: string;
260
+ start: number;
261
+ end: number;
262
+ quoted: boolean;
263
+ };
264
+
265
+ const WORD_SEPARATORS = /[\s;&|()<>`]/;
266
+
267
+ function tokenizeShell(text: string): ShellToken[] {
268
+ const tokens: ShellToken[] = [];
269
+ let index = 0;
270
+ while (index < text.length) {
271
+ const character = text[index]!;
272
+ if (/\s/.test(character)) {
273
+ index++;
274
+ continue;
275
+ }
276
+ if (character === "#") {
277
+ const newline = text.indexOf("\n", index);
278
+ index = newline < 0 ? text.length : newline;
279
+ continue;
280
+ }
281
+ if (character === "'" || character === '"') {
282
+ const quoted = readPlainQuote(text, index, character);
283
+ if (!quoted) {
284
+ tokens.push({
285
+ value: text.slice(index + 1),
286
+ start: index,
287
+ end: text.length,
288
+ quoted: true,
289
+ });
290
+ break;
291
+ }
292
+ tokens.push({
293
+ value: quoted.body,
294
+ start: index,
295
+ end: quoted.end,
296
+ quoted: true,
297
+ });
298
+ index = quoted.end;
299
+ continue;
300
+ }
301
+ if (character === "$" && text[index + 1] === "'") {
302
+ const quoted = readAnsiCQuote(text, index);
303
+ if (!quoted) break;
304
+ tokens.push({
305
+ value: quoted.body,
306
+ start: index,
307
+ end: quoted.end,
308
+ quoted: true,
309
+ });
310
+ index = quoted.end;
311
+ continue;
312
+ }
313
+ let end = index;
314
+ while (end < text.length && !WORD_SEPARATORS.test(text[end]!)) end++;
315
+ if (end === index) {
316
+ index++;
317
+ continue;
318
+ }
319
+ tokens.push({
320
+ value: text.slice(index, end),
321
+ start: index,
322
+ end,
323
+ quoted: false,
324
+ });
325
+ index = end;
326
+ }
327
+ return tokens;
328
+ }
329
+
330
+ function commandEnd(text: string, start: number): number {
331
+ const match = /[;&|\n]/.exec(text.slice(start));
332
+ return match ? start + (match.index ?? 0) : text.length;
333
+ }
334
+
335
+ function baseName(value: string): string {
336
+ const slash = value.lastIndexOf("/");
337
+ return slash < 0 ? value : value.slice(slash + 1);
338
+ }
339
+
340
+ const SHELL_INTERPRETERS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
341
+ const SHORT_COMMAND_OPTION = /^-[A-Za-z]*c[A-Za-z]*$/;
342
+
343
+ type InterpreterPayload = { text: string; kind: "shell" | "opaque" };
344
+
345
+ // Shell options that consume a separate operand before a later -c option.
346
+ const SHELL_VALUE_OPTIONS = new Set([
347
+ "-o", "+o", "-O", "+O", "--rcfile", "--init-file",
348
+ ]);
349
+
350
+ function nextCommandToken(
351
+ text: string,
352
+ tokens: ShellToken[],
353
+ index: number,
354
+ ): ShellToken | undefined {
355
+ const current = tokens[index];
356
+ const next = tokens[index + 1];
357
+ if (!current || !next) return undefined;
358
+ // The tokenizer skips operators. Never treat a token in a subsequent
359
+ // command as this interpreter's option or payload.
360
+ return /[;&|\n()<>`]/.test(text.slice(current.end, next.start))
361
+ ? undefined
362
+ : next;
363
+ }
364
+
365
+ function shellPayloadToken(
366
+ text: string,
367
+ tokens: ShellToken[],
368
+ interpreterIndex: number,
369
+ ): ShellToken | undefined {
370
+ for (let index = interpreterIndex; index < tokens.length; index++) {
371
+ const option = nextCommandToken(text, tokens, index);
372
+ if (!option) return undefined;
373
+ if (
374
+ SHORT_COMMAND_OPTION.test(option.value) || option.value === "--command"
375
+ ) {
376
+ return nextCommandToken(text, tokens, index + 1);
377
+ }
378
+ // Stop at a script operand or the end-of-options marker.
379
+ if (option.value === "--" || !/^[-+][A-Za-z-]/.test(option.value)) {
380
+ return undefined;
381
+ }
382
+ if (SHELL_VALUE_OPTIONS.has(option.value)) {
383
+ if (!nextCommandToken(text, tokens, index + 1)) return undefined;
384
+ index++;
385
+ }
386
+ }
387
+ return undefined;
388
+ }
389
+
390
+ function* directPayloads(text: string): Generator<InterpreterPayload> {
391
+ const tokens = tokenizeShell(text);
392
+ const payloadValue = (token: ShellToken | undefined): string | undefined => {
393
+ if (!token) return undefined;
394
+ return token.quoted
395
+ ? token.value
396
+ : text.slice(token.start, commandEnd(text, token.start));
397
+ };
398
+ for (let index = 0; index < tokens.length; index++) {
399
+ const token = tokens[index]!;
400
+ const name = baseName(token.value);
401
+ let payload: string | undefined;
402
+ let kind: InterpreterPayload["kind"] = "opaque";
403
+ if (SHELL_INTERPRETERS.has(name)) {
404
+ payload = payloadValue(shellPayloadToken(text, tokens, index));
405
+ kind = "shell";
406
+ } else if (name === "eval") {
407
+ payload = payloadValue(tokens[index + 1]);
408
+ kind = "shell";
409
+ } else if (/^python[0-9.]*$/.test(name)) {
410
+ if (tokens[index + 1]?.value === "-c") {
411
+ payload = payloadValue(tokens[index + 2]);
412
+ }
413
+ } else if (name === "perl" || name === "ruby") {
414
+ if (tokens[index + 1]?.value === "-e") {
415
+ payload = payloadValue(tokens[index + 2]);
416
+ }
417
+ } else if (name === "node") {
418
+ const option = tokens[index + 1]?.value;
419
+ if (option === "-e" || option === "--eval") {
420
+ payload = payloadValue(tokens[index + 2]);
421
+ }
422
+ }
423
+ // Yield lazily: eagerly collecting all suffixes of `eval eval ...` would
424
+ // already allocate quadratic data before the caller can enforce a budget.
425
+ if (payload && payload.trim()) yield { text: payload, kind };
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Nested interpreter payloads within the shared count/byte budget, to a depth
431
+ * of two. Extraction understands `bash|sh|zsh|dash|ksh -c`, `eval`, `python* -c`,
432
+ * `perl -e`, `node -e`, and `ruby -e`. Safety callers must also check
433
+ * `shellExpansionLimitExceeded`; a bounded prefix is not a complete program.
434
+ */
435
+ export function nestedShellPayloads(text: string): string[] {
436
+ return nestedInterpreterPayloads(text).payloads.map((payload) => payload.text);
437
+ }
438
+
439
+ function nestedInterpreterPayloads(text: string): {
440
+ payloads: InterpreterPayload[];
441
+ limitExceeded: boolean;
442
+ } {
443
+ const payloads: InterpreterPayload[] = [];
444
+ let payloadBytes = 0;
445
+ let limitExceeded = false;
446
+ const visit = (value: string, depth: number): void => {
447
+ if (depth <= 0 || limitExceeded) return;
448
+ for (const payload of directPayloads(value)) {
449
+ const remainingBytes = MAX_NESTED_PAYLOAD_BYTES - payloadBytes;
450
+ // UTF-8 is at least as long as UTF-16 in code units. Reject an obviously
451
+ // oversized suffix before measuring/flattening its entire string.
452
+ if (
453
+ payloads.length >= MAX_NESTED_PAYLOAD_COUNT ||
454
+ payload.text.length > remainingBytes
455
+ ) {
456
+ limitExceeded = true;
457
+ return;
458
+ }
459
+ const bytes = Buffer.byteLength(payload.text, "utf8");
460
+ if (bytes > remainingBytes) {
461
+ limitExceeded = true;
462
+ return;
463
+ }
464
+ payloadBytes += bytes;
465
+ payloads.push(payload);
466
+ if (payload.kind === "shell") visit(payload.text, depth - 1);
467
+ if (limitExceeded) return;
468
+ }
469
+ };
470
+ visit(text, 2);
471
+ return { payloads, limitExceeded };
472
+ }
473
+
474
+ /** Whether nested execution could not be fully expanded within its budget. */
475
+ export function shellExpansionLimitExceeded(text: string): boolean {
476
+ return nestedInterpreterPayloads(text).limitExceeded;
477
+ }
478
+
479
+ /**
480
+ * Structural scan text: the literal-masked skeleton of `text` plus the
481
+ * skeleton of nested shell payloads. Other languages remain raw: a string
482
+ * passed to subprocess.run/execSync/system is not an inert shell literal.
483
+ * On budget exhaustion retain the complete original text for hard denies;
484
+ * callers must defer if that scan finds no deny, rather than trusting the
485
+ * partially expanded program. Extra payload text never exceeds the budget.
486
+ */
487
+ export function structuralScanText(text: string): string {
488
+ const { payloads, limitExceeded } = nestedInterpreterPayloads(text);
489
+ const parts = [limitExceeded ? text : stripQuotedLiterals(text)];
490
+ for (const payload of payloads) {
491
+ parts.push(
492
+ payload.kind === "shell" ? stripQuotedLiterals(payload.text) : payload.text,
493
+ );
494
+ }
495
+ return parts.join("\n");
496
+ }
@@ -17,6 +17,7 @@ export type ReasoningLevel =
17
17
  export type BoundedSurface = "external_directory" | "path";
18
18
 
19
19
  export type Config = {
20
+ /** Model id, provider/model, or "current" for the live Pi session model. */
20
21
  model: string;
21
22
  reasoning: ReasoningLevel;
22
23
  timeoutMs: number;