@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/CHANGELOG.md +39 -0
- package/README.md +52 -74
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +12 -51
- package/config.ts +100 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +190 -209
- package/index.ts +484 -306
- package/jsonl-sidecar.ts +22 -0
- package/override-evidence.ts +3 -17
- package/package.json +3 -8
- package/prompt-select.ts +4 -15
- package/review-display.ts +134 -220
- package/review-queue.ts +88 -21
- package/review-scope.ts +15 -4
- package/review.ts +136 -68
- package/session-overrides.ts +27 -145
- package/settings-command.ts +11 -48
- package/settings-menu.ts +34 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +45 -21
- package/approval-pulse.ts +0 -80
- package/loop-context.ts +0 -41
- package/loop-revise-budget.ts +0 -348
- package/openai-codex-transport.ts +0 -9
- package/standing-overrides.ts +0 -266
package/index.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
391
|
+
const signal = callSignalOf(scope);
|
|
281
392
|
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
282
|
-
const promptSignal =
|
|
283
|
-
const cancelled =
|
|
393
|
+
const promptSignal = withLifecycle(signal, lifecycleSignal);
|
|
394
|
+
const cancelled = reviewCancelledResult;
|
|
284
395
|
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
285
|
-
if (!lifecycleStale())
|
|
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
|
|
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())
|
|
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(
|
|
434
|
+
overrides.recordPromptDecision(gate, command, classification, detail, reviewer.lastEvidenceKey);
|
|
332
435
|
}
|
|
333
|
-
if (
|
|
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 (
|
|
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
|
|
367
|
-
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
578
|
-
reviewer.
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
overrides.loadStanding(config, ctx);
|
|
606
|
-
if (config.ui.placement === "toolRow") display.registerGuardedBash(ctx);
|
|
778
|
+
config = currentConfig(ctx);
|
|
607
779
|
} catch {
|
|
608
|
-
//
|
|
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
|
}
|