@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/CHANGELOG.md +38 -0
- package/README.md +120 -5
- package/config-writer.ts +28 -0
- package/config.ts +197 -7
- 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 +503 -17
- package/loop-revise-budget.ts +48 -5
- package/package.json +6 -2
- package/review.ts +133 -1
- 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,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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
939
|
-
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
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
|
-
|
|
1242
|
-
|
|
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);
|