@hank-warren/pi-auto-permissions 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  } from "./loop-revise-budget.js";
11
11
  import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
12
12
  import {
13
+ getAgentDir,
13
14
  getSelectListTheme,
14
15
  getSettingsListTheme,
15
16
  type ExtensionAPI,
@@ -29,13 +30,30 @@ import { patchAutoPermissionsConfig } from "./config-writer.js";
29
30
  import {
30
31
  applySettingChange,
31
32
  buildSettingItems,
33
+ createDenialsSubmenu,
32
34
  createModelSubmenu,
33
35
  createTimeoutSubmenu,
36
+ type DenialsSubmenuHost,
37
+ type DenialSummary,
34
38
  type MenuModel,
35
39
  type ReviewerSettings,
36
40
  type SettingChange,
37
41
  type SubmenuHost,
38
42
  } from "./settings-menu.js";
43
+ import {
44
+ appendDenialRecord,
45
+ buildDenialRecord,
46
+ readRecentDenials,
47
+ type DenialSource,
48
+ type DenialVerdict,
49
+ } from "./denial-log.js";
50
+ import {
51
+ buildSetupDraftRequest,
52
+ parseSetupDraft,
53
+ scanProject,
54
+ scanRecentSessionCommands,
55
+ SETUP_DRAFT_SYSTEM_PROMPT,
56
+ } from "./setup-scan.js";
39
57
  import {
40
58
  appendPromptEvaluation,
41
59
  classifyPromptChoice,
@@ -43,7 +61,7 @@ import {
43
61
  permissionPromptOptions,
44
62
  type PromptEvaluationUserChoice,
45
63
  } from "./evaluation-log.js";
46
- import { findGates, type Gate } from "./gates.js";
64
+ import { ALL_SHELL_GATE, findGates, type Gate } from "./gates.js";
47
65
  import { appendUsageRecord, buildUsageLogRecord } from "./usage-log.js";
48
66
  import {
49
67
  reviewFrameIntervalMs,
@@ -54,18 +72,26 @@ import {
54
72
  } from "./widget-status.js";
55
73
  import {
56
74
  applyFullRebuildEviction,
75
+ buildGuardianPolicySection,
57
76
  buildReviewEnvelope,
58
77
  collectReviewEvidence,
59
78
  LOOP_CONTEXT_SYSTEM_PROMPT,
60
79
  INJECTED_USER_MESSAGE_SYSTEM_PROMPT,
61
80
  OVERRIDE_FEEDBACK_SYSTEM_PROMPT,
62
81
  parsePermissionVerdict,
82
+ parsePrefilterVerdict,
83
+ PREFILTER_INSTRUCTION,
63
84
  SUBAGENT_CONTEXT_SYSTEM_PROMPT,
64
85
  type EvidenceCaps,
65
86
  type PermissionVerdict,
66
87
  type ReviewEvidenceRecord,
67
88
  } from "./review.js";
68
89
  import { mergeOverrideEvidence, type PermissionOverride } from "./override-evidence.js";
90
+ import {
91
+ buildSessionEnvironmentSection,
92
+ captureSessionEnvironment,
93
+ type SessionEnvironmentSnapshot,
94
+ } from "./session-environment.js";
69
95
  import { OptionSelector } from "@hank-warren/pi-permission-selector/selector.ts";
70
96
  import { PulsingApprovalIndicator } from "./approval-pulse.js";
71
97
 
@@ -243,6 +269,10 @@ function loadTrustedGroups(cwd: string): Set<string> {
243
269
  return groups;
244
270
  }
245
271
 
272
+ function denyReason(gate: Gate): string {
273
+ return `Blocked by policy: ${gate.label}\n\n${gate.message ?? "This operation is denied by rule."}\n\nThis is a deny rule: it cannot be overridden with request_override, trusted groups, or user approval. Choose a different approach.`;
274
+ }
275
+
246
276
  function conventionReason(gate: Gate, command: string): string {
247
277
  let reason = `Convention violation: ${gate.label}\n\n${gate.message ?? "Use the configured project tooling."}`;
248
278
  const suggestion = gate.suggest?.(command);
@@ -277,6 +307,109 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
277
307
  let activeReviewerSessionId: string | undefined;
278
308
  const permissionOverrides: PermissionOverride[] = [];
279
309
  let overrideSeq = 0;
310
+ /** Captured once per session; see session-environment.ts for why once. */
311
+ let sessionEnvironment: SessionEnvironmentSnapshot | undefined;
312
+
313
+ /**
314
+ * Overrides persist as custom session entries the way the loop budget does,
315
+ * so a resumed session keeps the user's prompt decisions instead of
316
+ * forgetting every allow and standing block constraint.
317
+ */
318
+ const OVERRIDES_ENTRY_TYPE = "auto-permissions-overrides";
319
+
320
+ function persistOverrides(): void {
321
+ try {
322
+ pi.appendEntry(OVERRIDES_ENTRY_TYPE, {
323
+ seq: overrideSeq,
324
+ overrides: permissionOverrides.map((override) => ({ ...override })),
325
+ });
326
+ } catch {
327
+ // Persistence is best-effort; the in-memory records still apply now.
328
+ }
329
+ }
330
+
331
+ /** Fail-open like the loop budget: unreadable state means no overrides. */
332
+ function restoreOverrides(entries: readonly unknown[]): void {
333
+ permissionOverrides.length = 0;
334
+ overrideSeq = 0;
335
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
336
+ const entry = entries[index] as { type?: string; customType?: string; data?: unknown } | undefined;
337
+ if (entry?.type !== "custom" || entry.customType !== OVERRIDES_ENTRY_TYPE) continue;
338
+ const data = entry.data as { seq?: unknown; overrides?: unknown } | undefined;
339
+ if (Array.isArray(data?.overrides)) {
340
+ for (const raw of data.overrides) {
341
+ if (!raw || typeof raw !== "object") continue;
342
+ const candidate = raw as PermissionOverride;
343
+ if (
344
+ typeof candidate.seq !== "number"
345
+ || typeof candidate.gateLabel !== "string"
346
+ || typeof candidate.command !== "string"
347
+ || typeof candidate.reviewerReason !== "string"
348
+ || !["allow", "allow_unnecessary", "allow_appropriate", "block"].includes(candidate.choice)
349
+ ) {
350
+ continue;
351
+ }
352
+ permissionOverrides.push({
353
+ seq: candidate.seq,
354
+ ...(typeof candidate.anchorKey === "string" ? { anchorKey: candidate.anchorKey } : {}),
355
+ gateLabel: candidate.gateLabel,
356
+ command: candidate.command,
357
+ reviewerReason: candidate.reviewerReason,
358
+ choice: candidate.choice,
359
+ });
360
+ }
361
+ }
362
+ if (typeof data?.seq === "number" && Number.isSafeInteger(data.seq) && data.seq >= 0) {
363
+ overrideSeq = data.seq;
364
+ }
365
+ return;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Record a non-approved outcome: a `pi.events` emit (the PermissionDenied
371
+ * hook equivalent) always, plus a denial-log line when enabled. Never blocks
372
+ * the decision itself.
373
+ */
374
+ function recordDenial(
375
+ config: AutoPermissionsConfig,
376
+ ctx: ExtensionContext,
377
+ target: ReviewTarget,
378
+ gate: Gate,
379
+ command: string,
380
+ verdict: DenialVerdict,
381
+ reason: string,
382
+ decisionSource: DenialSource,
383
+ ): void {
384
+ try {
385
+ pi.events.emit("auto-permissions:denied", {
386
+ tool: target.toolName,
387
+ command,
388
+ gate: gate.label,
389
+ group: gate.group,
390
+ verdict,
391
+ reason,
392
+ decisionSource,
393
+ });
394
+ } catch {
395
+ // Event fan-out is observability, never part of the decision.
396
+ }
397
+ if (!config.denialLog.enabled) return;
398
+ try {
399
+ appendDenialRecord(config.denialLog.path, buildDenialRecord({
400
+ sessionId: ctx.sessionManager.getSessionId(),
401
+ cwd: ctx.cwd,
402
+ tool: target.toolName,
403
+ gate: { label: gate.label, group: gate.group },
404
+ command,
405
+ verdict,
406
+ reason,
407
+ decisionSource,
408
+ }));
409
+ } catch {
410
+ // The denial log is best-effort observability.
411
+ }
412
+ }
280
413
  let reviewerGeneration = 0;
281
414
  let reviewerLifecycleController = new AbortController();
282
415
 
@@ -384,8 +517,13 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
384
517
  // allowlist rather than on whether this turn's evidence happens to contain
385
518
  // one: the system prompt has to be identical across the turns of a review
386
519
  // lineage, and "did an injected record appear yet" is not.
520
+ const guardianPolicySection = buildGuardianPolicySection(config.guardianPolicy);
521
+ // Lazy in case a review lands before session_start ran; still once only.
522
+ sessionEnvironment ??= captureSessionEnvironment(ctx.cwd);
387
523
  const policyPrompt = [
388
524
  basePolicyPrompt,
525
+ ...(guardianPolicySection ? [guardianPolicySection] : []),
526
+ buildSessionEnvironmentSection(sessionEnvironment),
389
527
  OVERRIDE_FEEDBACK_SYSTEM_PROMPT,
390
528
  ...(config.reviewEvidence.userMessageTypes.length ? [INJECTED_USER_MESSAGE_SYSTEM_PROMPT] : []),
391
529
  ].join("\n\n");
@@ -434,6 +572,84 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
434
572
  });
435
573
 
436
574
  const fullEvidence = () => applyFullRebuildEviction(evidence, config.reviewEvidence.fullRebuildKeepToolRecords);
575
+
576
+ // Stage one: an optional stateless single-token prefilter at minimal
577
+ // reasoning. SAFE approves; REVIEW and every failure fall through to the
578
+ // full lineage review below, which is untouched — the prefilter uses its
579
+ // own throwaway session, so the append-only lineage invariant holds. The
580
+ // envelope text matches a full-rebuild review's, so when the full review
581
+ // does run without a lineage base its prompt is largely a provider cache
582
+ // hit of this call.
583
+ if (config.reviewer?.prefilter) {
584
+ const prefilterSessionId = reviewerSessionId(model);
585
+ const prefilterSignal = AbortSignal.any([
586
+ AbortSignal.timeout(config.reviewer.timeoutMs),
587
+ reviewerLifecycleController.signal,
588
+ ...(signal ? [signal] : []),
589
+ ]);
590
+ let safe = false;
591
+ try {
592
+ const auth = await waitForSignal(ctx.modelRegistry.getApiKeyAndHeaders(model), prefilterSignal);
593
+ if (!auth.ok) throw new Error(auth.error);
594
+ const response = await resolveGuardianCompleteSimple(ctx.modelRegistry, "pi-auto-permissions")(
595
+ model,
596
+ {
597
+ systemPrompt,
598
+ messages: [{
599
+ role: "user",
600
+ content: [{ type: "text", text: `${buildReviewEnvelope(fullEvidence(), request, "full")}\n\n${PREFILTER_INSTRUCTION}` }],
601
+ timestamp: Date.now(),
602
+ }],
603
+ },
604
+ {
605
+ apiKey: auth.apiKey,
606
+ headers: auth.headers,
607
+ env: auth.env,
608
+ signal: prefilterSignal,
609
+ reasoning: "minimal",
610
+ sessionId: prefilterSessionId,
611
+ transport: isOpenAICodexModel(model) ? "websocket" : "auto",
612
+ cacheRetention: "long",
613
+ },
614
+ );
615
+ if (response.stopReason !== "aborted" && response.stopReason !== "error" && !prefilterSignal.aborted) {
616
+ recordReviewerUsage(config, model, response.usage, subagentContext !== undefined, "prefilter");
617
+ safe = parsePrefilterVerdict(assistantText(response.content)) === "safe";
618
+ }
619
+ } catch {
620
+ // Fail closed into the full review: a prefilter that cannot answer
621
+ // flags for review, it never approves and never blocks by itself.
622
+ } finally {
623
+ cleanupReviewerSession(prefilterSessionId);
624
+ }
625
+ if (!sessionActive || signal?.aborted) throw new Error("review timed out or was cancelled");
626
+ if (safe) {
627
+ if (config.evaluationLog.enabled) {
628
+ try {
629
+ appendPromptEvaluation(config.evaluationLog.path, {
630
+ version: 2,
631
+ timestamp: new Date().toISOString(),
632
+ sessionId: mainSessionId,
633
+ cwd: ctx.cwd,
634
+ tool: toolName,
635
+ gate: { label: gate.label, group: gate.group },
636
+ userRequest: evidence
637
+ .filter((record) => record.source === "user")
638
+ .map((record) => record.text)
639
+ .join("\n"),
640
+ command: typeof input.command === "string" ? input.command : JSON.stringify(input),
641
+ relevantContext: [...evidence],
642
+ actualDecision: "approve",
643
+ actualReason: "prefilter",
644
+ decisionSource: "prefilter",
645
+ });
646
+ } catch {
647
+ // Evaluation logging must never block a permission decision.
648
+ }
649
+ }
650
+ return { decision: "approve", reason: "prefilter" };
651
+ }
652
+ }
437
653
  let userMessage = makeUserMessage(base ? evidence.slice(base.evidenceKeys.length) : fullEvidence(), base ? "delta" : "full");
438
654
  let messages = base ? [...base.messages, userMessage] : [userMessage];
439
655
  if (base && estimateReviewTokens(systemPrompt, messages) >= budget) {
@@ -513,17 +729,22 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
513
729
  * Reviewer calls never reach the session transcript, so their usage is invisible to
514
730
  * tooling that reads session files. Record content-free counters in a sidecar instead.
515
731
  */
516
- function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown, subagent: boolean): void {
732
+ function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown, subagent: boolean, label: "guardian" | "prefilter" | "setup" = "guardian"): void {
517
733
  if (!config.usageLog.enabled) return;
518
734
  try {
519
- appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage, "guardian", "auto-permissions", subagent));
735
+ appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage, label, "auto-permissions", subagent));
520
736
  } catch {
521
737
  // Usage accounting is optional and must never block a permission decision.
522
738
  }
