@hank-warren/pi-auto-permissions 0.12.0 → 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,17 +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,
79
+ INJECTED_USER_MESSAGE_SYSTEM_PROMPT,
60
80
  OVERRIDE_FEEDBACK_SYSTEM_PROMPT,
61
81
  parsePermissionVerdict,
82
+ parsePrefilterVerdict,
83
+ PREFILTER_INSTRUCTION,
62
84
  SUBAGENT_CONTEXT_SYSTEM_PROMPT,
63
85
  type EvidenceCaps,
64
86
  type PermissionVerdict,
65
87
  type ReviewEvidenceRecord,
66
88
  } from "./review.js";
67
89
  import { mergeOverrideEvidence, type PermissionOverride } from "./override-evidence.js";
90
+ import {
91
+ buildSessionEnvironmentSection,
92
+ captureSessionEnvironment,
93
+ type SessionEnvironmentSnapshot,
94
+ } from "./session-environment.js";
68
95
  import { OptionSelector } from "@hank-warren/pi-permission-selector/selector.ts";
69
96
  import { PulsingApprovalIndicator } from "./approval-pulse.js";
70
97
 
@@ -134,6 +161,12 @@ function reviewerFingerprint(
134
161
  userAnswerTools: config.reviewEvidence.userAnswerTools.length
135
162
  ? [...config.reviewEvidence.userAnswerTools].sort()
136
163
  : undefined,
164
+ // Widening or narrowing the allowlist changes which records exist and
165
+ // whether they are user-source, so a cached lineage built under the old
166
+ // one is not a base the new one may append to.
167
+ userMessageTypes: config.reviewEvidence.userMessageTypes.length
168
+ ? [...config.reviewEvidence.userMessageTypes].sort()
169
+ : undefined,
137
170
  });
138
171
  }
139
172
 
@@ -236,6 +269,10 @@ function loadTrustedGroups(cwd: string): Set<string> {
236
269
  return groups;
237
270
  }
238
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
+
239
276
  function conventionReason(gate: Gate, command: string): string {
240
277
  let reason = `Convention violation: ${gate.label}\n\n${gate.message ?? "Use the configured project tooling."}`;
241
278
  const suggestion = gate.suggest?.(command);
@@ -270,6 +307,109 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
270
307
  let activeReviewerSessionId: string | undefined;
271
308
  const permissionOverrides: PermissionOverride[] = [];
272
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
+ }
273
413
  let reviewerGeneration = 0;
274
414
  let reviewerLifecycleController = new AbortController();
275
415
 
@@ -372,8 +512,21 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
372
512
  ...(loopContext ? [LOOP_CONTEXT_SYSTEM_PROMPT] : []),
373
513
  ].join("\n\n");
374
514
  // Appended outside config.systemPrompt so sessions using a customized
375
- // systemPromptFile still learn how to weigh override records.
376
- const policyPrompt = `${basePolicyPrompt}\n\n${OVERRIDE_FEEDBACK_SYSTEM_PROMPT}`;
515
+ // systemPromptFile still learn how to weigh override records. The injected
516
+ // section joins it on the same reasoning, keyed on the configured
517
+ // allowlist rather than on whether this turn's evidence happens to contain
518
+ // one: the system prompt has to be identical across the turns of a review
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);
523
+ const policyPrompt = [
524
+ basePolicyPrompt,
525
+ ...(guardianPolicySection ? [guardianPolicySection] : []),
526
+ buildSessionEnvironmentSection(sessionEnvironment),
527
+ OVERRIDE_FEEDBACK_SYSTEM_PROMPT,
528
+ ...(config.reviewEvidence.userMessageTypes.length ? [INJECTED_USER_MESSAGE_SYSTEM_PROMPT] : []),
529
+ ].join("\n\n");
377
530
  const systemPrompt = buildReviewerSystemPrompt(policyPrompt, projectInstructions);
378
531
  const fingerprint = reviewerFingerprint(mainSessionId, model, config, systemPrompt, projectTrusted);
379
532
  const evidence = mergeOverrideEvidence(
@@ -382,6 +535,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
382
535
  toolCallId,
383
536
  config.reviewEvidence.userAnswerTools,
384
537
  evidenceCaps(config),
538
+ config.reviewEvidence.userMessageTypes,
385
539
  ),
386
540
  permissionOverrides,
387
541
  );
@@ -418,6 +572,84 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
418
572
  });
419
573
 
420
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
+ }
421
653
  let userMessage = makeUserMessage(base ? evidence.slice(base.evidenceKeys.length) : fullEvidence(), base ? "delta" : "full");
422
654
  let messages = base ? [...base.messages, userMessage] : [userMessage];
