@hank-warren/pi-auto-permissions 0.16.2 → 0.18.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/index.ts CHANGED
@@ -1,13 +1,6 @@
1
- import { detectLoopContext } from "./loop-context.js";
2
- import { createReviewQueue } from "./review-queue.js";
3
- import {
4
- LoopReviseBudget,
5
- loopBlockReason,
6
- type LoopReviseCharge,
7
- } from "./loop-revise-budget.js";
1
+ import { createReviewLimiter, createReviewQueue } from "./review-queue.js";
8
2
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
9
3
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
- import { Type } from "typebox";
11
4
  import { readFileSync } from "node:fs";
12
5
  import { join } from "node:path";
13
6
  import { loadAutoPermissionsConfig, type AutoPermissionsConfig } from "./config.js";
@@ -22,12 +15,11 @@ import {
22
15
  classifyPromptChoice,
23
16
  expectedDecisionForChoice,
24
17
  permissionPromptOptions,
25
- shouldOfferStandingApproval,
26
18
  type PromptEvaluationUserChoice,
27
19
  } from "./evaluation-log.js";
28
20
  import type { Gate } from "./gates.js";
29
- import { classifyCommand, untrustedMatches } from "./classify.js";
30
- import type { ReviewEvidenceRecord } from "./review.js";
21
+ import { classifyCommand } from "./classify.js";
22
+ import type { PermissionVerdict, ReviewEvidenceRecord } from "./review.js";
31
23
  import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
32
24
  import { createReviewDisplay } from "./review-display.js";
33
25
  import { createSessionOverrides } from "./session-overrides.js";
@@ -75,14 +67,103 @@ function loadTrustedGroups(cwd: string): Set<string> {
75
67
  }
76
68
 
77
69
  function denyReason(gate: Gate): string {
78
- return `Blocked by policy: ${gate.label}\n\n${gate.message ?? "This operation is denied by rule."}\n\nThis is a deny rule: it cannot be overridden with request_override, trusted groups, or user approval. Choose a different approach.`;
70
+ return `Blocked by policy: ${gate.label}\n\n${gate.message ?? "This operation is denied by rule."}\n\nThis is a deny rule: it cannot be overridden by trusted groups or user approval. Choose a different approach.`;
71
+ }
72
+
73
+ function reviewCancelledResult(): BlockResult {
74
+ return { block: true, reason: "Auto Permissions review cancelled" };
75
+ }
76
+
77
+ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal): AbortSignal {
78
+ return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
79
+ }
80
+
81
+ /**
82
+ * Settle with `promise`, or reject as soon as `signal` aborts. The promise is
83
+ * left to finish on its own; its outcome is then ignored.
84
+ */
85
+ function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
86
+ if (signal.aborted) {
87
+ promise.catch(() => undefined);
88
+ return Promise.reject(signal.reason);
89
+ }
90
+ return new Promise<T>((resolve, reject) => {
91
+ const onAbort = () => reject(signal.reason);
92
+ signal.addEventListener("abort", onAbort, { once: true });
93
+ promise.then(
94
+ (value) => {
95
+ signal.removeEventListener("abort", onAbort);
96
+ resolve(value);
97
+ },
98
+ (error) => {
99
+ signal.removeEventListener("abort", onAbort);
100
+ reject(error);
101
+ },
102
+ );
103
+ });
104
+ }
105
+
106
+ /** Aborts when the turn ends or, for a codemode call, when its script does. */
107
+ function callSignalOf(scope: ReviewScope): AbortSignal | undefined {
108
+ const { ctx, callSignal } = scope;
109
+ if (!callSignal) return ctx.signal;
110
+ return ctx.signal ? AbortSignal.any([ctx.signal, callSignal]) : callSignal;
111
+ }
112
+
113
+ /** How one guardian review ended, before its verdict is applied. */
114
+ type ReviewAttempt =
115
+ | { kind: "verdict"; verdict: PermissionVerdict; evidenceKeys: string[] }
116
+ | { kind: "failed"; reason: string; evidenceKeys: string[] }
117
+ | { kind: "cancelled" };
118
+
119
+ /**
120
+ * A verdict whose evidence changed while it waited (the user answered another
121
+ * command's prompt, a tool result landed) is reviewed again before it is
122
+ * applied. Bounded so evidence that never settles cannot loop forever: the
123
+ * last review runs holding the decision slot, so no other prompt can be
124
+ * answered while it runs, which is the staleness window a lone review has
125
+ * always had.
126
+ */
127
+ const MAX_REVIEWS_PER_COMMAND = 3;
128
+
129
+ /** How many finished call ids are remembered for calls a script left behind. */
130
+ const MAX_FINISHED_CALLS = 1024;
131
+
132
+ /** Abort reason for an early review replaced by a fresh one for the same call. */
133
+ const SUPERSEDED_REVIEW = "auto-permissions: superseded review";
134
+
135
+ /** A review started early for a later bash call in the same assistant message. */
136
+ interface SiblingReview {
137
+ scope: ReviewScope;
138
+ input: string;
139
+ gateLabel: string;
140
+ attempt: Promise<ReviewAttempt>;
141
+ controller: AbortController;
142
+ }
143
+
144
+ interface AssistantToolCall {
145
+ id: string;
146
+ name: string;
147
+ arguments: unknown;
79
148
  }
