@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/CHANGELOG.md +28 -0
- package/README.md +100 -5
- package/config-writer.ts +28 -0
- package/config.ts +145 -6
- package/default-rules.ts +265 -0
- package/denial-log.ts +111 -0
- package/evaluation-log.ts +27 -1
- package/gates.ts +29 -1
- package/guardian-transport.ts +6 -6
- package/index.ts +478 -14
- package/package.json +6 -2
- package/review.ts +97 -11
- package/session-environment.ts +73 -0
- package/settings-menu.ts +137 -0
- package/setup-scan.ts +262 -0
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,
|
|
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
|
-
|
|
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
|
|
956
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1264
|
-
|
|
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);
|