@hank-warren/pi-auto-permissions 0.17.0 → 0.19.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 +27 -0
- package/README.md +47 -3
- package/config-writer.ts +8 -2
- package/config.ts +16 -0
- package/denial-log.ts +8 -0
- package/guardian-reviewer.ts +132 -49
- package/guardian-transport.ts +5 -6
- package/index.ts +531 -61
- package/package.json +1 -1
- package/prompt-select.ts +5 -0
- package/review-display.ts +133 -58
- package/review-queue.ts +88 -11
- package/review-scope.ts +11 -0
- package/review.ts +118 -12
- package/settings-command.ts +12 -4
- package/settings-menu.ts +29 -0
- package/subagent-context.ts +6 -6
- 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,13 +19,14 @@ 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";
|
|
26
26
|
import { createGuardianReviewer } from "./guardian-reviewer.js";
|
|
27
27
|
import { registerSettingsCommand } from "./settings-command.js";
|
|
28
28
|
import type { BlockResult, ReviewScope, ReviewTarget } from "./review-scope.js";
|
|
29
|
+
import { detectSubagentContext } from "./subagent-context.js";
|
|
29
30
|
import type { ReviewDisplayState } from "./widget-status.js";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -48,6 +49,8 @@ interface ReviewOutcome {
|
|
|
48
49
|
reason: string;
|
|
49
50
|
/** What the agent is told; absent means the command runs. */
|
|
50
51
|
block?: string;
|
|
52
|
+
/** A subagent's revise-first refusal, marked so the denial log tells it from a guardian revise. */
|
|
53
|
+
reviseFirst?: boolean;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
const PROJECT_CONFIG_DIR_NAME = (PiCodingAgent as { CONFIG_DIR_NAME?: string }).CONFIG_DIR_NAME ?? ".pi";
|
|
@@ -70,6 +73,22 @@ function denyReason(gate: Gate): string {
|
|
|
70
73
|
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
74
|
}
|
|
72
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Appended to a subagent child's system prompt. A child that does have a UI
|
|
78
|
+
* (an RPC child whose prompts surface in the parent) still costs the human an
|
|
79
|
+
* interruption per prompt, so it is told to revise before it escalates.
|
|
80
|
+
*/
|
|
81
|
+
/** The system prompt section that carries SUBAGENT_PERMISSIONS_PROMPT on Pi versions with a structured prompt. */
|
|
82
|
+
export const SUBAGENT_PROMPT_SECTION = "auto_permissions_subagent";
|
|
83
|
+
|
|
84
|
+
export const SUBAGENT_PERMISSIONS_PROMPT = `## Auto Permissions (subagent)
|
|
85
|
+
You are running as a subagent. Bash commands that Auto Permissions cannot approve on its own need a human, and asking one pauses the supervising session until they answer. Prefer commands that stay inside your task and working directory, and read-only alternatives where they answer the question. When Auto Permissions asks you to revise a command, change the approach instead of retrying it; re-run a command unchanged only when it is genuinely required and a human should decide.`;
|
|
86
|
+
|
|
87
|
+
/** What a subagent is told the first time a command would need human approval. */
|
|
88
|
+
export function subagentReviseFirstReason(gateLabel: string, reason: string): string {
|
|
89
|
+
return `${gateLabel} needs human approval: ${reason}\nYou are a subagent: asking the human pauses the supervising session until they answer. Revise first: get the same result with a command that stays inside your task and working directory (read-only, a narrower target, local instead of remote). If this exact command is genuinely required, run it again unchanged and the request goes to the human.`;
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
function reviewCancelledResult(): BlockResult {
|
|
74
93
|
return { block: true, reason: "Auto Permissions review cancelled" };
|
|
75
94
|
}
|
|
@@ -78,12 +97,162 @@ function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal):
|
|
|
78
97
|
return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
|
|
79
98
|
}
|
|
80
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Settle with `promise`, or reject as soon as `signal` aborts. The promise is
|
|
102
|
+
* left to finish on its own; its outcome is then ignored.
|
|
103
|
+
*/
|
|
104
|
+
function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
105
|
+
if (signal.aborted) {
|
|
106
|
+
promise.catch(() => undefined);
|
|
107
|
+
return Promise.reject(signal.reason);
|
|
108
|
+
}
|
|
109
|
+
return new Promise<T>((resolve, reject) => {
|
|
110
|
+
const onAbort = () => reject(signal.reason);
|
|
111
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
promise.then(
|
|
113
|
+
(value) => {
|
|
114
|
+
signal.removeEventListener("abort", onAbort);
|
|
115
|
+
resolve(value);
|
|
116
|
+
},
|
|
117
|
+
(error) => {
|
|
118
|
+
signal.removeEventListener("abort", onAbort);
|
|
119
|
+
reject(error);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Aborts when the turn ends or, for a codemode call, when its script does. */
|
|
126
|
+
function callSignalOf(scope: ReviewScope): AbortSignal | undefined {
|
|
127
|
+
const { ctx, callSignal } = scope;
|
|
128
|
+
if (!callSignal) return ctx.signal;
|
|
129
|
+
return ctx.signal ? AbortSignal.any([ctx.signal, callSignal]) : callSignal;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** How one guardian review ended, before its verdict is applied. */
|
|
133
|
+
type ReviewAttempt =
|
|
134
|
+
| { kind: "verdict"; verdict: PermissionVerdict; evidenceKeys: string[] }
|
|
135
|
+
| { kind: "failed"; reason: string; evidenceKeys: string[] }
|
|
136
|
+
| { kind: "cancelled" };
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A verdict whose evidence changed while it waited (the user answered another
|
|
140
|
+
* command's prompt, a tool result landed) is reviewed again before it is
|
|
141
|
+
* applied. Bounded so evidence that never settles cannot loop forever: the
|
|
142
|
+
* last review runs holding the decision slot, so no other prompt can be
|
|
143
|
+
* answered while it runs, which is the staleness window a lone review has
|
|
144
|
+
* always had.
|
|
145
|
+
*/
|
|
146
|
+
const MAX_REVIEWS_PER_COMMAND = 3;
|
|
147
|
+
|
|
148
|
+
/** How many finished call ids are remembered for calls a script left behind. */
|
|
149
|
+
const MAX_FINISHED_CALLS = 1024;
|
|
150
|
+
|
|
151
|
+
/** Abort reason for an early review replaced by a fresh one for the same call. */
|
|
152
|
+
const SUPERSEDED_REVIEW = "auto-permissions: superseded review";
|
|
153
|
+
|
|
154
|
+
/** A review started early for a later bash call in the same assistant message. */
|
|
155
|
+
interface SiblingReview {
|
|
156
|
+
scope: ReviewScope;
|
|
157
|
+
input: string;
|
|
158
|
+
gateLabel: string;
|
|
159
|
+
attempt: Promise<ReviewAttempt>;
|
|
160
|
+
controller: AbortController;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
interface AssistantToolCall {
|
|
164
|
+
id: string;
|
|
165
|
+
name: string;
|
|
166
|
+
arguments: unknown;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isBashTool(name: string): boolean {
|
|
170
|
+
return name === "bash" || name.endsWith(".bash");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function sameKeys(a: readonly string[], b: readonly string[]): boolean {
|
|
174
|
+
return a.length === b.length && a.every((key, index) => key === b[index]);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function assistantToolCalls(message: unknown): AssistantToolCall[] {
|
|
178
|
+
const candidate = message as { role?: unknown; content?: unknown } | undefined;
|
|
179
|
+
if (candidate?.role !== "assistant" || !Array.isArray(candidate.content)) return [];
|
|
180
|
+
return candidate.content.flatMap((block) => {
|
|
181
|
+
const call = block as { type?: unknown; id?: unknown; name?: unknown; arguments?: unknown } | undefined;
|
|
182
|
+
return call?.type === "toolCall" && typeof call.id === "string" && typeof call.name === "string"
|
|
183
|
+
? [{ id: call.id, name: call.name, arguments: call.arguments }]
|
|
184
|
+
: [];
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
81
188
|
export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
82
189
|
const overrides = createSessionOverrides(pi);
|
|
83
190
|
let trustedGroups = new Set<string>();
|
|
191
|
+
// Set per session: a subagent child (PI_SUBAGENT_CHILD=1) revises before it
|
|
192
|
+
// asks. `reviseFirst` maps each command it was told to revise to the turn
|
|
193
|
+
// it was told in; issuing it again unchanged in a *later* turn escalates it
|
|
194
|
+
// to the human, once: the entry goes with it. Same-turn duplicates (sibling calls, codemode Promise.all)
|
|
195
|
+
// are refused too, since the model has not yet seen the first refusal.
|
|
196
|
+
let subagentSession = false;
|
|
197
|
+
let turnGeneration = 0;
|
|
198
|
+
const reviseFirst = new Map<string, number>();
|
|
84
199
|
let lastConfigError: string | undefined;
|
|
85
200
|
let lastEvaluationLogError: string | undefined;
|
|
86
|
-
|
|
201
|
+
// Guardian calls run in parallel up to config.reviewConcurrency; applying
|
|
202
|
+
// verdicts and prompting the user stay one command at a time.
|
|
203
|
+
const reviewSlots = createReviewLimiter();
|
|
204
|
+
const decisionQueue = createReviewQueue();
|
|
205
|
+
// Pi runs tool_call handlers for one assistant message's calls one after
|
|
206
|
+
// another, so the later bash calls' reviews are started early from here.
|
|
207
|
+
let lastAssistantCalls: AssistantToolCall[] = [];
|
|
208
|
+
const siblingReviews = new Map<string, SiblingReview>();
|
|
209
|
+
// One controller per tool call (a codemode script) that has made bash calls,
|
|
210
|
+
// aborted when Pi reports that call finished: its pending calls can no
|
|
211
|
+
// longer run, so their reviews and prompts are released.
|
|
212
|
+
const scriptsEnded = new Map<string, AbortController>();
|
|
213
|
+
// Calls that already finished, newest last. A script can end before Pi runs
|
|
214
|
+
// the tool_call hook of a call it fired without awaiting, and with sequential
|
|
215
|
+
// tool execution that hook can run after the turn is over, so this outlives
|
|
216
|
+
// the turn. Bounded: only a recently finished call can still have one pending.
|
|
217
|
+
const finishedCalls = new Set<string>();
|
|
218
|
+
// Nested call id → the model-issued call it descends from, so a call made
|
|
219
|
+
// several levels down still names the script whose SCRIPT record it shares.
|
|
220
|
+
const scriptRoots = new Map<string, string>();
|
|
221
|
+
|
|
222
|
+
function rememberFinished(toolCallId: string): void {
|
|
223
|
+
finishedCalls.delete(toolCallId);
|
|
224
|
+
finishedCalls.add(toolCallId);
|
|
225
|
+
if (finishedCalls.size > MAX_FINISHED_CALLS) {
|
|
226
|
+
finishedCalls.delete(finishedCalls.values().next().value!);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function callEndedSignal(toolCallId: string): AbortSignal {
|
|
231
|
+
if (finishedCalls.has(toolCallId)) return AbortSignal.abort();
|
|
232
|
+
let controller = scriptsEnded.get(toolCallId);
|
|
233
|
+
if (!controller) {
|
|
234
|
+
controller = new AbortController();
|
|
235
|
+
scriptsEnded.set(toolCallId, controller);
|
|
236
|
+
}
|
|
237
|
+
return controller.signal;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Aborts when the call that made this one ends, or the script at the top of
|
|
242
|
+
* the chain does: a tool in between may keep awaiting this call after its
|
|
243
|
+
* script has gone.
|
|
244
|
+
*/
|
|
245
|
+
function scriptEndedSignal(parentToolCallId: string, scriptToolCallId: string): AbortSignal {
|
|
246
|
+
const parent = callEndedSignal(parentToolCallId);
|
|
247
|
+
return scriptToolCallId === parentToolCallId
|
|
248
|
+
? parent
|
|
249
|
+
: AbortSignal.any([parent, callEndedSignal(scriptToolCallId)]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function endScripts(): void {
|
|
253
|
+
for (const controller of scriptsEnded.values()) controller.abort();
|
|
254
|
+
scriptsEnded.clear();
|
|
255
|
+
}
|
|
87
256
|
// The session is active exactly while the reviewer lifecycle is unaborted:
|
|
88
257
|
// session_shutdown aborts it, session_start synchronously aborts and replaces
|
|
89
258
|
// it, and nothing else touches it.
|
|
@@ -100,6 +269,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
100
269
|
verdict: DenialVerdict,
|
|
101
270
|
reason: string,
|
|
102
271
|
decisionSource: DenialSource,
|
|
272
|
+
reviseFirst = false,
|
|
103
273
|
): void {
|
|
104
274
|
const { ctx, config, gate, command, target } = scope;
|
|
105
275
|
try {
|
|
@@ -111,6 +281,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
111
281
|
verdict,
|
|
112
282
|
reason,
|
|
113
283
|
decisionSource,
|
|
284
|
+
...(reviseFirst ? { reviseFirst: true } : {}),
|
|
114
285
|
});
|
|
115
286
|
} catch {
|
|
116
287
|
// Event fan-out is observability, never part of the decision.
|
|
@@ -126,6 +297,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
126
297
|
verdict,
|
|
127
298
|
reason,
|
|
128
299
|
decisionSource,
|
|
300
|
+
reviseFirst,
|
|
129
301
|
}));
|
|
130
302
|
} catch {
|
|
131
303
|
// The denial log is best-effort observability.
|
|
@@ -135,7 +307,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
135
307
|
/** The one place a decision is recorded, rendered and returned. */
|
|
136
308
|
function settle(scope: ReviewScope, outcome: ReviewOutcome): BlockResult | undefined {
|
|
137
309
|
if (outcome.verdict && outcome.source) {
|
|
138
|
-
recordDenial(scope, outcome.verdict, outcome.reason, outcome.source);
|
|
310
|
+
recordDenial(scope, outcome.verdict, outcome.reason, outcome.source, outcome.reviseFirst === true);
|
|
139
311
|
}
|
|
140
312
|
if (outcome.display) {
|
|
141
313
|
display.show(scope, outcome.display, outcome.detail ?? outcome.reason, true);
|
|
@@ -181,10 +353,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
181
353
|
return signal?.aborted === true;
|
|
182
354
|
}
|
|
183
355
|
|
|
356
|
+
/**
|
|
357
|
+
* A cancelled turn resets the reviewer lineage. A codemode script ending
|
|
358
|
+
* does not: the reviewer conversation is intact, and dropping it would make
|
|
359
|
+
* the next reviews start cold.
|
|
360
|
+
*/
|
|
361
|
+
function discardLineageIfTurnCancelled(scope: ReviewScope): void {
|
|
362
|
+
if (reviewCancelled(scope.ctx.signal)) reviewer.discardLineage();
|
|
363
|
+
}
|
|
364
|
+
|
|
184
365
|
function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
|
|
185
366
|
if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
|
|
186
|
-
if (!reviewCancelled(scope
|
|
187
|
-
|
|
367
|
+
if (!reviewCancelled(callSignalOf(scope))) return undefined;
|
|
368
|
+
discardLineageIfTurnCancelled(scope);
|
|
188
369
|
display.clear(scope);
|
|
189
370
|
return reviewCancelledResult();
|
|
190
371
|
}
|
|
@@ -237,12 +418,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
237
418
|
decisionSource: "guardian" | "review_failure",
|
|
238
419
|
): Promise<BlockResult | undefined> {
|
|
239
420
|
const { ctx, config, gate, command } = scope;
|
|
240
|
-
const signal =
|
|
421
|
+
const signal = callSignalOf(scope);
|
|
241
422
|
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
242
423
|
const promptSignal = withLifecycle(signal, lifecycleSignal);
|
|
243
424
|
const cancelled = reviewCancelledResult;
|
|
244
425
|
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
245
|
-
if (!lifecycleStale())
|
|
426
|
+
if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
|
|
246
427
|
return cancelled();
|
|
247
428
|
}
|
|
248
429
|
display.show(scope, "ask_user", detail);
|
|
@@ -270,7 +451,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
270
451
|
);
|
|
271
452
|
} catch (error) {
|
|
272
453
|
if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
|
|
273
|
-
if (!lifecycleStale())
|
|
454
|
+
if (!lifecycleStale()) discardLineageIfTurnCancelled(scope);
|
|
274
455
|
return cancelled();
|
|
275
456
|
}
|
|
276
457
|
const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
|
|
@@ -301,11 +482,298 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
301
482
|
}
|
|
302
483
|
}
|
|
303
484
|
|
|
485
|
+
function evidenceKeys(scope: ReviewScope): string[] {
|
|
486
|
+
return reviewer.collectEvidence(scope).map((record) => record.key);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* One guardian review, run as soon as a review slot is free. Never applies
|
|
491
|
+
* the verdict: that waits for the decision slot in `decide`.
|
|
492
|
+
*
|
|
493
|
+
* `abandon` is set for a sibling review started early. Aborting it drops the
|
|
494
|
+
* verdict; its row is cleared too, unless the abort reason is
|
|
495
|
+
* `SUPERSEDED_REVIEW`, meaning a fresh review for the same call owns the row.
|
|
496
|
+
*/
|
|
497
|
+
async function runReview(
|
|
498
|
+
scope: ReviewScope,
|
|
499
|
+
input: Record<string, unknown>,
|
|
500
|
+
lifecycleSignal: AbortSignal,
|
|
501
|
+
abandon?: AbortSignal,
|
|
502
|
+
): Promise<ReviewAttempt> {
|
|
503
|
+
const { ctx, config } = scope;
|
|
504
|
+
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
505
|
+
const abandoned = () => abandon?.aborted === true;
|
|
506
|
+
const drop = (): ReviewAttempt => {
|
|
507
|
+
if (!lifecycleStale() && abandon?.reason !== SUPERSEDED_REVIEW) display.clear(scope);
|
|
508
|
+
return { kind: "cancelled" };
|
|
509
|
+
};
|
|
510
|
+
const callSignal = callSignalOf(scope);
|
|
511
|
+
const slotSignal = AbortSignal.any([
|
|
512
|
+
lifecycleSignal,
|
|
513
|
+
...(callSignal ? [callSignal] : []),
|
|
514
|
+
...(abandon ? [abandon] : []),
|
|
515
|
+
]);
|
|
516
|
+
if (reviewSlots.wouldWait(config.reviewConcurrency)) display.show(scope, "queued");
|
|
517
|
+
let releaseSlot: () => void;
|
|
518
|
+
try {
|
|
519
|
+
releaseSlot = await reviewSlots.acquire(config.reviewConcurrency, slotSignal);
|
|
520
|
+
} catch {
|
|
521
|
+
return drop();
|
|
522
|
+
}
|
|
523
|
+
try {
|
|
524
|
+
if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
|
|
525
|
+
display.show(scope, "waiting");
|
|
526
|
+
// Collected synchronously right before the review collects the same
|
|
527
|
+
// stream, so the keys describe exactly what the guardian judged.
|
|
528
|
+
const keys = evidenceKeys(scope);
|
|
529
|
+
let attempt: ReviewAttempt;
|
|
530
|
+
try {
|
|
531
|
+
// The reviewer already follows the turn signal. A codemode call whose
|
|
532
|
+
// script ended, or an early review nobody will take, cancels its
|
|
533
|
+
// request and gives its slot back at once.
|
|
534
|
+
const stop = scope.callSignal && abandon
|
|
535
|
+
? AbortSignal.any([scope.callSignal, abandon])
|
|
536
|
+
: scope.callSignal ?? abandon;
|
|
537
|
+
const review = reviewer.review(scope, input, stop);
|
|
538
|
+
const verdict = await (stop ? untilAborted(review, stop) : review);
|
|
539
|
+
if (!lifecycleStale() && reviewCancelled(ctx.signal)) reviewer.discardLineage();
|
|
540
|
+
attempt = { kind: "verdict", verdict, evidenceKeys: keys };
|
|
541
|
+
} catch (error) {
|
|
542
|
+
attempt = { kind: "failed", reason: error instanceof Error ? error.message : String(error), evidenceKeys: keys };
|
|
543
|
+
}
|
|
544
|
+
if (lifecycleStale() || reviewCancelled(callSignal) || abandoned()) return drop();
|
|
545
|
+
// An early sibling review now waits for Pi to reach its call.
|
|
546
|
+
if (abandon) display.show(scope, "queued");
|
|
547
|
+
return attempt;
|
|
548
|
+
} finally {
|
|
549
|
+
releaseSlot();
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* In a subagent with a UI, the first time a command would go to the human it
|
|
555
|
+
* is refused with a revise-first reason instead. Same-turn duplicates are
|
|
556
|
+
* refused too; an unchanged retry in a later turn returns undefined, and the
|
|
557
|
+
* caller asks the human.
|
|
558
|
+
*/
|
|
559
|
+
function reviseFirstBlock(
|
|
560
|
+
scope: ReviewScope,
|
|
561
|
+
reason: string,
|
|
562
|
+
source: "guardian" | "review_failure",
|
|
563
|
+
lifecycleSignal: AbortSignal,
|
|
564
|
+
): BlockResult | undefined {
|
|
565
|
+
if (!subagentSession || !scope.ctx.hasUI) return undefined;
|
|
566
|
+
// As askUser does: a refusal the model will never see must not count as
|
|
567
|
+
// its revise-first turn, or its retry would go straight to the human.
|
|
568
|
+
const cancelled = cancelledAfterAwait(scope, lifecycleSignal);
|
|
569
|
+
if (cancelled) return cancelled;
|
|
570
|
+
const key = `${scope.gate.label}\0${scope.command}`;
|
|
571
|
+
const refusedIn = reviseFirst.get(key);
|
|
572
|
+
if (refusedIn !== undefined && refusedIn !== turnGeneration) {
|
|
573
|
+
// Escalated now: a later identical attempt is revised first again.
|
|
574
|
+
reviseFirst.delete(key);
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
if (refusedIn === undefined) reviseFirst.set(key, turnGeneration);
|
|
578
|
+
return settle(scope, {
|
|
579
|
+
display: "revise",
|
|
580
|
+
verdict: "revise",
|
|
581
|
+
source,
|
|
582
|
+
reason,
|
|
583
|
+
block: subagentReviseFirstReason(scope.gate.label, reason),
|
|
584
|
+
reviseFirst: true,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Apply one settled review. Runs holding the decision slot. */
|
|
589
|
+
function applyReview(
|
|
590
|
+
scope: ReviewScope,
|
|
591
|
+
outcome: Exclude<ReviewAttempt, { kind: "cancelled" }>,
|
|
592
|
+
lifecycleSignal: AbortSignal,
|
|
593
|
+
): Promise<BlockResult | undefined> | BlockResult | undefined {
|
|
594
|
+
if (outcome.kind === "failed") {
|
|
595
|
+
const reason = `Automatic review failed: ${outcome.reason}`;
|
|
596
|
+
return reviseFirstBlock(scope, reason, "review_failure", lifecycleSignal) ?? askUser(scope, reason, lifecycleSignal, "review_failure");
|
|
597
|
+
}
|
|
598
|
+
const { verdict } = outcome;
|
|
599
|
+
if (verdict.decision === "approve") {
|
|
600
|
+
return settle(scope, { display: "approved", reason: verdict.reason });
|
|
601
|
+
}
|
|
602
|
+
if (verdict.decision === "revise") {
|
|
603
|
+
return settle(scope, {
|
|
604
|
+
display: "revise",
|
|
605
|
+
verdict: "revise",
|
|
606
|
+
source: "guardian",
|
|
607
|
+
reason: verdict.reason,
|
|
608
|
+
block: `Auto Permissions requested revision: ${verdict.reason}\nRevise the command and try again.`,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return reviseFirstBlock(scope, verdict.reason, "guardian", lifecycleSignal) ?? askUser(scope, verdict.reason, lifecycleSignal, "guardian");
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* Wait for a review, then apply it in the decision slot, one command at a
|
|
616
|
+
* time. The slot spans any approval prompt, so a verdict reached while the
|
|
617
|
+
* user was answering another command's prompt is checked against the
|
|
618
|
+
* evidence that answer added, and reviewed again when it changed. The last
|
|
619
|
+
* review `MAX_REVIEWS_PER_COMMAND` allows runs inside the slot.
|
|
620
|
+
*/
|
|
621
|
+
async function decide(
|
|
622
|
+
scope: ReviewScope,
|
|
623
|
+
input: Record<string, unknown>,
|
|
624
|
+
lifecycleSignal: AbortSignal,
|
|
625
|
+
firstAttempt: Promise<ReviewAttempt>,
|
|
626
|
+
): Promise<BlockResult | undefined> {
|
|
627
|
+
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
628
|
+
const callSignal = callSignalOf(scope);
|
|
629
|
+
// Same composite the ask path builds, so Esc, a reviewer-lifecycle reset
|
|
630
|
+
// and the end of a codemode script all release a waiting command instead
|
|
631
|
+
// of stranding it behind a prompt it is no longer waiting for.
|
|
632
|
+
const queueSignal = withLifecycle(callSignal, lifecycleSignal);
|
|
633
|
+
let attempt = firstAttempt;
|
|
634
|
+
for (let reviews = 1; ; reviews += 1) {
|
|
635
|
+
const outcome = await attempt;
|
|
636
|
+
if (outcome.kind === "cancelled") return reviewCancelledResult();
|
|
637
|
+
if (decisionQueue.busy) display.show(scope, "queued");
|
|
638
|
+
let releaseDecision: () => void;
|
|
639
|
+
try {
|
|
640
|
+
releaseDecision = await decisionQueue.acquire(queueSignal);
|
|
641
|
+
} catch {
|
|
642
|
+
if (!lifecycleStale()) display.clear(scope);
|
|
643
|
+
return reviewCancelledResult();
|
|
644
|
+
}
|
|
645
|
+
try {
|
|
646
|
+
if (lifecycleStale() || reviewCancelled(callSignal)) {
|
|
647
|
+
if (!lifecycleStale()) display.clear(scope);
|
|
648
|
+
return reviewCancelledResult();
|
|
649
|
+
}
|
|
650
|
+
if (sameKeys(outcome.evidenceKeys, evidenceKeys(scope))) {
|
|
651
|
+
return await applyReview(scope, outcome, lifecycleSignal);
|
|
652
|
+
}
|
|
653
|
+
if (reviews + 1 < MAX_REVIEWS_PER_COMMAND) {
|
|
654
|
+
attempt = runReview(scope, input, lifecycleSignal);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const last = await runReview(scope, input, lifecycleSignal);
|
|
658
|
+
if (last.kind === "cancelled") return reviewCancelledResult();
|
|
659
|
+
return await applyReview(scope, last, lifecycleSignal);
|
|
660
|
+
} finally {
|
|
661
|
+
releaseDecision();
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Start reviews for the guarded bash calls that follow `toolCallId` in the
|
|
668
|
+
* assistant message that issued it. Pi calls tool_call handlers for one
|
|
669
|
+
* message's calls one at a time, so without this their reviews could never
|
|
670
|
+
* overlap. Each result is taken by the call's own handler, and only when the
|
|
671
|
+
* input it finally receives is exactly the input that was reviewed.
|
|
672
|
+
*/
|
|
673
|
+
function startSiblingReviews(
|
|
674
|
+
toolCallId: string,
|
|
675
|
+
ctx: ExtensionContext,
|
|
676
|
+
config: AutoPermissionsConfig,
|
|
677
|
+
lifecycleSignal: AbortSignal,
|
|
678
|
+
): void {
|
|
679
|
+
if (config.reviewConcurrency <= 1) return;
|
|
680
|
+
const index = lastAssistantCalls.findIndex((call) => call.id === toolCallId);
|
|
681
|
+
if (index < 0) return;
|
|
682
|
+
for (const call of lastAssistantCalls.slice(index + 1)) {
|
|
683
|
+
if (!isBashTool(call.name) || siblingReviews.has(call.id)) continue;
|
|
684
|
+
const input = call.arguments as Record<string, unknown> | undefined;
|
|
685
|
+
const command = input?.command;
|
|
686
|
+
if (typeof command !== "string") continue;
|
|
687
|
+
const classified = classifyCommand(command, config, trustedGroups);
|
|
688
|
+
if (classified.kind !== "review") continue;
|
|
689
|
+
const controller = new AbortController();
|
|
690
|
+
const scope: ReviewScope = {
|
|
691
|
+
ctx,
|
|
692
|
+
config,
|
|
693
|
+
gate: classified.gate,
|
|
694
|
+
command,
|
|
695
|
+
target: { toolName: call.name, toolCallId: call.id },
|
|
696
|
+
};
|
|
697
|
+
siblingReviews.set(call.id, {
|
|
698
|
+
scope,
|
|
699
|
+
input: JSON.stringify(input),
|
|
700
|
+
gateLabel: classified.gate.label,
|
|
701
|
+
attempt: runReview(scope, input!, lifecycleSignal, controller.signal),
|
|
702
|
+
controller,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The early review of this call, when it reviewed exactly this input under
|
|
709
|
+
* this gate. `gateLabel` is undefined when the call no longer needs review
|
|
710
|
+
* (another handler changed it), in which case the early review is dropped.
|
|
711
|
+
*/
|
|
712
|
+
function takeSiblingReview(
|
|
713
|
+
toolCallId: string,
|
|
714
|
+
input: unknown,
|
|
715
|
+
gateLabel: string | undefined,
|
|
716
|
+
): Promise<ReviewAttempt> | undefined {
|
|
717
|
+
const sibling = siblingReviews.get(toolCallId);
|
|
718
|
+
if (!sibling) return undefined;
|
|
719
|
+
siblingReviews.delete(toolCallId);
|
|
720
|
+
if (gateLabel === undefined) {
|
|
721
|
+
sibling.controller.abort();
|
|
722
|
+
display.clear(sibling.scope);
|
|
723
|
+
return undefined;
|
|
724
|
+
}
|
|
725
|
+
if (sibling.input === JSON.stringify(input) && sibling.gateLabel === gateLabel) return sibling.attempt;
|
|
726
|
+
// Changed after it was reviewed early: the fresh review owns the row.
|
|
727
|
+
sibling.controller.abort(SUPERSEDED_REVIEW);
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** Drop early reviews whose calls never reached this extension. */
|
|
732
|
+
function abandonSiblingReviews(): void {
|
|
733
|
+
for (const sibling of siblingReviews.values()) {
|
|
734
|
+
sibling.controller.abort();
|
|
735
|
+
display.clear(sibling.scope);
|
|
736
|
+
}
|
|
737
|
+
siblingReviews.clear();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
pi.on("message_end", (event) => {
|
|
741
|
+
const calls = assistantToolCalls((event as { message?: unknown }).message);
|
|
742
|
+
if (calls.length > 0) lastAssistantCalls = calls;
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
pi.on("turn_end", () => {
|
|
746
|
+
turnGeneration += 1;
|
|
747
|
+
lastAssistantCalls = [];
|
|
748
|
+
abandonSiblingReviews();
|
|
749
|
+
endScripts();
|
|
750
|
+
scriptRoots.clear();
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
pi.on("tool_execution_start", (event) => {
|
|
754
|
+
const { toolCallId, parentToolCallId } = event as { toolCallId?: unknown; parentToolCallId?: unknown };
|
|
755
|
+
if (typeof toolCallId !== "string") return;
|
|
756
|
+
// A provider may reuse a call id in a later turn; that call is live again.
|
|
757
|
+
finishedCalls.delete(toolCallId);
|
|
758
|
+
if (typeof parentToolCallId === "string") {
|
|
759
|
+
scriptRoots.set(toolCallId, scriptRoots.get(parentToolCallId) ?? parentToolCallId);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
pi.on("tool_execution_end", (event) => {
|
|
764
|
+
const { toolCallId } = event as { toolCallId?: unknown };
|
|
765
|
+
if (typeof toolCallId !== "string") return;
|
|
766
|
+
rememberFinished(toolCallId);
|
|
767
|
+
const controller = scriptsEnded.get(toolCallId);
|
|
768
|
+
if (!controller) return;
|
|
769
|
+
scriptsEnded.delete(toolCallId);
|
|
770
|
+
controller.abort();
|
|
771
|
+
});
|
|
772
|
+
|
|
304
773
|
pi.on("tool_call", async (event, ctx) => {
|
|
305
|
-
if (
|
|
774
|
+
if (!isBashTool(event.toolName)) return;
|
|
306
775
|
const command = (event.input as { command: string }).command;
|
|
307
776
|
const lifecycleSignal = reviewer.lifecycleSignal;
|
|
308
|
-
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
309
777
|
|
|
310
778
|
let config: AutoPermissionsConfig;
|
|
311
779
|
try {
|
|
@@ -315,11 +783,33 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
315
783
|
return { block: true, reason: error instanceof Error ? error.message : String(error) };
|
|
316
784
|
}
|
|
317
785
|
if (!config.enabled) return;
|
|
318
|
-
const
|
|
786
|
+
const parentToolCallId = (event as { parentToolCallId?: string }).parentToolCallId;
|
|
787
|
+
if (!parentToolCallId) startSiblingReviews(event.toolCallId, ctx, config, lifecycleSignal);
|
|
788
|
+
const scriptToolCallId = parentToolCallId ? scriptRoots.get(parentToolCallId) ?? parentToolCallId : undefined;
|
|
789
|
+
const target: ReviewTarget = {
|
|
790
|
+
toolName: event.toolName,
|
|
791
|
+
toolCallId: event.toolCallId,
|
|
792
|
+
...(scriptToolCallId ? { scriptToolCallId } : {}),
|
|
793
|
+
};
|
|
794
|
+
const input = event.input as Record<string, unknown>;
|
|
319
795
|
const classified = classifyCommand(command, config, trustedGroups);
|
|
796
|
+
const early = takeSiblingReview(
|
|
797
|
+
event.toolCallId,
|
|
798
|
+
input,
|
|
799
|
+
classified.kind === "review" ? classified.gate.label : undefined,
|
|
800
|
+
);
|
|
320
801
|
if (classified.kind === "pass") return;
|
|
321
802
|
const gate = classified.gate;
|
|
322
|
-
const scope: ReviewScope = {
|
|
803
|
+
const scope: ReviewScope = {
|
|
804
|
+
ctx,
|
|
805
|
+
config,
|
|
806
|
+
gate,
|
|
807
|
+
command,
|
|
808
|
+
target,
|
|
809
|
+
...(parentToolCallId && scriptToolCallId
|
|
810
|
+
? { callSignal: scriptEndedSignal(parentToolCallId, scriptToolCallId) }
|
|
811
|
+
: {}),
|
|
812
|
+
};
|
|
323
813
|
if (classified.kind === "deny") {
|
|
324
814
|
return settle(scope, {
|
|
325
815
|
verdict: "block",
|
|
@@ -329,63 +819,40 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
329
819
|
});
|
|
330
820
|
}
|
|
331
821
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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;
|
|
822
|
+
return decide(scope, input, lifecycleSignal, early ?? runReview(scope, input, lifecycleSignal));
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
pi.on("before_agent_start", (event) => {
|
|
826
|
+
if (!subagentSession) return;
|
|
345
827
|
try {
|
|
346
|
-
|
|
828
|
+
if (!loadAutoPermissionsConfig().enabled) return;
|
|
347
829
|
} catch {
|
|
348
|
-
|
|
830
|
+
// An invalid config fails closed on the first bash call; the guidance still applies.
|
|
349
831
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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();
|
|
832
|
+
// On Pi 1.x a returned systemPrompt forces the whole prompt for the run,
|
|
833
|
+
// dropping what later extensions change in its options (pi-agents adds
|
|
834
|
+
// the parent's instruction files there), so the guidance goes into a
|
|
835
|
+
// named section instead. Older Pi takes the prompt as a string.
|
|
836
|
+
const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
|
|
837
|
+
if (sections) {
|
|
838
|
+
sections[SUBAGENT_PROMPT_SECTION] = SUBAGENT_PERMISSIONS_PROMPT;
|
|
839
|
+
return;
|
|
383
840
|
}
|
|
841
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${SUBAGENT_PERMISSIONS_PROMPT}` };
|
|
384
842
|
});
|
|
385
843
|
|
|
386
844
|
registerSettingsCommand(pi, { overrides, reviewer });
|
|
387
845
|
|
|
846
|
+
function resetCallTracking(): void {
|
|
847
|
+
lastAssistantCalls = [];
|
|
848
|
+
abandonSiblingReviews();
|
|
849
|
+
endScripts();
|
|
850
|
+
finishedCalls.clear();
|
|
851
|
+
scriptRoots.clear();
|
|
852
|
+
}
|
|
853
|
+
|
|
388
854
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
855
|
+
resetCallTracking();
|
|
389
856
|
reviewer.endSession();
|
|
390
857
|
display.shutdown(ctx);
|
|
391
858
|
setHerdrBlocked(pi, false);
|
|
@@ -400,6 +867,9 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
400
867
|
}
|
|
401
868
|
if (config) warnAboutMissingReviewerProvider(ctx, config);
|
|
402
869
|
|
|
870
|
+
resetCallTracking();
|
|
871
|
+
subagentSession = detectSubagentContext(ctx.cwd) !== undefined;
|
|
872
|
+
reviseFirst.clear();
|
|
403
873
|
reviewer.startSession(ctx.cwd);
|
|
404
874
|
overrides.restore(ctx.sessionManager.getBranch());
|
|
405
875
|
trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
|