80
149
 
81
- function conventionReason(gate: Gate, command: string): string {
82
- let reason = `Convention violation: ${gate.label}\n\n${gate.message ?? "Use the configured project tooling."}`;
83
- const suggestion = gate.suggest?.(command);
84
- if (suggestion && suggestion !== command) reason += `\n\nSuggested command:\n ${suggestion}`;
85
- return `${reason}\n\nIf this is a legitimate edge case, explain why and call \`request_override\` with the exact command.`;
150
+ function isBashTool(name: string): boolean {
151
+ return name === "bash" || name.endsWith(".bash");
152
+ }
153
+
154
+ function sameKeys(a: readonly string[], b: readonly string[]): boolean {
155
+ return a.length === b.length && a.every((key, index) => key === b[index]);
156
+ }
157
+
158
+ function assistantToolCalls(message: unknown): AssistantToolCall[] {
159
+ const candidate = message as { role?: unknown; content?: unknown } | undefined;
160
+ if (candidate?.role !== "assistant" || !Array.isArray(candidate.content)) return [];
161
+ return candidate.content.flatMap((block) => {
162
+ const call = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown } | undefined;
163
+ return call?.type === "toolCall" && typeof call.id === "string" && typeof call.name === "string"
164
+ ? [{ id: call.id, name: call.name, arguments: call.arguments }]
165
+ : [];
166
+ });
86
167
  }
87
168
 
88
169
  export default function autoPermissionsExtension(pi: ExtensionAPI) {
@@ -90,21 +171,66 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
90
171
  let trustedGroups = new Set<string>();
91
172
  let lastConfigError: string | undefined;
92
173
  let lastEvaluationLogError: string | undefined;
93
- let sessionActive = true;
174
+ // Guardian calls run in parallel up to config.reviewConcurrency; applying
175
+ // verdicts and prompting the user stay one command at a time.
176
+ const reviewSlots = createReviewLimiter();
177
+ const decisionQueue = createReviewQueue();
178
+ // Pi runs tool_call handlers for one assistant message's calls one after
179
+ // another, so the later bash calls' reviews are started early from here.
180
+ let lastAssistantCalls: AssistantToolCall[] = [];
181
+ const siblingReviews = new Map<string, SiblingReview>();
182
+ // One controller per tool call (a codemode script) that has made bash calls,
183
+ // aborted when Pi reports that call finished: its pending calls can no
184
+ // longer run, so their reviews and prompts are released.
185
+ const scriptsEnded = new Map<string, AbortController>();
186
+ // Calls that already finished, newest last. A script can end before Pi runs
187
+ // the tool_call hook of a call it fired without awaiting, and with sequential
188
+ // tool execution that hook can run after the turn is over, so this outlives
189
+ // the turn. Bounded: only a recently finished call can still have one pending.
190
+ const finishedCalls = new Set<string>();
191
+ // Nested call id → the model-issued call it descends from, so a call made
192
+ // several levels down still names the script whose SCRIPT record it shares.
193
+ const scriptRoots = new Map<string, string>();
194
+
195
+ function rememberFinished(toolCallId: string): void {
196
+ finishedCalls.delete(toolCallId);
197
+ finishedCalls.add(toolCallId);
198
+ if (finishedCalls.size > MAX_FINISHED_CALLS) {
199
+ finishedCalls.delete(finishedCalls.values().next().value!);
200
+ }
201
+ }
202
+
203
+ function callEndedSignal(toolCallId: string): AbortSignal {
204
+ if (finishedCalls.has(toolCallId)) return AbortSignal.abort();
205
+ let controller = scriptsEnded.get(toolCallId);
206
+ if (!controller) {
207
+ controller = new AbortController();
208
+ scriptsEnded.set(toolCallId, controller);
209
+ }
210
+ return controller.signal;
211
+ }
212
+
94
213
  /**
95
- * Revision rounds spent per guardian concern while a loop is active.
96
- *
97
- * Session-scoped and held here rather than in the review closure, so a
98
- * context compaction (which rewrites the conversation, not this process)
99
- * cannot reset it. Restored from the session branch at session_start so a
100
- * resume cannot reset it either.
214
+ * Aborts when the call that made this one ends, or the script at the top of
215
+ * the chain does: a tool in between may keep awaiting this call after its
216
+ * script has gone.
101
217
  */
102
- const loopReviseBudget = new LoopReviseBudget();
103
- /** The loop these counts belong to, so a new loop starts with a clean budget. */
104
- let loopReviseBudgetLoopId: string | undefined;
105
- const guardianReviewQueue = createReviewQueue();
106
- const display = createReviewDisplay(pi, { isSessionActive: () => sessionActive });
107
- const reviewer = createGuardianReviewer({ isSessionActive: () => sessionActive, overrides });
218
+ function scriptEndedSignal(parentToolCallId: string, scriptToolCallId: string): AbortSignal {
219
+ const parent = callEndedSignal(parentToolCallId);
220
+ return scriptToolCallId === parentToolCallId
221
+ ? parent
222
+ : AbortSignal.any([parent, callEndedSignal(scriptToolCallId)]);
223
+ }
224
+
225
+ function endScripts(): void {
226
+ for (const controller of scriptsEnded.values()) controller.abort();
227
+ scriptsEnded.clear();
228
+ }
229
+ // The session is active exactly while the reviewer lifecycle is unaborted:
230
+ // session_shutdown aborts it, session_start synchronously aborts and replaces
231
+ // it, and nothing else touches it.
232
+ const reviewer = createGuardianReviewer({ overrides });
233
+ const display = createReviewDisplay({ isSessionActive: () => !reviewer.lifecycleSignal.aborted });
108
234
 
109
235
  /**
110
236
  * Record a non-approved outcome: a `pi.events` emit (the PermissionDenied
@@ -182,13 +308,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
182
308
  * if that package is installed. Say so once per session instead of letting the
183
309
  * first guarded command fail with a bare "review model not found".
184
310
  */
185
- function warnAboutMissingReviewerProvider(ctx: ExtensionContext): void {
186
- let provider: string | undefined;
187
- try {
188
- provider = currentConfig(ctx).reviewer?.provider;
189
- } catch {
190
- return; // The config error was already reported by currentConfig().
191
- }
311
+ function warnAboutMissingReviewerProvider(ctx: ExtensionContext, config: AutoPermissionsConfig): void {
312
+ const provider = config.reviewer?.provider;
192
313
  if (!provider || ctx.modelRegistry.getProvider(provider)) return;
193
314
 
194
315
  const message =
@@ -199,7 +320,24 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
199
320
  }
200
321
 
201
322
  function reviewCancelled(signal: AbortSignal | undefined): boolean {
202
- return !sessionActive || signal?.aborted === true;
323
+ return signal?.aborted === true;
324
+ }
325
+
326
+ /**
327
+ * A cancelled turn resets the reviewer lineage. A codemode script ending
328
+ * does not: the reviewer conversation is intact, and dropping it would make
329
+ * the next reviews start cold.
330
+ */
331
+ function discardLineageIfTurnCancelled(scope: ReviewScope): void {
332
+ if (reviewCancelled(scope.ctx.signal)) reviewer.discardLineage();
333
+ }
334
+
335
+ function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
336
+ if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
337
+ if (!reviewCancelled(callSignalOf(scope))) return undefined;
338
+ discardLineageIfTurnCancelled(scope);
339
+ display.clear(scope);
340
+ return reviewCancelledResult();
203
341
  }
204
342
 
205
343
  function logPromptEvaluation(
@@ -243,33 +381,6 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
243
381
  }
244
382
  }