523
739
  }
524
740
 
525
741
  function untrustedMatches(command: string, config: AutoPermissionsConfig): Gate[] {
526
- return findGates(command, config.rules).filter((gate) => !trustedGroups.has(gate.group));
742
+ // Deny rules are hard policy boundaries: a project-scoped trusted-ops file
743
+ // must not be able to lift one, or a checked-in file could disarm the
744
+ // circuit breaker. Only guarded and convention matches honor the bypass.
745
+ return findGates(command, config.rules).filter(
746
+ (gate) => gate.level === "deny" || !trustedGroups.has(gate.group),
747
+ );
527
748
  }
528
749
 
529
750
  function evidenceCaps(config: AutoPermissionsConfig): EvidenceCaps {
@@ -856,6 +1077,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
856
1077
  }
857
1078
  showReviewDisplay(ctx, config, gate, command, target, "ask_user", detail);
858
1079
  if (!ctx.hasUI) {
1080
+ recordDenial(
1081
+ config,
1082
+ ctx,
1083
+ target,
1084
+ gate,
1085
+ command,
1086
+ "block",
1087
+ detail,
1088
+ decisionSource === "guardian" ? "guardian" : "review_failure",
1089
+ );
859
1090
  showReviewDisplay(ctx, config, gate, command, target, "blocked", detail, true);
860
1091
  return {
861
1092
  block: true,
@@ -912,6 +1143,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
912
1143
  reviewerReason: detail,
913
1144
  choice: overrideChoice,
914
1145
  });
