@hank-warren/pi-auto-permissions 0.17.0 → 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/CHANGELOG.md +15 -0
- package/README.md +35 -0
- package/config-writer.ts +8 -2
- package/config.ts +16 -0
- package/guardian-reviewer.ts +130 -47
- package/index.ts +446 -64
- package/package.json +1 -1
- package/review-display.ts +133 -58
- package/review-queue.ts +88 -11
- package/review-scope.ts +11 -0
- package/review.ts +104 -9
- package/settings-command.ts +11 -3
- package/settings-menu.ts +29 -0
- package/widget-status.ts +41 -4
package/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createReviewQueue } from "./review-queue.js";
|
|
1
|
+
import { createReviewLimiter, createReviewQueue } from "./review-queue.js";
|
|
2
2
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "./evaluation-log.js";
|
|
20
20
|
import type { Gate } from "./gates.js";
|
|
21
21
|
import { classifyCommand } from "./classify.js";
|
|
22
|
-
import type { ReviewEvidenceRecord } from "./review.js";
|
|
22
|
+
import type { PermissionVerdict, ReviewEvidenceRecord } from "./review.js";
|
|
23
23
|
import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
|
|
24
24
|
import { createReviewDisplay } from "./review-display.js";
|
|
25
25
|
import { createSessionOverrides } from "./session-overrides.js";
|
|
@@ -78,12 +78,154 @@ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal):
|
|
|
78
78
|
return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
|
|
79
79
|
}
|
|
80
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;
|
|
148
|
+
}
|
|
149
|
+
|
|
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
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
81
169
|
export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
82
170
|
const overrides = createSessionOverrides(pi);
|
|
83
171
|
let trustedGroups = new Set<string>();
|
|
84
172
|
let lastConfigError: string | undefined;
|
|
85
173
|
let lastEvaluationLogError: string | undefined;
|
|
86
|
-
|
|
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
|
+
|
|
213
|
+
/**
|
|
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.
|
|
217
|
+
*/
|
|
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
|
+
}
|
|
87
229
|
// The session is active exactly while the reviewer lifecycle is unaborted:
|
|
88
230
|
// session_shutdown aborts it, session_start synchronously aborts and replaces
|
|
89
231
|
// it, and nothing else touches it.
|
|
@@ -181,10 +323,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
181
323
|
return signal?.aborted === true;
|
|
182
324
|
}
|
|
183
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
|
+
|
|
184
335
|
function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
|
|
185
336
|
if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
|
|
186
|
-
if (!reviewCancelled(scope
|
|
187
|
-
|
|
337
|
+
if (!reviewCancelled(callSignalOf(scope))) return undefined;
|
|
338
|
+
discardLineageIfTurnCancelled(scope);
|
|
188
339
|
display.clear(scope);
|
|
189
340
|
return reviewCancelledResult();
|
|
190
341
|
}
|
|
@@ -237,12 +388,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
237
388
|
decisionSource: "guardian" | "review_failure",
|
|
238
389
|
): Promise<BlockResult | undefined> {
|
|
239
390
|
const { ctx, config, gate, command } = scope;
|
|
240
|
-
const signal =
|
|
391
|
+
const signal = callSignalOf(scope);
|
|
241
392
|
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
242
393
|
const promptSignal = withLifecycle(signal, lifecycleSignal);
|
|
243
394
|
const cancelled = reviewCancelledResult;
|
|
244
395
|
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
245
|
-
if (!lifecycleStale())
|
|
396
|
+
if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
|
|
246
397
|
return cancelled();
|
|
247
398
|
}
|
|
248
399
|
display.show(scope, "ask_user", detail);
|
|
@@ -270,7 +421,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
270
421
|
);
|
|
271
422
|
} catch (error) {
|
|
272
423
|
if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
|
|
273
|
-
if (!lifecycleStale())
|
|
424
|
+
if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
|
|
274
425
|
return cancelled();
|
|
275
426
|
}
|
|
276
427
|
const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
|
|
@@ -301,11 +452,261 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
301
452
|
}
|
|
302
453
|
}
|
|
303
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
|
+
|
|
304
706
|
pi.on("tool_call", async (event, ctx) => {
|
|
305
|
-
if (
|
|
707
|
+
if (!isBashTool(event.toolName)) return;
|
|
306
708
|
const command = (event.input as { command: string }).command;
|
|
307
709
|
const lifecycleSignal = reviewer.lifecycleSignal;
|
|
308
|
-
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
309
710
|
|
|
310
711
|
let config: AutoPermissionsConfig;
|
|
311
712
|
try {
|
|
@@ -315,11 +716,33 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
315
716
|
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
316
717
|
}
|
|
317
718
|
if (!config.enabled) return;
|
|
318
|
-
const
|
|
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>;
|
|
319
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
|
+
);
|
|
320
734
|
if (classified.kind === "pass") return;
|
|
321
735
|
const gate = classified.gate;
|
|
322
|
-
const scope: ReviewScope = {
|
|
736
|
+
const scope: ReviewScope = {
|
|
737
|
+
ctx,
|
|
738
|
+
config,
|
|
739
|
+
gate,
|
|
740
|
+
command,
|
|
741
|
+
target,
|
|
742
|
+
...(parentToolCallId && scriptToolCallId
|
|
743
|
+
? { callSignal: scriptEndedSignal(parentToolCallId, scriptToolCallId) }
|
|
744
|
+
: {}),
|
|
745
|
+
};
|
|
323
746
|
if (classified.kind === "deny") {
|
|
324
747
|
return settle(scope, {
|
|
325
748
|
verdict: "block",
|
|
@@ -329,63 +752,21 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
329
752
|
});
|
|
330
753
|
}
|
|
331
754
|
|
|
332
|
-
|
|
333
|
-
// something to wait for: the critical section spans the human prompt, so a
|
|
334
|
-
// second guarded command in the same turn can sit here for as long as it
|
|
335
|
-
// takes someone to answer. Rendering nothing for that whole time reads as a
|
|
336
|
-
// hang. A lone review never flashes this, because the queue is idle.
|
|
337
|
-
if (guardianReviewQueue.busy) {
|
|
338
|
-
display.show(scope, "queued");
|
|
339
|
-
}
|
|
340
|
-
// Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
|
|
341
|
-
// both release a queued command instead of stranding it behind a review it
|
|
342
|
-
// is no longer waiting for.
|
|
343
|
-
const queueSignal = withLifecycle(ctx.signal, lifecycleSignal);
|
|
344
|
-
let releaseReviewSlot: () => void;
|
|
345
|
-
try {
|
|
346
|
-
releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
|
|
347
|
-
} catch {
|
|
348
|
-
return reviewCancelledResult();
|
|
349
|
-
}
|
|
350
|
-
try {
|
|
351
|
-
if (lifecycleStale() || reviewCancelled(ctx.signal)) {
|
|
352
|
-
return reviewCancelledResult();
|
|
353
|
-
}
|
|
354
|
-
const signal = ctx.signal;
|
|
355
|
-
display.show(scope, "waiting");
|
|
356
|
-
try {
|
|
357
|
-
const verdict = await reviewer.review(scope, event.input as Record<string, unknown>);
|
|
358
|
-
const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
|
|
359
|
-
if (cancelledResult) return cancelledResult;
|
|
360
|
-
if (verdict.decision === "approve") {
|
|
361
|
-
return settle(scope, { display: "approved", reason: verdict.reason });
|
|
362
|
-
}
|
|
363
|
-
if (verdict.decision === "revise") {
|
|
364
|
-
return settle(scope, {
|
|
365
|
-
display: "revise",
|
|
366
|
-
verdict: "revise",
|
|
367
|
-
source: "guardian",
|
|
368
|
-
reason: verdict.reason,
|
|
369
|
-
block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
|
|
373
|
-
} catch (error) {
|
|
374
|
-
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
375
|
-
if (!lifecycleStale()) display.clear(scope);
|
|
376
|
-
return reviewCancelledResult();
|
|
377
|
-
}
|
|
378
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
379
|
-
return askUser(scope, `Automatic review failed: ${reason}`, lifecycleSignal, "review_failure");
|
|
380
|
-
}
|
|
381
|
-
} finally {
|
|
382
|
-
releaseReviewSlot();
|
|
383
|
-
}
|
|
755
|
+
return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
|
|
384
756
|
});
|
|
385
757
|
|
|
386
758
|
registerSettingsCommand(pi, { overrides, reviewer });
|
|
387
759
|
|
|
760
|
+
function resetCallTracking(): void {
|
|
761
|
+
lastAssistantCalls = [];
|
|
762
|
+
abandonSiblingReviews();
|
|
763
|
+
endScripts();
|
|
764
|
+
finishedCalls.clear();
|
|
765
|
+
scriptRoots.clear();
|
|
766
|
+
}
|
|
767
|
+
|
|
388
768
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
769
|
+
resetCallTracking();
|
|
389
770
|
reviewer.endSession();
|
|
390
771
|
display.shutdown(ctx);
|
|
391
772
|
setHerdrBlocked(pi, false);
|
|
@@ -400,6 +781,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
400
781
|
}
|
|
401
782
|
if (config) warnAboutMissingReviewerProvider(ctx, config);
|
|
402
783
|
|
|
784
|
+
resetCallTracking();
|
|
403
785
|
reviewer.startSession(ctx.cwd);
|
|
404
786
|
overrides.restore(ctx.sessionManager.getBranch());
|
|
405
787
|
trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|