245
383
 
246
- /**
247
- * Charge one revision round against a guardian concern and persist the
248
- * budget. A loop id different from the one the counts belong to means a new
249
- * loop: it inherits nothing from the last one's arguments with the guardian.
250
- */
251
- function chargeLoopRevision(
252
- loopId: string | undefined,
253
- gateLabel: string,
254
- reason: string,
255
- ): LoopReviseCharge {
256
- if (loopReviseBudgetLoopId !== loopId) {
257
- loopReviseBudget.clear();
258
- loopReviseBudgetLoopId = loopId;
259
- }
260
- const charge = loopReviseBudget.charge(gateLabel, reason);
261
- try {
262
- pi.appendEntry(LoopReviseBudget.entryType, {
263
- ...loopReviseBudget.snapshot(),
264
- ...(loopId ? { loopId } : {}),
265
- });
266
- } catch {
267
- // Persistence is what survives a restore; the in-memory count is what
268
- // survives a compaction. Losing the former never widens the bound now.
269
- }
270
- return charge;
271
- }
272
-
273
384
  async function askUser(
274
385
  scope: ReviewScope,
275
386
  detail: string,
@@ -277,12 +388,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
277
388
  decisionSource: "guardian" | "review_failure",
278
389
  ): Promise<BlockResult | undefined> {
279
390
  const { ctx, config, gate, command } = scope;
280
- const signal = ctx.signal;
391
+ const signal = callSignalOf(scope);
281
392
  const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
282
- const promptSignal = signal ? AbortSignal.any([signal, lifecycleSignal]) : lifecycleSignal;
283
- const cancelled = (): BlockResult => ({ block: true, reason: "Auto Permissions review cancelled" });
393
+ const promptSignal = withLifecycle(signal, lifecycleSignal);
394
+ const cancelled = reviewCancelledResult;
284
395
  if (lifecycleStale() || reviewCancelled(signal)) {
285
- if (!lifecycleStale()) reviewer.discardLineage();
396
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
286
397
  return cancelled();
287
398
  }
288
399
  display.show(scope, "ask_user", detail);
@@ -290,7 +401,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
290
401
  return settle(scope, {
291
402
  display: "blocked",
292
403
  verdict: "block",
293
- source: decisionSource === "guardian" ? "guardian" : "review_failure",
404
+ source: decisionSource,
294
405
  reason: detail,
295
406
  block: `${gate.label} requires user approval: ${detail}\nThis session has no interactive user to ask. Prefer an approach that avoids the gated operation, or report this blocker in your final output instead of retrying the same command.`,
296
407
  });
@@ -305,32 +416,24 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
305
416
  pi,
306
417
  ctx,
307
418
  `${gate.label} — Auto Permissions needs approval\n\n${detail}\n\n${command}`,
308
- permissionPromptOptions(
309
- config.evaluationLog.enabled,
310
- shouldOfferStandingApproval(decisionSource, config.standingApprovals.enabled),
311
- ),
419
+ permissionPromptOptions(config.evaluationLog.enabled),
312
420
  promptSignal,
313
- { allowComment: true },
314
421
  );