1146
+ persistOverrides();
915
1147
  }
916
1148
  }
917
1149
  if (config.evaluationLog.enabled && classification?.userChoice) {
@@ -931,6 +1163,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
931
1163
  showReviewDisplay(ctx, config, gate, command, target, "approved", "approved by user", true);
932
1164
  return undefined;
933
1165
  }
1166
+ recordDenial(
1167
+ config,
1168
+ ctx,
1169
+ target,
1170
+ gate,
1171
+ command,
1172
+ "block",
1173
+ detail,
1174
+ decisionSource === "guardian" ? "user" : "review_failure",
1175
+ );
934
1176
  showReviewDisplay(ctx, config, gate, command, target, "blocked", "blocked by user", true);
935
1177
  return { block: true, reason: "Blocked by user" };
936
1178
  } finally {
@@ -952,17 +1194,44 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
952
1194
  return { block: true, reason: error instanceof Error ? error.message : String(error) };
953
1195
  }
954
1196
  if (!config.enabled) return;
955
- const matches = untrustedMatches(command, config);
956
- if (!matches.length) return;
1197
+ const target: ReviewTarget = { toolName: event.toolName, toolCallId: event.toolCallId };
1198
+ const ruleMatches = findGates(command, config.rules);
1199
+ const matches = ruleMatches.filter(
1200
+ (gate) => gate.level === "deny" || !trustedGroups.has(gate.group),
1201
+ );
1202
+ // Level priority across all matches, never first-match: a convention or
1203
+ // guarded rule earlier in config order must not shadow a deny rule later.
1204
+ const deny = matches.find((gate) => gate.level === "deny");
1205
+ if (deny) {
1206
+ recordDenial(config, ctx, target, deny, command, "block", deny.message ?? deny.label, "deny");
1207
+ return { block: true, reason: denyReason(deny) };
1208
+ }
957
1209
  const convention = matches.find((gate) => gate.level === "convention");