423
655
  if (base && estimateReviewTokens(systemPrompt, messages) >= budget) {
@@ -497,17 +729,22 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
497
729
  * Reviewer calls never reach the session transcript, so their usage is invisible to
498
730
  * tooling that reads session files. Record content-free counters in a sidecar instead.
499
731
  */
500
- 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 {
501
733
  if (!config.usageLog.enabled) return;
502
734
  try {
503
- 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));
504
736
  } catch {
505
737
  // Usage accounting is optional and must never block a permission decision.
506
738
  }
507
739
  }
508
740
 
509
741
  function untrustedMatches(command: string, config: AutoPermissionsConfig): Gate[] {
510
- 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
+ );
511
748
  }
512
749
 
513
750
  function evidenceCaps(config: AutoPermissionsConfig): EvidenceCaps {
@@ -840,6 +1077,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
840
1077
  }
841
1078
  showReviewDisplay(ctx, config, gate, command, target, "ask_user", detail);
842
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
+ );
843
1090
  showReviewDisplay(ctx, config, gate, command, target, "blocked", detail, true);
844
1091
  return {
845
1092
  block: true,
@@ -854,6 +1101,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
854
1101
  target.toolCallId,
855
1102
  config.reviewEvidence.userAnswerTools,
856
1103
  evidenceCaps(config),
1104
+ config.reviewEvidence.userMessageTypes,
857
1105
  ),
858
1106
  permissionOverrides,
859
1107
  )
@@ -895,6 +1143,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
895
1143
  reviewerReason: detail,
896
1144
  choice: overrideChoice,
897
1145
  });
1146
+ persistOverrides();
898
1147
  }
899
1148
  }
900
1149
  if (config.evaluationLog.enabled && classification?.userChoice) {
@@ -914,6 +1163,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
914
1163
  showReviewDisplay(ctx, config, gate, command, target, "approved", "approved by user", true);
915
1164
  return undefined;
916
1165
  }
1166
+ recordDenial(
1167
+ config,
1168
+ ctx,
1169
+ target,
1170
+ gate,
1171
+ command,
1172
+ "block",
1173
+ detail,
1174
+ decisionSource === "guardian" ? "user" : "review_failure",
1175
+ );
917
1176
  showReviewDisplay(ctx, config, gate, command, target, "blocked", "blocked by user", true);
918
1177
  return { block: true, reason: "Blocked by user" };
919
1178
  } finally {
@@ -935,17 +1194,44 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
935
1194
  return { block: true, reason: error instanceof Error ? error.message : String(error) };
936
1195
  }
937
1196
  if (!config.enabled) return;
938
- const matches = untrustedMatches(command, config);
939
- 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
+ }
940
1209
  const convention = matches.find((gate) => gate.level === "convention");
941
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
+ );
942
1221
  return { block: true, reason: conventionReason(convention, command) };
943
1222
  }
944
- 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
+ }
945
1232
  if (!gate) return;
946
1233
 
947
1234
  const signal = ctx.signal;
948
- const target = { toolName: event.toolName, toolCallId: event.toolCallId };
949
1235
  showReviewDisplay(ctx, config, gate, command, target, "waiting");