315
422
  } catch (error) {
316
423
  if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
317
- if (!lifecycleStale()) reviewer.discardLineage();
318
- return cancelled();
319
- }
320
- if (lifecycleStale()) return cancelled();
321
- if (reviewCancelled(signal)) {
322
- reviewer.discardLineage();
323
- if (sessionActive) display.clear(scope);
424
+ if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
324
425
  return cancelled();
325
426
  }
427
+ const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
428
+ if (cancelledResult) return cancelledResult;
326
429
  const classification = classifyPromptChoice(choice);
327
430
  // Feed the user's decision back to the guardian as session-scoped
328
431
  // user-source evidence. review_failure prompts are excluded: their
329
432
  // "concern" is an infrastructure error, not a guardian judgment.
330
433
  if (decisionSource === "guardian" && classification) {
331
- overrides.recordPromptDecision(scope, classification, detail, reviewer.lastEvidenceKey);
434
+ overrides.recordPromptDecision(gate, command, classification, detail, reviewer.lastEvidenceKey);
332
435
  }
333
- if (config.evaluationLog.enabled && classification?.userChoice) {
436
+ if (classification?.userChoice) {
334
437
  logPromptEvaluation(scope, detail, evaluationContext, decisionSource, classification.userChoice);
335
438
  }
336
439
  if (classification?.allowsExecution) {
@@ -349,11 +452,261 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
349
452
  }
350
453
  }
351
454
 