958
1210
  if (convention && !allowedConventionCommands.has(command)) {
1211
+ recordDenial(
1212
+ config,
1213
+ ctx,
1214
+ target,
1215
+ convention,
1216
+ command,
1217
+ "block",
1218
+ convention.message ?? convention.label,
1219
+ "convention",
1220
+ );
959
1221
  return { block: true, reason: conventionReason(convention, command) };
960
1222
  }
961
- const gate = matches.find((candidate) => candidate.level === "guarded");
1223
+ let gate = matches.find((candidate) => candidate.level === "guarded");
1224
+ // With reviewAllShell on, a command no rule names at all still gets a
1225
+ // guardian review under the generic gate. "No rule" is judged before the
1226
+ // trusted-groups filter: a command whose matching group the project
1227
+ // trusts was explicitly waved through, not left unnamed, and must not be
1228
+ // re-captured by the blanket gate.
1229
+ if (!gate && !ruleMatches.length && config.reviewAllShell && !trustedGroups.has(ALL_SHELL_GATE.group)) {
1230
+ gate = ALL_SHELL_GATE;
1231
+ }
962
1232
  if (!gate) return;
963
1233
 
964
1234
  const signal = ctx.signal;
965
- const target = { toolName: event.toolName, toolCallId: event.toolCallId };
966
1235
  showReviewDisplay(ctx, config, gate, command, target, "waiting");
