@hank-warren/pi-auto-permissions 0.1.1 → 0.2.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/NOTICE.md +1 -1
- package/README.md +33 -2
- package/config.ts +25 -0
- package/evaluation-log.ts +40 -3
- package/index.ts +40 -9
- package/package.json +2 -1
- package/usage-log.ts +84 -0
package/NOTICE.md
CHANGED
|
@@ -4,4 +4,4 @@ This package is a fork of [`@ogulcancelik/pi-auto-permissions`](https://github.c
|
|
|
4
4
|
|
|
5
5
|
The original work is Copyright (c) 2026 Can Celik and is used under the MIT License included in [`LICENSE`](LICENSE).
|
|
6
6
|
|
|
7
|
-
Fork-specific changes (the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25)
|
|
7
|
+
Fork-specific changes are Copyright (c) 2026 Hank Warren and are released under the same MIT License. They include the `reviewEvidence.userAnswerTools` dialog-answer evidence feature, proposed upstream in [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) / [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25), and the prompted-review evaluation log with explicit user feedback.
|
package/README.md
CHANGED
|
@@ -185,7 +185,7 @@ The allowlist matches what you wrote: a bare name such as `ask_user_question` ma
|
|
|
185
185
|
|
|
186
186
|
## Prompted-review evaluation log
|
|
187
187
|
|
|
188
|
-
Auto Permissions can append a private JSONL regression record whenever the guardian asks for confirmation and the user
|
|
188
|
+
Auto Permissions can append a private JSONL regression record whenever the guardian asks for confirmation and the user gives explicit feedback:
|
|
189
189
|
|
|
190
190
|
```json
|
|
191
191
|
{
|
|
@@ -198,10 +198,41 @@ Auto Permissions can append a private JSONL regression record whenever the guard
|
|
|
198
198
|
|
|
199
199
|
The path defaults to `review-evals.jsonl` beside the Auto Permissions config and resolves relative to that config. The file is created with mode `0600`.
|
|
200
200
|
|
|
201
|
-
|
|
201
|
+
When logging is enabled, prompted reviews offer three choices:
|
|
202
|
+
|
|
203
|
+
- **Allow — asking was unnecessary** executes the command and records `userChoice: "allow_unnecessary"` with `expectedDecision: "approve"`.
|
|
204
|
+
- **Block** remains the second choice, blocks the command, and records `userChoice: "block"` with `expectedDecision: "ask_user"`.
|
|
205
|
+
- **Allow — asking was appropriate** executes the command and records `userChoice: "allow_appropriate"` with `expectedDecision: "ask_user"`.
|
|
206
|
+
|
|
207
|
+
When logging is disabled, the prompt retains the normal **Allow** and **Block** choices. Prompts from Pi or other extensions are unchanged.
|
|
208
|
+
|
|
209
|
+
Each version 2 record contains the collected user request, exact command, compact reviewer evidence, guardian reason, gate and session metadata, raw user choice, and both labels used for evaluation. The guardian's `actualDecision` is `ask_user`. Automatic-review failures are identified separately with `decisionSource: "review_failure"`. Existing version 1 records can remain in the same JSONL file.
|
|
202
210
|
|
|
203
211
|
Logging is disabled by default. Records can contain sensitive conversation text and shell commands, so keep the file private and out of repositories. Cancelled or interrupted prompts are not labeled or logged.
|
|
204
212
|
|
|
213
|
+
## Reviewer usage sidecar
|
|
214
|
+
|
|
215
|
+
Guardian reviews are model calls made outside the Pi agent loop, so they never appear in the session transcript and are invisible to tools that total usage from session files. Auto Permissions therefore appends one content-free record per completed review:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{"v":1,"id":"3f2b…","ts":"2026-08-11T22:41:03.118Z","source":"auto-permissions","label":"guardian","provider":"anthropic","model":"claude-fable-5","usage":{"input":812,"output":96,"cacheRead":18442,"cacheWrite":0,"reasoning":48,"cost":0.0121}}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The record carries identity, timing, and counters only. It never contains prompts, commands, reviewer evidence, verdicts, or responses, which is what makes it safe to keep on by default:
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"usageLog": {
|
|
226
|
+
"enabled": true,
|
|
227
|
+
"path": "./usage.jsonl"
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
The path defaults to `usage.jsonl` beside the Auto Permissions config and resolves relative to it. The file is created with mode `0600` and rotates to `usage.jsonl.1` once it passes 16 MB, keeping one previous generation. Writing is best effort: a failing sidecar never blocks or changes a permission decision.
|
|
233
|
+
|
|
234
|
+
[`@hank-warren/pi-stats`](../pi-stats) reads `<agent dir>/<extension>/usage.jsonl` sidecars and shows this usage as its own `provider/model (guardian)` row. Set `"enabled": false` to stop recording.
|
|
235
|
+
|
|
205
236
|
## Review display
|
|
206
237
|
|
|
207
238
|
The default UI shows guardian progress in a temporary widget below the editor. Configure it with:
|
package/config.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface AutoPermissionsConfig {
|
|
|
27
27
|
enabled: boolean;
|
|
28
28
|
path: string;
|
|
29
29
|
};
|
|
30
|
+
usageLog: {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
path: string;
|
|
33
|
+
};
|
|
30
34
|
rules: Gate[];
|
|
31
35
|
ui: {
|
|
32
36
|
enabled: boolean;
|
|
@@ -147,6 +151,26 @@ function resolveEvaluationLog(
|
|
|
147
151
|
};
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
function resolveUsageLog(
|
|
155
|
+
raw: Record<string, unknown>,
|
|
156
|
+
configFilePath: string,
|
|
157
|
+
): AutoPermissionsConfig["usageLog"] {
|
|
158
|
+
const defaultPath = resolve(dirname(configFilePath), "usage.jsonl");
|
|
159
|
+
if (raw.usageLog === undefined) return { enabled: true, path: defaultPath };
|
|
160
|
+
if (!raw.usageLog || typeof raw.usageLog !== "object" || Array.isArray(raw.usageLog)) {
|
|
161
|
+
throw new Error("usageLog must be an object");
|
|
162
|
+
}
|
|
163
|
+
const usageLog = raw.usageLog as Record<string, unknown>;
|
|
164
|
+
if (usageLog.enabled !== undefined && typeof usageLog.enabled !== "boolean") {
|
|
165
|
+
throw new Error("usageLog.enabled must be boolean");
|
|
166
|
+
}
|
|
167
|
+
const enabled = usageLog.enabled !== false;
|
|
168
|
+
const configuredPath = optionalString(usageLog.path, "usageLog.path");
|
|
169
|
+
if (!configuredPath) return { enabled, path: defaultPath };
|
|
170
|
+
const expanded = configuredPath.startsWith("~/") ? join(homedir(), configuredPath.slice(2)) : configuredPath;
|
|
171
|
+
return { enabled, path: isAbsolute(expanded) ? expanded : resolve(dirname(configFilePath), expanded) };
|
|
172
|
+
}
|
|
173
|
+
|
|
150
174
|
function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
|
|
151
175
|
if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
|
|
152
176
|
if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
|
|
@@ -199,6 +223,7 @@ export function loadAutoPermissionsConfig(path = configPath()): AutoPermissionsC
|
|
|
199
223
|
systemPrompt: resolvePrompt(raw, path),
|
|
200
224
|
reviewEvidence: resolveReviewEvidence(raw),
|
|
201
225
|
evaluationLog: resolveEvaluationLog(raw, path),
|
|
226
|
+
usageLog: resolveUsageLog(raw, path),
|
|
202
227
|
rules: customRules,
|
|
203
228
|
ui: resolveUi(raw),
|
|
204
229
|
};
|
package/evaluation-log.ts
CHANGED
|
@@ -2,8 +2,31 @@ import { chmodSync, mkdirSync, appendFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import type { PermissionDecision, ReviewEvidenceRecord } from "./review.js";
|
|
4
4
|
|
|
5
|
+
export const PROMPT_FEEDBACK_OPTIONS = {
|
|
6
|
+
allowUnnecessary: "Allow — asking was unnecessary",
|
|
7
|
+
allowAppropriate: "Allow — asking was appropriate",
|
|
8
|
+
block: "Block",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
export type PromptEvaluationUserChoice = "allow_unnecessary" | "allow_appropriate" | "block";
|
|
12
|
+
|
|
13
|
+
export interface PromptChoiceClassification {
|
|
14
|
+
allowsExecution: boolean;
|
|
15
|
+
userChoice?: PromptEvaluationUserChoice;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function permissionPromptOptions(evaluationLoggingEnabled: boolean): string[] {
|
|
19
|
+
return evaluationLoggingEnabled
|
|
20
|
+
? [
|
|
21
|
+
PROMPT_FEEDBACK_OPTIONS.allowUnnecessary,
|
|
22
|
+
PROMPT_FEEDBACK_OPTIONS.block,
|
|
23
|
+
PROMPT_FEEDBACK_OPTIONS.allowAppropriate,
|
|
24
|
+
]
|
|
25
|
+
: ["Allow", "Block"];
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
export interface PromptEvaluationRecord {
|
|
6
|
-
version:
|
|
29
|
+
version: 2;
|
|
7
30
|
timestamp: string;
|
|
8
31
|
sessionId: string;
|
|
9
32
|
cwd: string;
|
|
@@ -18,14 +41,28 @@ export interface PromptEvaluationRecord {
|
|
|
18
41
|
actualDecision: "ask_user";
|
|
19
42
|
actualReason: string;
|
|
20
43
|
decisionSource: "guardian" | "review_failure";
|
|
21
|
-
userChoice:
|
|
44
|
+
userChoice: PromptEvaluationUserChoice;
|
|
22
45
|
expectedDecision: Extract<PermissionDecision, "approve" | "ask_user">;
|
|
23
46
|
}
|
|
24
47
|
|
|
48
|
+
export function classifyPromptChoice(choice: string | undefined): PromptChoiceClassification | undefined {
|
|
49
|
+
if (choice === "Allow") return { allowsExecution: true };
|
|
50
|
+
if (choice === PROMPT_FEEDBACK_OPTIONS.allowUnnecessary) {
|
|
51
|
+
return { allowsExecution: true, userChoice: "allow_unnecessary" };
|
|
52
|
+
}
|
|
53
|
+
if (choice === PROMPT_FEEDBACK_OPTIONS.allowAppropriate) {
|
|
54
|
+
return { allowsExecution: true, userChoice: "allow_appropriate" };
|
|
55
|
+
}
|
|
56
|
+
if (choice === PROMPT_FEEDBACK_OPTIONS.block) {
|
|
57
|
+
return { allowsExecution: false, userChoice: "block" };
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
25
62
|
export function expectedDecisionForChoice(
|
|
26
63
|
choice: PromptEvaluationRecord["userChoice"],
|
|
27
64
|
): PromptEvaluationRecord["expectedDecision"] {
|
|
28
|
-
return choice === "
|
|
65
|
+
return choice === "allow_unnecessary" ? "approve" : "ask_user";
|
|
29
66
|
}
|
|
30
67
|
|
|
31
68
|
export function appendPromptEvaluation(path: string, record: PromptEvaluationRecord): void {
|
package/index.ts
CHANGED
|
@@ -8,8 +8,15 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|
|
8
8
|
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { loadAutoPermissionsConfig, type AutoPermissionsConfig } from "./config.js";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
appendPromptEvaluation,
|
|
13
|
+
classifyPromptChoice,
|
|
14
|
+
expectedDecisionForChoice,
|
|
15
|
+
permissionPromptOptions,
|
|
16
|
+
type PromptEvaluationUserChoice,
|
|
17
|
+
} from "./evaluation-log.js";
|
|
12
18
|
import { findGates, type Gate } from "./gates.js";
|
|
19
|
+
import { appendUsageRecord, buildUsageLogRecord } from "./usage-log.js";
|
|
13
20
|
import {
|
|
14
21
|
buildReviewEnvelope,
|
|
15
22
|
collectReviewEvidence,
|
|
@@ -351,6 +358,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
351
358
|
if (response.stopReason === "error") {
|
|
352
359
|
throw new Error(response.errorMessage ?? "reviewer request failed");
|
|
353
360
|
}
|
|
361
|
+
recordReviewerUsage(config, model, response.usage);
|
|
354
362
|
const verdict = parsePermissionVerdict(assistantText(response.content));
|
|
355
363
|
if (!sessionActive || signal?.aborted || reviewerGeneration !== attemptGeneration) {
|
|
356
364
|
throw new Error("review timed out or was cancelled");
|
|
@@ -373,6 +381,19 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
373
381
|
}
|
|
374
382
|
}
|
|
375
383
|
|
|
384
|
+
/**
|
|
385
|
+
* Reviewer calls never reach the session transcript, so their usage is invisible to
|
|
386
|
+
* tooling that reads session files. Record content-free counters in a sidecar instead.
|
|
387
|
+
*/
|
|
388
|
+
function recordReviewerUsage(config: AutoPermissionsConfig, model: { provider: string; id: string }, usage: unknown): void {
|
|
389
|
+
if (!config.usageLog.enabled) return;
|
|
390
|
+
try {
|
|
391
|
+
appendUsageRecord(config.usageLog.path, buildUsageLogRecord(model.provider, model.id, usage));
|
|
392
|
+
} catch {
|
|
393
|
+
// Usage accounting is optional and must never block a permission decision.
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
376
397
|
function untrustedMatches(command: string, config: AutoPermissionsConfig): Gate[] {
|
|
377
398
|
return findGates(command, config.rules).filter((gate) => !trustedGroups.has(gate.group));
|
|
378
399
|
}
|
|
@@ -530,7 +551,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
530
551
|
target: ReviewTarget,
|
|
531
552
|
relevantContext: ReviewEvidenceRecord[],
|
|
532
553
|
decisionSource: "guardian" | "review_failure",
|
|
533
|
-
userChoice:
|
|
554
|
+
userChoice: PromptEvaluationUserChoice,
|
|
534
555
|
): void {
|
|
535
556
|
if (!config.evaluationLog.enabled) return;
|
|
536
557
|
try {
|
|
@@ -539,7 +560,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
539
560
|
.map((record) => record.text)
|
|
540
561
|
.join("\n");
|
|
541
562
|
appendPromptEvaluation(config.evaluationLog.path, {
|
|
542
|
-
version:
|
|
563
|
+
version: 2,
|
|
543
564
|
timestamp: new Date().toISOString(),
|
|
544
565
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
545
566
|
cwd: ctx.cwd,
|
|
@@ -602,7 +623,7 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
602
623
|
try {
|
|
603
624
|
choice = await ctx.ui.select(
|
|
604
625
|
`${gate.label} — Auto Permissions needs approval\n\n${detail}\n\n${command}`,
|
|
605
|
-
|
|
626
|
+
permissionPromptOptions(config.evaluationLog.enabled),
|
|
606
627
|
{ signal: promptSignal },
|
|
607
628
|
);
|
|
608
629
|
} catch (error) {
|
|
@@ -616,14 +637,24 @@ export default function autoPermissionsExtension(pi: ExtensionAPI) {
|
|
|
616
637
|
if (sessionActive) clearReviewDisplay(ctx, target);
|
|
617
638
|
return cancelled();
|
|
618
639
|
}
|
|
619
|
-
|
|
620
|
-
|
|
640
|
+
const classification = classifyPromptChoice(choice);
|
|
641
|
+
if (config.evaluationLog.enabled && classification?.userChoice) {
|
|
642
|
+
logPromptEvaluation(
|
|
643
|
+
gate,
|
|
644
|
+
command,
|
|
645
|
+
detail,
|
|
646
|
+
config,
|
|
647
|
+
ctx,
|
|
648
|
+
target,
|
|
649
|
+
evaluationContext,
|
|
650
|
+
decisionSource,
|
|
651
|
+
classification.userChoice,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
if (classification?.allowsExecution) {
|
|
621
655
|
showReviewDisplay(ctx, config, gate, command, target, "approved", "approved by user", true);
|
|
622
656
|
return undefined;
|
|
623
657
|
}
|
|
624
|
-
if (choice === "Block") {
|
|
625
|
-
logPromptEvaluation(gate, command, detail, config, ctx, target, evaluationContext, decisionSource, "block");
|
|
626
|
-
}
|
|
627
658
|
showReviewDisplay(ctx, config, gate, command, target, "blocked", "blocked by user", true);
|
|
628
659
|
return { block: true, reason: "Blocked by user" };
|
|
629
660
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-auto-permissions",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Fork of @ogulcancelik/pi-auto-permissions with dialog-answer user evidence: context-aware Bash permissions for Pi with automated guardian review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"evaluation-log.ts",
|
|
44
44
|
"gates.ts",
|
|
45
45
|
"review.ts",
|
|
46
|
+
"usage-log.ts",
|
|
46
47
|
"README.md",
|
|
47
48
|
"LICENSE"
|
|
48
49
|
],
|
package/usage-log.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { appendFileSync, chmodSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Rotate once the sidecar grows past this size; one previous generation is kept. */
|
|
6
|
+
export const USAGE_LOG_ROTATE_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export interface UsageLogTotals {
|
|
9
|
+
input: number;
|
|
10
|
+
output: number;
|
|
11
|
+
cacheRead: number;
|
|
12
|
+
cacheWrite: number;
|
|
13
|
+
reasoning: number;
|
|
14
|
+
cost: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One model call recorded outside the Pi session transcript. The record is deliberately
|
|
19
|
+
* content free: it carries identity, timing, and counters only, never prompts, commands,
|
|
20
|
+
* evidence, or responses.
|
|
21
|
+
*/
|
|
22
|
+
export interface UsageLogRecord {
|
|
23
|
+
v: 1;
|
|
24
|
+
id: string;
|
|
25
|
+
ts: string;
|
|
26
|
+
source: string;
|
|
27
|
+
label: string;
|
|
28
|
+
provider: string;
|
|
29
|
+
model: string;
|
|
30
|
+
usage: UsageLogTotals;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function count(value: unknown): number {
|
|
34
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Normalize a pi-ai usage payload, whose cost is an object with a total. */
|
|
38
|
+
export function usageLogTotals(usage: unknown): UsageLogTotals {
|
|
39
|
+
const record = (usage && typeof usage === "object" ? usage : {}) as Record<string, unknown>;
|
|
40
|
+
const cost = (record.cost && typeof record.cost === "object" ? record.cost : {}) as Record<string, unknown>;
|
|
41
|
+
return {
|
|
42
|
+
input: count(record.input),
|
|
43
|
+
output: count(record.output),
|
|
44
|
+
cacheRead: count(record.cacheRead),
|
|
45
|
+
cacheWrite: count(record.cacheWrite),
|
|
46
|
+
reasoning: count(record.reasoning),
|
|
47
|
+
cost: count(typeof record.cost === "number" ? record.cost : cost.total),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildUsageLogRecord(
|
|
52
|
+
provider: string,
|
|
53
|
+
model: string,
|
|
54
|
+
usage: unknown,
|
|
55
|
+
label = "guardian",
|
|
56
|
+
source = "auto-permissions",
|
|
57
|
+
): UsageLogRecord {
|
|
58
|
+
return {
|
|
59
|
+
v: 1,
|
|
60
|
+
id: randomUUID(),
|
|
61
|
+
ts: new Date().toISOString(),
|
|
62
|
+
source,
|
|
63
|
+
label,
|
|
64
|
+
provider,
|
|
65
|
+
model,
|
|
66
|
+
usage: usageLogTotals(usage),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rotateIfLarge(path: string): void {
|
|
71
|
+
try {
|
|
72
|
+
if (statSync(path).size < USAGE_LOG_ROTATE_BYTES) return;
|
|
73
|
+
renameSync(path, `${path}.1`);
|
|
74
|
+
} catch {
|
|
75
|
+
// A missing or unrotatable sidecar simply keeps appending.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function appendUsageRecord(path: string, record: UsageLogRecord): void {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
81
|
+
rotateIfLarge(path);
|
|
82
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
83
|
+
chmodSync(path, 0o600);
|
|
84
|
+
}
|