455
+ function evidenceKeys(scope: ReviewScope): string[] {
456
+ return reviewer.collectEvidence(scope).map((record) => record.key);
457
+ }
458
+
459
+ /**
460
+ * One guardian review, run as soon as a review slot is free. Never applies
461
+ * the verdict: that waits for the decision slot in `decide`.
462
+ *
463
+ * `abandon` is set for a sibling review started early. Aborting it drops the
464
+ * verdict; its row is cleared too, unless the abort reason is
465
+ * `SUPERSEDED_REVIEW`, meaning a fresh review for the same call owns the row.
466
+ */
467
+ async function runReview(
468
+ scope: ReviewScope,
469
+ input: Record<string, unknown>,
470
+ lifecycleSignal: AbortSignal,
471
+ abandon?: AbortSignal,
472
+ ): Promise<ReviewAttempt> {
473
+ const { ctx, config } = scope;
474
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
475
+ const abandoned = () => abandon?.aborted === true;
476
+ const drop = (): ReviewAttempt => {
477
+ if (!lifecycleStale() && abandon?.reason !== SUPERSEDED_REVIEW) display.clear(scope);
478
+ return { kind: "cancelled" };
479
+ };
480
+ const callSignal = callSignalOf(scope);
481
+ const slotSignal = AbortSignal.any([
482
+ lifecycleSignal,
483
+ ...(callSignal ? [callSignal] : []),
484
+ ...(abandon ? [abandon] : []),
485
+ ]);
486
+ if (reviewSlots.wouldWait(config.reviewConcurrency)) display.show(scope, "queued");
487
+ let releaseSlot: () => void;
488
+ try {
489
+ releaseSlot = await reviewSlots.acquire(config.reviewConcurrency, slotSignal);
490
+ } catch {
491
+ return drop();
492
+ }
493
+ try {
494
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
495
+ display.show(scope, "waiting");
496
+ // Collected synchronously right before the review collects the same
497
+ // stream, so the keys describe exactly what the guardian judged.
498
+ const keys = evidenceKeys(scope);
499
+ let attempt: ReviewAttempt;
500
+ try {
501
+ // The reviewer already follows the turn signal. A codemode call whose
502
+ // script ended, or an early review nobody will take, cancels its
503
+ // request and gives its slot back at once.
504
+ const stop = scope.callSignal && abandon
505
+ ? AbortSignal.any([scope.callSignal, abandon])
506
+ : scope.callSignal ?? abandon;
507
+ const review = reviewer.review(scope, input, stop);
508
+ const verdict = await (stop ? untilAborted(review, stop) : review);
509
+ if (!lifecycleStale() && reviewCancelled(ctx.signal)) reviewer.discardLineage();
510
+ attempt = { kind: "verdict", verdict, evidenceKeys: keys };
511
+ } catch (error) {
512
+ attempt = { kind: "failed", reason: error instanceof Error ? error.message : String(error), evidenceKeys: keys };
513
+ }
514
+ if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
515
+ // An early sibling review now waits for Pi to reach its call.
516
+ if (abandon) display.show(scope, "queued");
517
+ return attempt;
518
+ } finally {
519
+ releaseSlot();
520
+ }
521
+ }
522
+
523
+ /** Apply one settled review. Runs holding the decision slot. */
524
+ function applyReview(
525
+ scope: ReviewScope,
526
+ outcome: Exclude<ReviewAttempt, { kind: "cancelled" }>,
527
+ lifecycleSignal: AbortSignal,
528
+ ): Promise<BlockResult | undefined> | BlockResult | undefined {
529
+ if (outcome.kind === "failed") {
530
+ return askUser(scope, `Automatic review failed: ${outcome.reason}`, lifecycleSignal, "review_failure");
531
+ }
532
+ const { verdict } = outcome;
533
+ if (verdict.decision === "approve") {
534
+ return settle(scope, { display: "approved", reason: verdict.reason });
535
+ }
536
+ if (verdict.decision === "revise") {
537
+ return settle(scope, {
538
+ display: "revise",
539
+ verdict: "revise",
540
+ source: "guardian",
541
+ reason: verdict.reason,
542
+ block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
543
+ });
544
+ }
545
+ return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
546
+ }
547
+
548
+ /**
549
+ * Wait for a review, then apply it in the decision slot, one command at a
550
+ * time. The slot spans any approval prompt, so a verdict reached while the
551
+ * user was answering another command's prompt is checked against the
552
+ * evidence that answer added, and reviewed again when it changed. The last
553
+ * review `MAX_REVIEWS_PER_COMMAND` allows runs inside the slot.
554
+ */
555
+ async function decide(
556
+ scope: ReviewScope,
557
+ input: Record<string, unknown>,
558
+ lifecycleSignal: AbortSignal,
559
+ firstAttempt: Promise<ReviewAttempt>,
560
+ ): Promise<BlockResult | undefined> {
561
+ const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
562
+ const callSignal = callSignalOf(scope);
563
+ // Same composite the ask path builds, so Esc, a reviewer-lifecycle reset
564
+ // and the end of a codemode script all release a waiting command instead
565
+ // of stranding it behind a prompt it is no longer waiting for.
566
+ const queueSignal = withLifecycle(callSignal, lifecycleSignal);
567
+ let attempt = firstAttempt;
568
+ for (let reviews = 1; ; reviews += 1) {
569
+ const outcome = await attempt;
570
+ if (outcome.kind === "cancelled") return reviewCancelledResult();
571
+ if (decisionQueue.busy) display.show(scope, "queued");
572
+ let releaseDecision: () => void;
573
+ try {
574
+ releaseDecision = await decisionQueue.acquire(queueSignal);
575
+ } catch {
576
+ if (!lifecycleStale()) display.clear(scope);
577
+ return reviewCancelledResult();
578
+ }
579
+ try {
580
+ if (lifecycleStale() || reviewCancelled(callSignal)) {
581
+ if (!lifecycleStale()) display.clear(scope);
582
+ return reviewCancelledResult();
583
+ }
584
+ if (sameKeys(outcome.evidenceKeys, evidenceKeys(scope))) {
585
+ return await applyReview(scope, outcome, lifecycleSignal);
586
+ }
587
+ if (reviews + 1 < MAX_REVIEWS_PER_COMMAND) {
588
+ attempt = runReview(scope, input, lifecycleSignal);
589
+ continue;
590
+ }
591
+ const last = await runReview(scope, input, lifecycleSignal);
592
+ if (last.kind === "cancelled") return reviewCancelledResult();
593
+ return await applyReview(scope, last, lifecycleSignal);
594
+ } finally {
595
+ releaseDecision();
596
+ }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Start reviews for the guarded bash calls that follow `toolCallId` in the
602
+ * assistant message that issued it. Pi calls tool_call handlers for one
603
+ * message's calls one at a time, so without this their reviews could never
604
+ * overlap. Each result is taken by the call's own handler, and only when the
605
+ * input it finally receives is exactly the input that was reviewed.
606
+ */
607
+ function startSiblingReviews(
608
+ toolCallId: string,
609
+ ctx: ExtensionContext,
610
+ config: AutoPermissionsConfig,
611
+ lifecycleSignal: AbortSignal,
612
+ ): void {
613
+ if (config.reviewConcurrency <= 1) return;
614
+ const index = lastAssistantCalls.findIndex((call) => call.id === toolCallId);
615
+ if (index < 0) return;
616
+ for (const call of lastAssistantCalls.slice(index + 1)) {
617
+ if (!isBashTool(call.name) || siblingReviews.has(call.id)) continue;
618
+ const input = call.arguments as Record<string, unknown> | undefined;
619
+ const command = input?.command;
620
+ if (typeof command !== "string") continue;
621
+ const classified = classifyCommand(command, config, trustedGroups);
622
+ if (classified.kind !== "review") continue;
623
+ const controller = new AbortController();
624
+ const scope: ReviewScope = {
625
+ ctx,
626
+ config,
627
+ gate: classified.gate,
628
+ command,
629
+ target: { toolName: call.name, toolCallId: call.id },
630
+ };
631
+ siblingReviews.set(call.id, {
632
+ scope,
633
+ input: JSON.stringify(input),
634
+ gateLabel: classified.gate.label,
635
+ attempt: runReview(scope, input!, lifecycleSignal, controller.signal),
636
+ controller,
637
+ });
638
+ }
639
+ }
640
+
641
+ /**
642
+ * The early review of this call, when it reviewed exactly this input under
643
+ * this gate. `gateLabel` is undefined when the call no longer needs review
644
+ * (another handler changed it), in which case the early review is dropped.
645
+ */
646
+ function takeSiblingReview(
647
+ toolCallId: string,
648
+ input: unknown,
649
+ gateLabel: string | undefined,
650
+ ): Promise<ReviewAttempt> | undefined {
651
+ const sibling = siblingReviews.get(toolCallId);
652
+ if (!sibling) return undefined;
653
+ siblingReviews.delete(toolCallId);
654
+ if (gateLabel === undefined) {
655
+ sibling.controller.abort();
656
+ display.clear(sibling.scope);
657
+ return undefined;
658
+ }
659
+ if (sibling.input === JSON.stringify(input) && sibling.gateLabel === gateLabel) return sibling.attempt;
660
+ // Changed after it was reviewed early: the fresh review owns the row.
661
+ sibling.controller.abort(SUPERSEDED_REVIEW);
662
+ return undefined;
663
+ }
664
+
665
+ /** Drop early reviews whose calls never reached this extension. */
666
+ function abandonSiblingReviews(): void {
667
+ for (const sibling of siblingReviews.values()) {
668
+ sibling.controller.abort();
669
+ display.clear(sibling.scope);
670
+ }
671
+ siblingReviews.clear();
672
+ }
673
+
674
+ pi.on("message_end", (event) => {
675
+ const calls = assistantToolCalls((event as { message?: unknown }).message);
676
+ if (calls.length > 0) lastAssistantCalls = calls;
677
+ });
678
+
679
+ pi.on("turn_end", () => {
680
+ lastAssistantCalls = [];
681
+ abandonSiblingReviews();
682
+ endScripts();
683
+ scriptRoots.clear();
684
+ });
685
+
686
+ pi.on("tool_execution_start", (event) => {
687
+ const { toolCallId, parentToolCallId } = event as { toolCallId?: unknown; parentToolCallId?: unknown };
688
+ if (typeof toolCallId !== "string") return;
689
+ // A provider may reuse a call id in a later turn; that call is live again.
690
+ finishedCalls.delete(toolCallId);
691
+ if (typeof parentToolCallId === "string") {
692
+ scriptRoots.set(toolCallId, scriptRoots.get(parentToolCallId) ?? parentToolCallId);
693
+ }
694
+ });
695
+
696
+ pi.on("tool_execution_end", (event) => {
697
+ const { toolCallId } = event as { toolCallId?: unknown };
698
+ if (typeof toolCallId !== "string") return;
699
+ rememberFinished(toolCallId);
700
+ const controller = scriptsEnded.get(toolCallId);
701
+ if (!controller) return;
702
+ scriptsEnded.delete(toolCallId);
703
+ controller.abort();
704
+ });
705
+
352
706
  pi.on("tool_call", async (event, ctx) => {
353
- if (event.toolName !== "bash" && !event.toolName.endsWith(".bash")) return;
707
+ if (!isBashTool(event.toolName)) return;
354
708
  const command = (event.input as { command: string }).command;
355
709
  const lifecycleSignal = reviewer.lifecycleSignal;
356
- const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
357
710
 
358
711
  let config: AutoPermissionsConfig;
359
712
  try {
@@ -363,11 +716,33 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
363
716
  return { block: true, reason: error instanceof Error ? error.message : String(error) };
364
717
  }
365
718
  if (!config.enabled) return;
366
- const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
367
- const classified = classifyCommand(command, config, trustedGroups, overrides.allowedConventionCommands);
719
+ const parentToolCallId = (event as { parentToolCallId?: string }).parentToolCallId;
720
+ if (!parentToolCallId) startSiblingReviews(event.toolCallId, ctx, config, lifecycleSignal);
721
+ const scriptToolCallId = parentToolCallId ? scriptRoots.get(parentToolCallId) ?? parentToolCallId : undefined;
722
+ const target: ReviewTarget = {
723
+ toolName: event.toolName,
724
+ toolCallId: event.toolCallId,
725
+ ...(scriptToolCallId ? { scriptToolCallId } : {}),
726
+ };
727
+ const input = event.input as Record<string, unknown>;
728
+ const classified = classifyCommand(command, config, trustedGroups);
729
+ const early = takeSiblingReview(
730
+ event.toolCallId,
731
+ input,
732
+ classified.kind === "review" ? classified.gate.label : undefined,
733
+ );
368
734
  if (classified.kind === "pass") return;
369
735
  const gate = classified.gate;
370
- const scope: ReviewScope = { ctx, config, gate, command, target };
736
+ const scope: ReviewScope = {
737
+ ctx,
738
+ config,
739
+ gate,
740
+ command,
741
+ target,
742
+ ...(parentToolCallId && scriptToolCallId
743
+ ? { callSignal: scriptEndedSignal(parentToolCallId, scriptToolCallId) }
744
+ : {}),
745
+ };
371
746
  if (classified.kind === "deny") {
372
747
  return settle(scope, {
373
748
  verdict: "block",
@@ -376,236 +751,39 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
376
751
  block: denyReason(gate),
377
752
  });
378
753
  }
379
- if (classified.kind === "convention") {
380
- if (ctx.hasUI) overrides.activateOverrideTool();
381
- return settle(scope, {
382
- verdict: "block",
383
- source: "convention",
384
- reason: gate.message ?? gate.label,
385
- block: conventionReason(gate, command),
386
- });
387
- }
388
754
 
389
- // Show the queued row *before* waiting, and only when there is actually
390
- // something to wait for: the critical section spans the human prompt, so a
391
- // second guarded command in the same turn can sit here for as long as it
392
- // takes someone to answer. Rendering nothing for that whole time reads as a
393
- // hang. A lone review never flashes this, because the queue is idle.
394
- if (guardianReviewQueue.busy) {
395
- display.show(scope, "queued");
396
- }
397
- // Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
398
- // both release a queued command instead of stranding it behind a review it
399
- // is no longer waiting for.
400
- const queueSignal = ctx.signal ? AbortSignal.any([ctx.signal, lifecycleSignal]) : lifecycleSignal;
401
- let releaseReviewSlot: () => void;
402
- try {
403
- releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
404
- } catch {
405
- return { block: true, reason: "Auto Permissions review cancelled" };
406
- }
407
- try {
408
- if (lifecycleStale() || reviewCancelled(ctx.signal)) {
409
- return { block: true, reason: "Auto Permissions review cancelled" };
410
- }
411
- const signal = ctx.signal;
412
- display.show(scope, "waiting");
413
- try {
414
- const verdict = await reviewer.review(scope, event.input as Record<string, unknown>);
415
- if (lifecycleStale()) {
416
- return { block: true, reason: "Auto Permissions review cancelled" };
417
- }
418
- if (reviewCancelled(signal)) {
419
- reviewer.discardLineage();
420
- if (sessionActive) display.clear(scope);
421
- return { block: true, reason: "Auto Permissions review cancelled" };
422
- }
423
- if (verdict.decision === "approve") {
424
- const approved = settle(scope, { display: "approved", reason: verdict.reason });
425
- // An approval ends the argument at this gate: the per-gate bound exists
426
- // to stop an agent grinding, not to ration a long loop's whole session.
427
- loopReviseBudget.settle(gate.label);
428
- return approved;
429
- }
430
- // An unattended loop cannot answer a modal and must not be handed an
431
- // unbounded retry loop either, so both non-approving verdicts come back
432
- // as one bounded block. The verdict itself is unchanged: nothing is
433
- // approved here that would not have been approved with a user present.
434
- const loopContext = detectLoopContext();
435
- if (loopContext) {
436
- const charge = chargeLoopRevision(loopContext.loopId, gate.label, verdict.reason);
437
- return settle(scope, {
438
- display: verdict.decision === "revise" ? "revise" : "blocked",
439
- verdict: verdict.decision === "revise" ? "revise" : "block",
440
- source: "loop",
441
- reason: verdict.reason,
442
- block: loopBlockReason({
443
- gateLabel: gate.label,
444
- reason: verdict.reason,
445
- decision: verdict.decision,
446
- ...charge,
447
- }),
448
- });
449
- }
450
- if (verdict.decision === "revise") {
451
- return settle(scope, {
452
- display: "revise",
453
- verdict: "revise",
454
- source: "guardian",
455
- reason: verdict.reason,
456
- block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
457
- });
458
- }
459
- return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
460
- } catch (error) {
461
- if (lifecycleStale() || reviewCancelled(signal)) {
462
- if (!lifecycleStale() && sessionActive) display.clear(scope);
463
- return { block: true, reason: "Auto Permissions review cancelled" };
464
- }
465
- const reason = error instanceof Error ? error.message : String(error);
466
- // A review that failed is an infrastructure fault, not a guardian
467
- // judgment, so it charges no revision round — but it still must not open
468
- // a modal in a session with nobody to answer it.
469
- if (detectLoopContext()) {
470
- return settle(scope, {
471
- display: "blocked",
472
- verdict: "block",
473
- source: "review_failure",
474
- reason,
475
- block: `${gate.label} could not be reviewed (${reason}), and this session is running an unattended /loop, so there is no one to ask. Do not retry the same command hoping the reviewer recovers: call loop_wait naming the reviewer failure, or advance the objective another way.`,
476
- });
477
- }
478
- return askUser(scope, `Automatic review failed: ${reason}`, lifecycleSignal, "review_failure");
479
- }
480
- } finally {
481
- releaseReviewSlot();
482
- }
755
+ return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
483
756
  });
484
757
 
485
- pi.registerTool({
486
- name: "request_override",
487
- executionMode: "sequential",
488
- label: "Request Override",
489
- description: "Request a one-session exception for a command that violates a tooling convention. This cannot bypass guarded commands or deny rules.",
490
- parameters: Type.Object({
491
- command: Type.String({ description: "Exact command to allow" }),
492
- reason: Type.String({ description: "Why the convention does not apply" }),
493
- }),
494
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
495
- let config: AutoPermissionsConfig;
496
- try {
497
- config = currentConfig(ctx);
498
- } catch (error) {
499
- return {
500
- content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
501
- details: { success: false },
502
- };
503
- }
504
- const matches = untrustedMatches(params.command, config, trustedGroups);
505
- if (matches.some((gate) => gate.level === "deny")) {
506
- return {
507
- content: [{ type: "text", text: "The command matches a deny rule, which is a hard policy boundary. Deny rules cannot be bypassed with request_override; choose a different approach." }],
508
- details: { success: false },
509
- };
510
- }
511
- if (!matches.length || matches.some((gate) => gate.level === "guarded")) {
512
- return {
513
- content: [{ type: "text", text: "The command is not a convention violation. Guarded commands cannot be bypassed with request_override." }],
514
- details: { success: false },
515
- };
516
- }
517
- const gate = matches[0];
518
- const lifecycleSignal = reviewer.lifecycleSignal;
519
- const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
520
- const promptSignal = signal ? AbortSignal.any([signal, lifecycleSignal]) : lifecycleSignal;
521
- const cancelled = () => ({
522
- content: [{ type: "text" as const, text: "Override cancelled." }],
523
- details: { success: false },
524
- });
525
- if (lifecycleStale() || reviewCancelled(signal)) return cancelled();
526
- if (!ctx.hasUI) {
527
- return {
528
- content: [{ type: "text", text: "Cannot request an override without an interactive UI." }],
529
- details: { success: false },
530
- };
531
- }
532
-
533
- setHerdrBlocked(pi, true, gate.label);
534
- try {
535
- let choice: string | undefined;
536
- try {
537
- // The override prompt never had Tab-to-comment (the monkey patch only
538
- // enabled it on titles matching /needs approval/i), so keep parity.
539
- choice = await promptSelect(
540
- pi,
541
- ctx,
542
- `Convention override: ${gate.label}\n\n${params.reason}\n\n${params.command}`,
543
- ["Allow for this session", "Keep blocked"],
544
- promptSignal,
545
- { allowComment: false },
546
- );
547
- } catch (error) {
548
- if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
549
- return cancelled();
550
- }
551
- if (lifecycleStale() || reviewCancelled(signal)) return cancelled();
552
- if (choice === "Allow for this session") {
553
- overrides.allowConvention(params.command);
554
- return {
555
- content: [{ type: "text", text: `Override granted for this session:\n ${params.command}` }],
556
- details: { success: true, command: params.command },
557
- };
558
- }
559
- return {
560
- content: [{ type: "text", text: gate.message ?? "Use the configured project tooling." }],
561
- details: { success: false },
562
- };
563
- } finally {
564
- if (!lifecycleStale()) setHerdrBlocked(pi, false);
565
- }
566
- },
567
- });
568
-
569
- // The override schema is useful only after a convention denial has named an
570
- // exact command. It is kept registered for replay and narrowed out of the
571
- // active set at session_start — never here, because Pi refuses action methods
572
- // during extension loading.
573
-
574
758
  registerSettingsCommand(pi, { overrides, reviewer });
575
759
 
760
+ function resetCallTracking(): void {
761
+ lastAssistantCalls = [];
762
+ abandonSiblingReviews();
763
+ endScripts();
764
+ finishedCalls.clear();
765
+ scriptRoots.clear();
766
+ }
767
+
576
768
  pi.on("session_shutdown", async (_event, ctx) => {
577
- sessionActive = false;
578
- reviewer.abortLifecycle();
579
- reviewer.discardLineage();
769
+ resetCallTracking();
770
+ reviewer.endSession();
580
771
  display.shutdown(ctx);
581
772
  setHerdrBlocked(pi, false);
582
773
  });
583
774
 
584
775
  pi.on("session_start", async (_event, ctx) => {
585
- overrides.resetForSession();
586
- warnAboutMissingReviewerProvider(ctx);
587
-
588
- reviewer.abortLifecycle();
589
- reviewer.discardLineage();
590
- reviewer.resetLifecycle();
591
- sessionActive = true;
592
- overrides.restore(ctx.sessionManager.getBranch());
593
- // Rebuild the revise budget before the first tool call: a resumed session
594
- // that forgot its spent rounds would hand the agent a fresh set of them.
595
- loopReviseBudget.restore(ctx.sessionManager.getBranch());
596
- loopReviseBudgetLoopId = detectLoopContext()?.loopId;
597
- trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
598
- // Snapshot the trust baseline at session start: remotes configured now are
599
- // inside the boundary, anything added or repointed later is not. Re-capture
600
- // on every session_start (resume, branch switch) so the baseline follows
601
- // the session the reviews belong to; the fingerprint covers the change.
602
- reviewer.captureEnvironment(ctx.cwd);
776
+ let config: AutoPermissionsConfig | undefined;
603
777
  try {
604
- const config = currentConfig(ctx);
605
- overrides.loadStanding(config, ctx);
606
- if (config.ui.placement === "toolRow") display.registerGuardedBash(ctx);
778
+ config = currentConfig(ctx);
607
779
  } catch {
608
- // The first bash call will fail closed with the configuration error.
780
+ // Reported by currentConfig; the first bash call fails closed.
609
781
  }
782
+ if (config) warnAboutMissingReviewerProvider(ctx, config);
783
+
784
+ resetCallTracking();
785
+ reviewer.startSession(ctx.cwd);
786
+ overrides.restore(ctx.sessionManager.getBranch());
787
+ trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
610
788
  });
611
789
  }