@hank-warren/pi-auto-permissions 0.16.2 → 0.17.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 +24 -0
- package/README.md +24 -81
- package/auto-permissions-setup/SKILL.md +4 -2
- package/classify.ts +7 -26
- package/config-writer.ts +5 -50
- package/config.ts +84 -236
- package/denial-log.ts +5 -22
- package/evaluation-log.ts +8 -51
- package/gates.ts +6 -13
- package/guardian-reviewer.ts +68 -170
- package/index.ts +50 -254
- 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 +14 -175
- package/review-queue.ts +2 -12
- package/review-scope.ts +4 -4
- package/review.ts +35 -62
- package/session-overrides.ts +27 -145
- package/settings-command.ts +0 -45
- package/settings-menu.ts +5 -132
- package/usage-log.ts +3 -20
- package/widget-status.ts +4 -17
- 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 { detectLoopContext } from "./loop-context.js";
|
|
2
1
|
import { createReviewQueue } from "./review-queue.js";
|
|
3
|
-
import {
|
|
4
|
-
LoopReviseBudget,
|
|
5
|
-
loopBlockReason,
|
|
6
|
-
type LoopReviseCharge,
|
|
7
|
-
} from "./loop-revise-budget.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,11 +15,10 @@ 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
|
|
21
|
+
import { classifyCommand } from "./classify.js";
|
|
30
22
|
import type { ReviewEvidenceRecord } from "./review.js";
|
|
31
23
|
import { promptSelect, setHerdrBlocked } from "./prompt-select.js";
|
|
32
24
|
import { createReviewDisplay } from "./review-display.js";
|
|
@@ -75,14 +67,15 @@ 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" };
|
|
79
75
|
}
|
|
80
76
|
|
|
81
|
-
function
|
|
82
|
-
|
|
83
|
-
const suggestion = gate.suggest?.(command);
|
|
84
|
-
if (suggestion && suggestion !== command) reason += `\n\nSuggested command:\n ${suggestion}`;
|
|
85
|
-
return `${reason}\n\nIf this is a legitimate edge case, explain why and call \`request_override\` with the exact command.`;
|
|
77
|
+
function withLifecycle(signal: AbortSignal | undefined, lifecycle: AbortSignal): AbortSignal {
|
|
78
|
+
return signal ? AbortSignal.any([signal, lifecycle]) : lifecycle;
|
|
86
79
|
}
|
|
87
80
|
|
|
88
81
|
export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
@@ -90,21 +83,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
90
83
|
let trustedGroups = new Set<string>();
|
|
91
84
|
let lastConfigError: string | undefined;
|
|
92
85
|
let lastEvaluationLogError: string | undefined;
|
|
93
|
-
let sessionActive = true;
|
|
94
|
-
/**
|
|
95
|
-
* Revision rounds spent per guardian concern while a loop is active.
|
|
96
|
-
*
|
|
97
|
-
* Session-scoped and held here rather than in the review closure, so a
|
|
98
|
-
* context compaction (which rewrites the conversation, not this process)
|
|
99
|
-
* cannot reset it. Restored from the session branch at session_start so a
|
|
100
|
-
* resume cannot reset it either.
|
|
101
|
-
*/
|
|
102
|
-
const loopReviseBudget = new LoopReviseBudget();
|
|
103
|
-
/** The loop these counts belong to, so a new loop starts with a clean budget. */
|
|
104
|
-
let loopReviseBudgetLoopId: string | undefined;
|
|
105
86
|
const guardianReviewQueue = createReviewQueue();
|
|
106
|
-
|
|
107
|
-
|
|
87
|
+
// The session is active exactly while the reviewer lifecycle is unaborted:
|
|
88
|
+
// session_shutdown aborts it, session_start synchronously aborts and replaces
|
|
89
|
+
// it, and nothing else touches it.
|
|
90
|
+
const reviewer = createGuardianReviewer({ overrides });
|
|
91
|
+
const display = createReviewDisplay({ isSessionActive: () => !reviewer.lifecycleSignal.aborted });
|
|
108
92
|
|
|
109
93
|
/**
|
|
110
94
|
* Record a non-approved outcome: a `pi.events` emit (the PermissionDenied
|
|
@@ -182,13 +166,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
182
166
|
* if that package is installed. Say so once per session instead of letting the
|
|
183
167
|
* first guarded command fail with a bare "review model not found".
|
|
184
168
|
*/
|
|
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
|
-
}
|
|
169
|
+
function warnAboutMissingReviewerProvider(ctx: ExtensionContext, config: AutoPermissionsConfig): void {
|
|
170
|
+
const provider = config.reviewer?.provider;
|
|
192
171
|
if (!provider || ctx.modelRegistry.getProvider(provider)) return;
|
|
193
172
|
|
|
194
173
|
const message =
|
|
@@ -199,7 +178,15 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
199
178
|
}
|
|
200
179
|
|
|
201
180
|
function reviewCancelled(signal: AbortSignal | undefined): boolean {
|
|
202
|
-
return
|
|
181
|
+
return signal?.aborted === true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function cancelledAfterAwait(scope: ReviewScope, lifecycleSignal: AbortSignal): BlockResult | undefined {
|
|
185
|
+
if (reviewer.isStale(lifecycleSignal)) return reviewCancelledResult();
|
|
186
|
+
if (!reviewCancelled(scope.ctx.signal)) return undefined;
|
|
187
|
+
reviewer.discardLineage();
|
|
188
|
+
display.clear(scope);
|
|
189
|
+
return reviewCancelledResult();
|
|
203
190
|
}
|
|
204
191
|
|
|
205
192
|
function logPromptEvaluation(
|
|
@@ -243,33 +230,6 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
243
230
|
}
|
|
244
231
|
}
|
|
245
232
|
|
|
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
233
|
async function askUser(
|
|
274
234
|
scope: ReviewScope,
|
|
275
235
|
detail: string,
|
|
@@ -279,8 +239,8 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
279
239
|
const { ctx, config, gate, command } = scope;
|
|
280
240
|
const signal = ctx.signal;
|
|
281
241
|
const lifecycleStale = () => reviewer.isStale(lifecycleSignal);
|
|
282
|
-
const promptSignal =
|
|
283
|
-
const cancelled =
|
|
242
|
+
const promptSignal = withLifecycle(signal, lifecycleSignal);
|
|
243
|
+
const cancelled = reviewCancelledResult;
|
|
284
244
|
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
285
245
|
if (!lifecycleStale()) reviewer.discardLineage();
|
|
286
246
|
return cancelled();
|
|
@@ -290,7 +250,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
290
250
|
return settle(scope, {
|
|
291
251
|
display: "blocked",
|
|
292
252
|
verdict: "block",
|
|
293
|
-
source: decisionSource
|
|
253
|
+
source: decisionSource,
|
|
294
254
|
reason: detail,
|
|
295
255
|
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
256
|
});
|
|
@@ -305,32 +265,24 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
305
265
|
pi,
|
|
306
266
|
ctx,
|
|
307
267
|
`${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
|
-
),
|
|
268
|
+
permissionPromptOptions(config.evaluationLog.enabled),
|
|
312
269
|
promptSignal,
|
|
313
|
-
{ allowComment: true },
|
|
314
270
|
);
|
|
315
271
|
} catch (error) {
|
|
316
272
|
if (!lifecycleStale() && !reviewCancelled(signal)) throw error;
|
|
317
273
|
if (!lifecycleStale()) reviewer.discardLineage();
|
|
318
274
|
return cancelled();
|
|
319
275
|
}
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
reviewer.discardLineage();
|
|
323
|
-
if (sessionActive) display.clear(scope);
|
|
324
|
-
return cancelled();
|
|
325
|
-
}
|
|
276
|
+
const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
|
|
277
|
+
if (cancelledResult) return cancelledResult;
|
|
326
278
|
const classification = classifyPromptChoice(choice);
|
|
327
279
|
// Feed the user's decision back to the guardian as session-scoped
|
|
328
280
|
// user-source evidence. review_failure prompts are excluded: their
|
|
329
281
|
// "concern" is an infrastructure error, not a guardian judgment.
|
|
330
282
|
if (decisionSource === "guardian" && classification) {
|
|
331
|
-
overrides.recordPromptDecision(
|
|
283
|
+
overrides.recordPromptDecision(gate, command, classification, detail, reviewer.lastEvidenceKey);
|
|
332
284
|
}
|
|
333
|
-
if (
|
|
285
|
+
if (classification?.userChoice) {
|
|
334
286
|
logPromptEvaluation(scope, detail, evaluationContext, decisionSource, classification.userChoice);
|
|
335
287
|
}
|
|
336
288
|
if (classification?.allowsExecution) {
|
|
@@ -364,7 +316,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
364
316
|
}
|
|
365
317
|
if (!config.enabled) return;
|
|
366
318
|
const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
|
|
367
|
-
const classified = classifyCommand(command, config, trustedGroups
|
|
319
|
+
const classified = classifyCommand(command, config, trustedGroups);
|
|
368
320
|
if (classified.kind === "pass") return;
|
|
369
321
|
const gate = classified.gate;
|
|
370
322
|
const scope: ReviewScope = { ctx, config, gate, command, target };
|
|
@@ -376,15 +328,6 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
376
328
|
block: denyReason(gate),
|
|
377
329
|
});
|
|
378
330
|
}
|
|
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
331
|
|
|
389
332
|
// Show the queued row *before* waiting, and only when there is actually
|
|
390
333
|
// something to wait for: the critical section spans the human prompt, so a
|
|
@@ -397,55 +340,25 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
397
340
|
// Same composite the ask path builds, so Esc and a reviewer-lifecycle reset
|
|
398
341
|
// both release a queued command instead of stranding it behind a review it
|
|
399
342
|
// is no longer waiting for.
|
|
400
|
-
const queueSignal =
|
|
343
|
+
const queueSignal = withLifecycle(ctx.signal, lifecycleSignal);
|
|
401
344
|
let releaseReviewSlot: () => void;
|
|
402
345
|
try {
|
|
403
346
|
releaseReviewSlot = await guardianReviewQueue.acquire(queueSignal);
|
|
404
347
|
} catch {
|
|
405
|
-
return
|
|
348
|
+
return reviewCancelledResult();
|
|
406
349
|
}
|
|
407
350
|
try {
|
|
408
351
|
if (lifecycleStale() || reviewCancelled(ctx.signal)) {
|
|
409
|
-
return
|
|
352
|
+
return reviewCancelledResult();
|
|
410
353
|
}
|
|
411
354
|
const signal = ctx.signal;
|
|
412
355
|
display.show(scope, "waiting");
|
|
413
356
|
try {
|
|
414
357
|
const verdict = await reviewer.review(scope, event.input as Record<string, unknown>);
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
if (reviewCancelled(signal)) {
|
|
419
|
-
reviewer.discardLineage();
|
|
420
|
-
if (sessionActive) display.clear(scope);
|
|
421
|
-
return { block: true, reason: "Auto Permissions review cancelled" };
|
|
422
|
-
}
|
|
358
|
+
const cancelledResult = cancelledAfterAwait(scope, lifecycleSignal);
|
|
359
|
+
if (cancelledResult) return cancelledResult;
|
|
423
360
|
if (verdict.decision === "approve") {
|
|
424
|
-
|
|
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
|
-
});
|
|
361
|
+
return settle(scope, { display: "approved", reason: verdict.reason });
|
|
449
362
|
}
|
|
450
363
|
if (verdict.decision === "revise") {
|
|
451
364
|
return settle(scope, {
|
|
@@ -459,22 +372,10 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
459
372
|
return askUser(scope, verdict.reason, lifecycleSignal, "guardian");
|
|
460
373
|
} catch (error) {
|
|
461
374
|
if (lifecycleStale() || reviewCancelled(signal)) {
|
|
462
|
-
if (!lifecycleStale()
|
|
463
|
-
return
|
|
375
|
+
if (!lifecycleStale()) display.clear(scope);
|
|
376
|
+
return reviewCancelledResult();
|
|
464
377
|
}
|
|
465
378
|
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
379
|
return askUser(scope, `Automatic review failed: ${reason}`, lifecycleSignal, "review_failure");
|
|
479
380
|
}
|
|
480
381
|
} finally {
|
|
@@ -482,130 +383,25 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
482
383
|
}
|
|
483
384
|
});
|
|
484
385
|
|
|
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
386
|
registerSettingsCommand(pi, { overrides, reviewer });
|
|
575
387
|
|
|
576
388
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
577
|
-
|
|
578
|
-
reviewer.abortLifecycle();
|
|
579
|
-
reviewer.discardLineage();
|
|
389
|
+
reviewer.endSession();
|
|
580
390
|
display.shutdown(ctx);
|
|
581
391
|
setHerdrBlocked(pi, false);
|
|
582
392
|
});
|
|
583
393
|
|
|
584
394
|
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);
|
|
395
|
+
let config: AutoPermissionsConfig | undefined;
|
|
603
396
|
try {
|
|
604
|
-
|
|
605
|
-
overrides.loadStanding(config, ctx);
|
|
606
|
-
if (config.ui.placement === "toolRow") display.registerGuardedBash(ctx);
|
|
397
|
+
config = currentConfig(ctx);
|
|
607
398
|
} catch {
|
|
608
|
-
//
|
|
399
|
+
// Reported by currentConfig; the first bash call fails closed.
|
|
609
400
|
}
|
|
401
|
+
if (config) warnAboutMissingReviewerProvider(ctx, config);
|
|
402
|
+
|
|
403
|
+
reviewer.startSession(ctx.cwd);
|
|
404
|
+
overrides.restore(ctx.sessionManager.getBranch());
|
|
405
|
+
trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
|
|
610
406
|
});
|
|
611
407
|
}
|
package/jsonl-sidecar.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Rotate once a sidecar grows past this size; one previous generation is kept. */
|
|
5
|
+
export const SIDECAR_ROTATE_BYTES = 16 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
function rotateIfLarge(path: string, rotateBytes: number): void {
|
|
8
|
+
try {
|
|
9
|
+
if (statSync(path).size < rotateBytes) return;
|
|
10
|
+
renameSync(path, `${path}.1`);
|
|
11
|
+
} catch {
|
|
12
|
+
// A missing or unrotatable sidecar simply keeps appending.
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Append one JSON line to an owner-only sidecar, rotating first when `rotateBytes` is set. */
|
|
17
|
+
export function appendJsonlRecord(path: string, record: unknown, rotateBytes?: number): void {
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
19
|
+
if (rotateBytes !== undefined) rotateIfLarge(path, rotateBytes);
|
|
20
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
21
|
+
chmodSync(path, 0o600);
|
|
22
|
+
}
|
package/override-evidence.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { ReviewEvidenceRecord } from "./review.js";
|
|
|
6
6
|
* evidence stream as user-source records so the guardian can treat them as
|
|
7
7
|
* authorization (allows) or standing constraints (blocks).
|
|
8
8
|
*/
|
|
9
|
+
export const PERMISSION_OVERRIDE_CHOICES = ["allow", "allow_unnecessary", "allow_appropriate", "block"] as const;
|
|
10
|
+
|
|
9
11
|
export interface PermissionOverride {
|
|
10
12
|
seq: number;
|
|
11
13
|
/**
|
|
@@ -18,13 +20,7 @@ export interface PermissionOverride {
|
|
|
18
20
|
gateLabel: string;
|
|
19
21
|
command: string;
|
|
20
22
|
reviewerReason: string;
|
|
21
|
-
choice:
|
|
22
|
-
/** Present only for a user-scoped ledger approval loaded at session start. */
|
|
23
|
-
standing?: {
|
|
24
|
-
grantedAt: string;
|
|
25
|
-
project: string;
|
|
26
|
-
gateGroup: string;
|
|
27
|
-
};
|
|
23
|
+
choice: (typeof PERMISSION_OVERRIDE_CHOICES)[number];
|
|
28
24
|
}
|
|
29
25
|
|
|
30
26
|
/** Anchor value meaning "before every collected record". */
|
|
@@ -43,16 +39,6 @@ export function overrideEvidenceRecord(override: PermissionOverride): ReviewEvid
|
|
|
43
39
|
const command = JSON.stringify(commandPreview(override.command));
|
|
44
40
|
const gate = JSON.stringify(override.gateLabel);
|
|
45
41
|
const concern = JSON.stringify(commandPreview(override.reviewerReason));
|
|
46
|
-
if (override.standing) {
|
|
47
|
-
const granted = override.standing.grantedAt.slice(0, 10);
|
|
48
|
-
const project = JSON.stringify(commandPreview(override.standing.project));
|
|
49
|
-
const group = JSON.stringify(override.standing.gateGroup);
|
|
50
|
-
return {
|
|
51
|
-
key: `override:${override.seq}`,
|
|
52
|
-
source: "user",
|
|
53
|
-
text: `USER (standing permission override, granted ${granted} in ${project}): allowed gated command ${command} (gate ${gate}, group ${group}) despite reviewer concern ${concern}. Treat comparable actions in any project as authorized unless a later user statement or block contradicts this. This never authorizes an action of a materially higher risk class.`,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
42
|
let text: string;
|
|
57
43
|
switch (override.choice) {
|
|
58
44
|
case "allow_unnecessary":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.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": [
|
|
@@ -43,24 +43,20 @@
|
|
|
43
43
|
"NOTICE.md",
|
|
44
44
|
"auto-permissions-setup",
|
|
45
45
|
"index.ts",
|
|
46
|
-
"approval-pulse.ts",
|
|
47
46
|
"config.ts",
|
|
48
47
|
"config-writer.ts",
|
|
49
48
|
"default-rules.ts",
|
|
50
49
|
"denial-log.ts",
|
|
51
50
|
"settings-menu.ts",
|
|
52
51
|
"guardian-transport.ts",
|
|
53
|
-
"openai-codex-transport.ts",
|
|
54
52
|
"subagent-context.ts",
|
|
55
|
-
"loop-context.ts",
|
|
56
|
-
"loop-revise-budget.ts",
|
|
57
53
|
"evaluation-log.ts",
|
|
58
54
|
"gates.ts",
|
|
55
|
+
"jsonl-sidecar.ts",
|
|
59
56
|
"override-evidence.ts",
|
|
60
57
|
"review.ts",
|
|
61
58
|
"review-queue.ts",
|
|
62
59
|
"session-environment.ts",
|
|
63
|
-
"standing-overrides.ts",
|
|
64
60
|
"usage-log.ts",
|
|
65
61
|
"widget-status.ts",
|
|
66
62
|
"classify.ts",
|
|
@@ -80,7 +76,6 @@
|
|
|
80
76
|
"peerDependencies": {
|
|
81
77
|
"@earendil-works/pi-ai": "*",
|
|
82
78
|
"@earendil-works/pi-coding-agent": "*",
|
|
83
|
-
"@earendil-works/pi-tui": "*"
|
|
84
|
-
"typebox": "*"
|
|
79
|
+
"@earendil-works/pi-tui": "*"
|
|
85
80
|
}
|
|
86
81
|
}
|
package/prompt-select.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { OptionSelector } from "@hank-warren/pi-permission-selector/selector.ts";
|
|
3
|
-
import { PulsingApprovalIndicator } from "./approval-pulse.js";
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Tell Herdr this pane is waiting on a human, so a supervising agent can see
|
|
@@ -33,23 +32,20 @@ export function promptSelect(
|
|
|
33
32
|
title: string,
|
|
34
33
|
values: string[],
|
|
35
34
|
signal: AbortSignal,
|
|
36
|
-
opts: { allowComment: boolean },
|
|
37
35
|
): Promise<string | undefined> {
|
|
38
|
-
let indicator: PulsingApprovalIndicator | undefined;
|
|
39
36
|
return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
|
|
40
37
|
let finished = false;
|
|
41
38
|
const onAbort = () => finish(undefined);
|
|
42
39
|
const finish = (result: string | undefined) => {
|
|
43
40
|
if (finished) return;
|
|
44
41
|
finished = true;
|
|
45
|
-
indicator?.stop();
|
|
46
42
|
signal.removeEventListener("abort", onAbort);
|
|
47
43
|
done(result);
|
|
48
44
|
};
|
|
49
45
|
const selector = new OptionSelector({
|
|
50
|
-
title,
|
|
46
|
+
title: `${theme.fg("warning", theme.bold("●"))} ${title}`,
|
|
51
47
|
options: values.map((value) => ({ value, label: value })),
|
|
52
|
-
allowComment:
|
|
48
|
+
allowComment: true,
|
|
53
49
|
theme,
|
|
54
50
|
onSelect: (option, comment) => {
|
|
55
51
|
if (comment) {
|
|
@@ -64,17 +60,10 @@ export function promptSelect(
|
|
|
64
60
|
onCancel: () => finish(undefined),
|
|
65
61
|
requestRender: () => tui.requestRender(),
|
|
66
62
|
});
|
|
67
|
-
indicator = new PulsingApprovalIndicator(
|
|
68
|
-
title.split("\n", 1)[0] ?? "Auto Permissions needs approval",
|
|
69
|
-
selector,
|
|
70
|
-
theme,
|
|
71
|
-
() => tui.requestRender(),
|
|
72
|
-
);
|
|
73
63
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
74
64
|
// `done` before the factory resolves is safe: pi marks the dialog closed
|
|
75
65
|
// and never mounts the component.
|
|
76
66
|
if (signal.aborted) onAbort();
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}).finally(() => indicator?.stop());
|
|
67
|
+
return selector;
|
|
68
|
+
});
|
|
80
69
|
}
|