967
1236
  try {
968
1237
  const verdict = await reviewCommand(
@@ -996,6 +1265,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
996
1265
  const loopContext = detectLoopContext();
997
1266
  if (loopContext) {
998
1267
  const charge = chargeLoopRevision(loopContext.loopId, gate.label, verdict.reason);
1268
+ recordDenial(
1269
+ config,
1270
+ ctx,
1271
+ target,
1272
+ gate,
1273
+ command,
1274
+ verdict.decision === "revise" ? "revise" : "block",
1275
+ verdict.reason,
1276
+ "loop",
1277
+ );
999
1278
  showReviewDisplay(
1000
1279
  ctx,
1001
1280
  config,
@@ -1017,6 +1296,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1017
1296
  };
1018
1297
  }
1019
1298
  if (verdict.decision === "revise") {
1299
+ recordDenial(config, ctx, target, gate, command, "revise", verdict.reason, "guardian");
1020
1300
  showReviewDisplay(ctx, config, gate, command, target, "revise", verdict.reason, true);
1021
1301
  return {
1022
1302
  block: true,
@@ -1034,6 +1314,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1034
1314
  // judgment, so it charges no revision round — but it still must not open
1035
1315
  // a modal in a session with nobody to answer it.
1036
1316
  if (detectLoopContext()) {
1317
+ recordDenial(config, ctx, target, gate, command, "block", reason, "review_failure");
1037
1318
  showReviewDisplay(ctx, config, gate, command, target, "blocked", reason, true);
1038
1319
  return {
1039
1320
  block: true,
@@ -1063,7 +1344,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1063
1344
  name: "request_override",
1064
1345
  executionMode: "sequential",
1065
1346
  label: "Request Override",
1066
- description: "Request a one-session exception for a command that violates a tooling convention. This cannot bypass guarded commands.",
1347
+ description: "Request a one-session exception for a command that violates a tooling convention. This cannot bypass guarded commands or deny rules.",
1067
1348
  parameters: Type.Object({
1068
1349
  command: Type.String({ description: "Exact command to allow" }),
1069
1350
  reason: Type.String({ description: "Why the convention does not apply" }),
@@ -1079,6 +1360,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1079
1360
  };
1080
1361
  }
1081
1362
  const matches = untrustedMatches(params.command, config);
1363
+ if (matches.some((gate) => gate.level === "deny")) {
1364
+ return {
1365
+ 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." }],
1366
+ details: { success: false },
1367
+ };
1368
+ }
1082
1369
  if (!matches.length || matches.some((gate) => gate.level === "guarded")) {
1083
1370
  return {
1084
1371
  content: [{ type: "text", text: "The command is not a convention violation. Guarded commands cannot be bypassed with request_override." }],
@@ -1145,16 +1432,132 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1145
1432
  * and in every other one. reviewerFingerprint() covers provider, model and
1146
1433
  * reasoning effort, so the cached reviewer lineage self-invalidates too.
1147
1434
  */
1435
+ /**
1436
+ * `/auto-permissions setup`: scan the project (git remotes, AGENTS.md or
1437
+ * README.md, lockfiles) and recent session files — command strings only,
1438
+ * never user messages — then draft guardianPolicy.environment entries plus
1439
+ * suggested trusted-ops groups with one model call. The draft is accepted
1440
+ * or discarded as a whole; accepting appends the environment entries via
1441
+ * the narrow config writer. Trusted-ops suggestions are displayed only:
1442
+ * a wizard must not widen a project-level bypass file on its own.
1443
+ */
1444
+ async function runSetupWizard(ctx: ExtensionContext): Promise<void> {
1445
+ const path = autoPermissionsConfigPath();
1446
+ let config: AutoPermissionsConfig;
1447
+ try {
1448
+ config = loadAutoPermissionsConfig(path);
1449
+ } catch (error) {
1450
+ const message = error instanceof Error ? error.message : String(error);
1451
+ ctx.ui.notify(`Auto Permissions config error: ${message} — fix ${path} first`, "warning");
1452
+ return;
1453
+ }
1454
+ const model = config.reviewer
1455
+ ? ctx.modelRegistry.find(config.reviewer.provider, config.reviewer.model)
1456
+ : ctx.model;
1457
+ if (!model) {
1458
+ ctx.ui.notify("Auto Permissions setup needs a reviewer model (or an active session model).", "warning");
1459
+ return;
1460
+ }
1461
+
1462
+ ctx.ui.notify("Auto Permissions setup: scanning project facts and recent session commands…", "info");
1463
+ const project = scanProject(ctx.cwd);
1464
+ const commands = scanRecentSessionCommands(join(getAgentDir(), "sessions"));
1465
+ const availableGroups = [...new Set(config.rules.map((rule) => rule.group))].sort();
1466
+
1467
+ const sessionId = reviewerSessionId(model);
1468
+ let draftText: string;
1469
+ try {
1470
+ const setupSignal = AbortSignal.any([AbortSignal.timeout(60_000), reviewerLifecycleController.signal]);
1471
+ const auth = await waitForSignal(ctx.modelRegistry.getApiKeyAndHeaders(model), setupSignal);
1472
+ if (!auth.ok) throw new Error(auth.error);
1473
+ const response = await resolveGuardianCompleteSimple(ctx.modelRegistry, "pi-auto-permissions")(
1474
+ model,
1475
+ {
1476
+ systemPrompt: SETUP_DRAFT_SYSTEM_PROMPT,
1477
+ messages: [{
1478
+ role: "user",
1479
+ content: [{ type: "text", text: buildSetupDraftRequest(project, commands, availableGroups) }],
1480
+ timestamp: Date.now(),
1481
+ }],
1482
+ },
1483
+ {
1484
+ apiKey: auth.apiKey,
1485
+ headers: auth.headers,
1486
+ env: auth.env,
1487
+ signal: setupSignal,
1488
+ reasoning: "low",
1489
+ sessionId,
1490
+ transport: isOpenAICodexModel(model) ? "websocket" : "auto",
1491
+ },
1492
+ );
1493
+ if (response.stopReason === "aborted") throw new Error("setup draft timed out or was cancelled");
1494
+ if (response.stopReason === "error") throw new Error(response.errorMessage ?? "setup draft request failed");
1495
+ recordReviewerUsage(config, model, response.usage, false, "setup");
1496
+ draftText = assistantText(response.content);
1497
+ } catch (error) {
1498
+ const message = error instanceof Error ? error.message : String(error);
1499
+ ctx.ui.notify(`Auto Permissions setup failed: ${message}`, "warning");
1500
+ return;
1501
+ } finally {
1502
+ cleanupReviewerSession(sessionId);
1503
+ }
1504
+
1505
+ let draft: ReturnType<typeof parseSetupDraft>;
1506
+ try {
1507
+ draft = parseSetupDraft(draftText, availableGroups);
1508
+ } catch (error) {
1509
+ const message = error instanceof Error ? error.message : String(error);
1510
+ ctx.ui.notify(`Auto Permissions setup failed: ${message}`, "warning");
1511
+ return;
1512
+ }
1513
+ if (!draft.environment.length && !draft.trustedGroups.length) {
1514
+ ctx.ui.notify("Auto Permissions setup found nothing to suggest for this project.", "info");
1515
+ return;
1516
+ }
1517
+
1518
+ const lines = ["Auto Permissions setup — proposed trust configuration", ""];
1519
+ if (draft.environment.length) {
1520
+ lines.push("guardianPolicy.environment entries to append:");
1521
+ for (const entry of draft.environment) lines.push(` - ${entry}`);
1522
+ }
1523
+ if (draft.trustedGroups.length) {
1524
+ if (draft.environment.length) lines.push("");
1525
+ lines.push("Suggested .pi/trusted-ops groups (shown only — add them to the project file yourself):");
1526
+ for (const group of draft.trustedGroups) lines.push(` - ${group}`);
1527
+ }
1528
+
1529
+ const choice = await promptSelect(
1530
+ ctx,
1531
+ lines.join("\n"),
1532
+ draft.environment.length ? ["Accept and save", "Discard"] : ["Done"],
1533
+ reviewerLifecycleController.signal,
1534
+ { allowComment: false },
1535
+ );
1536
+ if (choice !== "Accept and save" || !draft.environment.length) return;
1537
+ try {
1538
+ patchAutoPermissionsConfig(path, { appendEnvironment: draft.environment });
1539
+ ctx.ui.notify(`Saved ${draft.environment.length} environment entr${draft.environment.length === 1 ? "y" : "ies"} to guardianPolicy.`, "info");
1540
+ } catch (error) {
1541
+ const message = error instanceof Error ? error.message : String(error);
1542
+ ctx.ui.notify(`Could not save Auto Permissions setup draft: ${message}`, "warning");
1543
+ }
1544
+ }
1545
+
1148
1546
  pi.registerCommand("auto-permissions", {
1149
- description: "Configure Auto Permissions",
1150
- handler: async (_args, ctx) => {
1547
+ description: "Configure Auto Permissions (\"setup\" drafts trust configuration from your project)",
1548
+ handler: async (args, ctx) => {
1151
1549
  if (ctx.mode !== "tui") {
1152
1550
  ctx.ui.notify("/auto-permissions requires interactive TUI mode", "warning");
1153
1551
  return;
1154
1552
  }
1553
+ if ((args ?? "").trim() === "setup") {
1554
+ await runSetupWizard(ctx);
1555
+ return;
1556
+ }
1155
1557
 
1156
1558
  const path = autoPermissionsConfigPath();
1157
1559
  let settings: ReviewerSettings;
1560
+ let denialLog: AutoPermissionsConfig["denialLog"];
1158
1561
  try {
1159
1562
  const config = loadAutoPermissionsConfig(path);
1160
1563
  settings = {
@@ -1162,6 +1565,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1162
1565
  reviewer: config.reviewer,
1163
1566
  systemPromptSource: config.systemPromptSource,
1164
1567
  };
1568
+ denialLog = config.denialLog;
1165
1569
  } catch (error) {
1166
1570
  // Editing a file we cannot validate is the one way this writer could
1167
1571
  // quietly make things worse, so refuse rather than open.
@@ -1203,6 +1607,52 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1203
1607
  return ctx.modelRegistry.find(settings.reviewer.provider, settings.reviewer.model) as MenuModel | undefined;
1204
1608
  };
1205
1609
 
1610
+ const recentDenials = (): DenialSummary[] => {
1611
+ if (!denialLog.enabled) return [];
1612
+ try {
1613
+ return readRecentDenials(denialLog.path, 20).map((record) => ({
1614
+ id: record.id,
1615
+ ts: record.ts,
1616
+ gateLabel: record.gate.label,
1617
+ command: record.command,
1618
+ verdict: record.verdict,
1619
+ reason: record.reason,
1620
+ }));
1621
+ } catch {
1622
+ return [];
1623
+ }
1624
+ };
1625
+
1626
+ /**
1627
+ * "Allow on retry": the existing override machinery, driven from the
1628
+ * denial ledger. An exact-command allow override (which already
1629
+ * generalizes correctly and survives via evidence re-injection and the
1630
+ * session entry), plus a visible injected message telling the agent it
1631
+ * may retry. No new authorization pathway.
1632
+ */
1633
+ const allowRetry = (denial: DenialSummary): void => {
1634
+ permissionOverrides.push({
1635
+ seq: overrideSeq++,
1636
+ anchorKey: reviewerLineage?.evidenceKeys.at(-1),
1637
+ gateLabel: denial.gateLabel,
1638
+ command: denial.command,
1639
+ reviewerReason: denial.reason,
1640
+ choice: "allow",
1641
+ });
1642
+ persistOverrides();
1643
+ try {
1644
+ // The same channel a prompt note uses. This is a real user decision
1645
+ // made in the menu, so a user message is honest provenance — and it
1646
+ // gives the agent a turn to actually retry.
1647
+ pi.sendUserMessage(
1648
+ `Auto Permissions: I reviewed the denied command in /auto-permissions and allowed it on retry:\n\n ${denial.command}\n\nYou may run this exact command again; a session override now authorizes it.`,
1649
+ );
1650
+ } catch {
1651
+ // The override itself is already in force; the nudge is best-effort.
1652
+ }
1653
+ ctx.ui.notify("Override added for the exact command; the agent may retry it.", "info");
1654
+ };
1655
+
1206
1656
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
1207
1657
  const host: SubmenuHost = {
1208
1658
  getSettings: () => settings,
@@ -1212,11 +1662,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1212
1662
  settingsTheme: getSettingsListTheme(),
1213
1663
  selectTheme: getSelectListTheme(),
1214
1664
  };
1665
+ const denialsHost: DenialsSubmenuHost = {
1666
+ recentDenials,
1667
+ allowRetry,
1668
+ settingsTheme: getSettingsListTheme(),
1669
+ selectTheme: getSelectListTheme(),
1670
+ requestRender: () => tui.requestRender(),
1671
+ };
1215
1672
  const list = new SettingsList(
1216
1673
  buildSettingItems(settings, {
1217
1674
  reviewerModel: createModelSubmenu(host),
1218
1675
  timeout: createTimeoutSubmenu(host),
1219
- }),
1676
+ ...(denialLog.enabled ? { recentDenials: createDenialsSubmenu(denialsHost) } : {}),
1677
+ }, undefined, denialLog.enabled ? recentDenials().length : undefined),
1220
1678
  10,
1221
1679
  host.settingsTheme,
1222
1680
  (id, value) => {
@@ -1260,8 +1718,9 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1260
1718
  reviewerLifecycleController = new AbortController();
1261
1719
  sessionActive = true;
1262
1720
  allowedConventionCommands.clear();
1263
- permissionOverrides.length = 0;
1264
- overrideSeq = 0;
1721
+ // Restore prompt decisions from the branch: a resumed session keeps its
1722
+ // allows and standing block constraints (parity with the loop budget).
1723
+ restoreOverrides(ctx.sessionManager.getBranch());
1265
1724
  // Rebuild the revise budget before the first tool call: a resumed session
1266
1725
  // that forgot its spent rounds would hand the agent a fresh set of them.
1267
1726
  loopReviseBudget.restore(ctx.sessionManager.getBranch());
@@ -1273,6 +1732,11 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1273
1732
  if (details?.success && details.command) allowedConventionCommands.add(details.command);
1274
1733
  }
1275
1734
  trustedGroups = ctx.isProjectTrusted() ? loadTrustedGroups(ctx.cwd) : new Set();
1735
+ // Snapshot the trust baseline at session start: remotes configured now are
1736
+ // inside the boundary, anything added or repointed later is not. Re-capture
1737
+ // on every session_start (resume, branch switch) so the baseline follows
1738
+ // the session the reviews belong to; the fingerprint covers the change.
1739
+ sessionEnvironment = captureSessionEnvironment(ctx.cwd);
1276
1740
  try {
1277
1741
  const config = currentConfig(ctx);
1278
1742
  if (config.ui.placement === "toolRow") registerGuardedBash(ctx);