950
1236
  try {
951
1237
  const verdict = await reviewCommand(
@@ -979,6 +1265,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
979
1265
  const loopContext = detectLoopContext();
980
1266
  if (loopContext) {
981
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
+ );
982
1278
  showReviewDisplay(
983
1279
  ctx,
984
1280
  config,
@@ -991,10 +1287,16 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
991
1287
  );
992
1288
  return {
993
1289
  block: true,
994
- reason: loopBlockReason({ gateLabel: gate.label, reason: verdict.reason, ...charge }),
1290
+ reason: loopBlockReason({
1291
+ gateLabel: gate.label,
1292
+ reason: verdict.reason,
1293
+ decision: verdict.decision,
1294
+ ...charge,
1295
+ }),
995
1296
  };
996
1297
  }
997
1298
  if (verdict.decision === "revise") {
1299
+ recordDenial(config, ctx, target, gate, command, "revise", verdict.reason, "guardian");
998
1300
  showReviewDisplay(ctx, config, gate, command, target, "revise", verdict.reason, true);
999
1301
  return {
1000
1302
  block: true,
@@ -1012,6 +1314,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1012
1314
  // judgment, so it charges no revision round — but it still must not open
1013
1315
  // a modal in a session with nobody to answer it.
1014
1316
  if (detectLoopContext()) {
1317
+ recordDenial(config, ctx, target, gate, command, "block", reason, "review_failure");
1015
1318
  showReviewDisplay(ctx, config, gate, command, target, "blocked", reason, true);
1016
1319
  return {
1017
1320
  block: true,
@@ -1041,7 +1344,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1041
1344
  name: "request_override",
1042
1345
  executionMode: "sequential",
1043
1346
  label: "Request Override",
1044
- 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.",
1045
1348
  parameters: Type.Object({
1046
1349
  command: Type.String({ description: "Exact command to allow" }),
1047
1350
  reason: Type.String({ description: "Why the convention does not apply" }),
@@ -1057,6 +1360,12 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1057
1360
  };
1058
1361
  }
1059
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
+ }
1060
1369
  if (!matches.length || matches.some((gate) => gate.level === "guarded")) {
1061
1370
  return {
1062
1371
  content: [{ type: "text", text: "The command is not a convention violation. Guarded commands cannot be bypassed with request_override." }],
@@ -1123,16 +1432,132 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1123
1432
  * and in every other one. reviewerFingerprint() covers provider, model and
1124
1433
  * reasoning effort, so the cached reviewer lineage self-invalidates too.
1125
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
+
1126
1546
  pi.registerCommand("auto-permissions", {
1127
- description: "Configure Auto Permissions",
1128
- handler: async (_args, ctx) => {
1547
+ description: "Configure Auto Permissions (\"setup\" drafts trust configuration from your project)",
1548
+ handler: async (args, ctx) => {
1129
1549
  if (ctx.mode !== "tui") {
1130
1550
  ctx.ui.notify("/auto-permissions requires interactive TUI mode", "warning");
1131
1551
  return;
1132
1552
  }
1553
+ if ((args ?? "").trim() === "setup") {
1554
+ await runSetupWizard(ctx);
1555
+ return;
1556
+ }
1133
1557
 
1134
1558
  const path = autoPermissionsConfigPath();
1135
1559
  let settings: ReviewerSettings;
1560
+ let denialLog: AutoPermissionsConfig["denialLog"];
1136
1561
  try {
1137
1562
  const config = loadAutoPermissionsConfig(path);
1138
1563
  settings = {
@@ -1140,6 +1565,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1140
1565
  reviewer: config.reviewer,
1141
1566
  systemPromptSource: config.systemPromptSource,
1142
1567
  };
1568
+ denialLog = config.denialLog;
1143
1569
  } catch (error) {
1144
1570
  // Editing a file we cannot validate is the one way this writer could
1145
1571
  // quietly make things worse, so refuse rather than open.
@@ -1181,6 +1607,52 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1181
1607
  return ctx.modelRegistry.find(settings.reviewer.provider, settings.reviewer.model) as MenuModel | undefined;
1182
1608
  };
1183
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
+
1184
1656
  await ctx.ui.custom<void>((tui, _theme, _keybindings, done) => {
1185
1657
  const host: SubmenuHost = {
1186
1658
  getSettings: () => settings,
@@ -1190,11 +1662,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1190
1662
  settingsTheme: getSettingsListTheme(),
1191
1663
  selectTheme: getSelectListTheme(),
1192
1664
  };
1665
+ const denialsHost: DenialsSubmenuHost = {
1666
+ recentDenials,
1667
+ allowRetry,
1668
+ settingsTheme: getSettingsListTheme(),
1669
+ selectTheme: getSelectListTheme(),
1670
+ requestRender: () => tui.requestRender(),
1671
+ };
1193
1672
  const list = new SettingsList(
1194
1673
  buildSettingItems(settings, {
1195
1674
  reviewerModel: createModelSubmenu(host),
1196
1675
  timeout: createTimeoutSubmenu(host),
1197
- }),
1676
+ ...(denialLog.enabled ? { recentDenials: createDenialsSubmenu(denialsHost) } : {}),
1677
+ }, undefined, denialLog.enabled ? recentDenials().length : undefined),
1198
1678
  10,
1199
1679
  host.settingsTheme,
1200
1680
  (id, value) => {
@@ -1238,8 +1718,9 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1238
1718
  reviewerLifecycleController = new AbortController();
1239
1719
  sessionActive = true;
1240
1720
  allowedConventionCommands.clear();
1241
- permissionOverrides.length = 0;
1242
- 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());
1243
1724
  // Rebuild the revise budget before the first tool call: a resumed session
1244
1725
  // that forgot its spent rounds would hand the agent a fresh set of them.
1245
1726
  loopReviseBudget.restore(ctx.sessionManager.getBranch());
@@ -1251,6 +1732,11 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
1251
1732
  if (details?.success && details.command) allowedConventionCommands.add(details.command);
1252
1733
  }
1253
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);
1254
1740
  try {
1255
1741
  const config = currentConfig(ctx);
1256
1742
  if (config.ui.placement === "toolRow") registerGuardedBash(ctx);