@hank-warren/pi-auto-permissions 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Can Celik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE.md ADDED
@@ -0,0 +1,7 @@
1
+ # Attribution
2
+
3
+ This package is a fork of [`@ogulcancelik/pi-auto-permissions`](https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-auto-permissions), version 0.1.3, from upstream commit `976550bff7224e5a6478f5a00da67df73c158250`.
4
+
5
+ The original work is Copyright (c) 2026 Can Celik and is used under the MIT License included in [`LICENSE`](LICENSE).
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)) are Copyright (c) 2026 Hank Warren and are released under the same MIT License.
package/README.md ADDED
@@ -0,0 +1,240 @@
1
+ # pi-auto-permissions
2
+
3
+ > **Published fork.** This is `@ogulcancelik/pi-auto-permissions` 0.1.3 (MIT, Can Celik)
4
+ > plus the dialog-answer evidence feature proposed upstream in
5
+ > [ogulcancelik/pi-extensions#24](https://github.com/ogulcancelik/pi-extensions/issues/24) /
6
+ > [PR #25](https://github.com/ogulcancelik/pi-extensions/pull/25). Switch back to the
7
+ > upstream package once it ships the feature. Never install both packages at once, or
8
+ > every Bash call is reviewed twice. The upstream `index.test.ts` harness suite is not
9
+ > vendored; the pure config/evidence tests live in `test/`.
10
+
11
+ A context-aware permission system for Pi shell commands, with an automated guardian that checks what the user actually authorized.
12
+
13
+ Pi Auto Permissions pauses configured Bash commands before execution. A guardian model reviews the exact command against a compact view of the current conversation:
14
+
15
+ - Clearly authorized and compliant commands run automatically.
16
+ - Authorized commands that violate the user's constraints are blocked with feedback so the agent can revise them.
17
+ - Commands without clear authorization are sent to the user for confirmation.
18
+ - High-risk commands always require confirmation.
19
+
20
+ Only user messages can grant permission or impose constraints. The assistant cannot authorize its own command.
21
+
22
+ ## Example
23
+
24
+ Suppose Git commits are globally guarded.
25
+
26
+ If the user says:
27
+
28
+ > Fix the failing test and commit it with a concise lowercase message.
29
+
30
+ Then a matching commit can run without another permission prompt:
31
+
32
+ ```text
33
+ git commit -m "fix retry test"
34
+ ```
35
+
36
+ But the guardian rejects a command that violates the request:
37
+
38
+ ```text
39
+ git commit -m "Fix the Failing Retry Test and Update Documentation"
40
+ ```
41
+
42
+ If the conversation never authorized a commit, an interactive Pi session asks the user before running it. A non-interactive session blocks it.
43
+
44
+ Permissions are contextual, not permanent. Each command is judged against the current conversation and the exact action being proposed.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pi install npm:@hank-warren/pi-auto-permissions
50
+ ```
51
+
52
+ For local development:
53
+
54
+ ```bash
55
+ pi install /absolute/path/to/pi-extensions/packages/pi-auto-permissions
56
+ ```
57
+
58
+ Remove or disable other extensions that gate the same commands to avoid duplicate prompts.
59
+
60
+ ## Define your policy
61
+
62
+ The extension ships with an empty command policy. Nothing is guarded until you add rules, and commands that do not match a rule run normally.
63
+
64
+ Configuration is read before every Bash command from:
65
+
66
+ ```text
67
+ $PI_CODING_AGENT_DIR/pi-auto-permissions/config.json
68
+ ```
69
+
70
+ `PI_CODING_AGENT_DIR` defaults to `~/.pi/agent`.
71
+
72
+ Here is a small starter policy for commits, pushes, and publishing:
73
+
74
+ ```json
75
+ {
76
+ "rules": [
77
+ {
78
+ "pattern": "\\bgit\\s+commit\\b",
79
+ "flags": "i",
80
+ "level": "guarded",
81
+ "group": "git",
82
+ "label": "Git commit"
83
+ },
84
+ {
85
+ "pattern": "\\bgit\\s+push\\b",
86
+ "flags": "i",
87
+ "level": "guarded",
88
+ "group": "git",
89
+ "label": "Git push"
90
+ },
91
+ {
92
+ "pattern": "\\bnpm\\s+publish\\b",
93
+ "flags": "i",
94
+ "level": "guarded",
95
+ "group": "npm",
96
+ "label": "npm publish"
97
+ }
98
+ ]
99
+ }
100
+ ```
101
+
102
+ Rule fields are:
103
+
104
+ - `pattern`: JavaScript regular expression source
105
+ - `flags`: optional regular expression flags, defaulting to `i`
106
+ - `level`: `guarded` for guardian review or `convention` for a direct policy block
107
+ - `group`: policy group used by trusted-project bypasses
108
+ - `label`: short description shown during review
109
+ - `message`: optional feedback; required for convention rules
110
+
111
+ A convention rule blocks directly with its configured feedback instead of asking the guardian. The agent can call `request_override` for a legitimate one-session exception. Overrides require user confirmation and cannot bypass guarded rules.
112
+
113
+ Set `enabled` to `false` to disable the extension. Invalid configuration fails closed and blocks Bash calls until corrected.
114
+
115
+ ## Guardian configuration
116
+
117
+ By default, the guardian uses Pi's active model with low reasoning effort and a 30-second timeout. You can select a separate low-cost model:
118
+
119
+ ```json
120
+ {
121
+ "reviewer": {
122
+ "provider": "openai-codex",
123
+ "model": "gpt-5.6-luna",
124
+ "reasoningEffort": "low",
125
+ "timeoutMs": 30000
126
+ }
127
+ }
128
+ ```
129
+
130
+ ### Guardian prompt
131
+
132
+ The bundled prompt evaluates authorization, command risk, and compliance with user constraints. Replace it inline with `systemPrompt`, or load a file:
133
+
134
+ ```json
135
+ {
136
+ "systemPromptFile": "./guardian-prompt.md"
137
+ }
138
+ ```
139
+
140
+ Relative paths resolve from the configuration directory. Set only one of `systemPrompt` or `systemPromptFile`.
141
+
142
+ The guardian must return one of three decisions:
143
+
144
+ - `approve`: execute the command
145
+ - `revise`: block it and tell the main agent what to correct
146
+ - `ask_user`: open Pi's normal confirmation prompt
147
+
148
+ ## Conversation context and caching
149
+
150
+ The guardian receives a compact chronological view of Pi's active, compaction-aware conversation context plus the exact pending command. It includes retained user and assistant text, Pi's latest compaction summary, and small summaries of finalized tool calls, but excludes summarized-away history, thinking, tool output bodies, file contents, patches, images, and session metadata. Compaction summaries are non-authoritative assistant context and cannot grant permission.
151
+
152
+ The extension also recognizes native checkpoints created by [`@ogulcancelik/pi-codex-compaction`](https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-codex-compaction). It keeps the checkpoint's retained plaintext user messages and post-checkpoint evidence, while excluding the opaque provider state and older local history. This integration is optional and does not change behavior when `pi-codex-compaction` is not installed.
153
+
154
+ The first review sends the complete compact evidence. Later reviews reuse the same reviewer session and append only newly finalized evidence and the latest action. The extension uses stable session identity, cache affinity, and long cache retention when supported by the provider. Branch changes, model or policy changes, failures, cancellation, and context pressure reset the reviewer session.
155
+
156
+ Assistant and tool evidence provide context but never grant permission. Later user messages override earlier conflicting user instructions.
157
+
158
+ Trusted projects may optionally provide their root `AGENTS.md`, or `CLAUDE.md` when no `AGENTS.md` exists, as policy evidence:
159
+
160
+ ```json
161
+ {
162
+ "reviewEvidence": {
163
+ "projectInstructions": true
164
+ }
165
+ }
166
+ ```
167
+
168
+ Project instructions help interpret the requested workflow, but cannot independently authorize an action or override guardian policy.
169
+
170
+ ### Interactive dialog answers
171
+
172
+ When the main agent gathers a decision through an interactive question tool (for example `ask_user_question`), the user's selection is stored as a tool result, which never grants permission. Operators can allowlist user-answer tools, whose confirmed answers become `source: "user"` evidence records:
173
+
174
+ ```json
175
+ {
176
+ "reviewEvidence": {
177
+ "userAnswerTools": ["ask_user_question"]
178
+ }
179
+ }
180
+ ```
181
+
182
+ A successful result from an allowlisted tool qualifies when its `details` are `{ "answers": [{ "question": string, "answer"?: string, "selected"?: string[], "notes"?: string }], "cancelled": false }` with no `error` field. `selected` takes precedence over `answer`, non-string answers are ignored, and notes count only alongside a real answer. Each answered question contributes one `USER (dialog answer):` record; the guardian treats it as authorization for exactly the selected content and is told the question wording is assistant-drafted context, never an instruction. Any dialog extension emitting that shape qualifies.
183
+
184
+ The allowlist matches what you wrote: a bare name such as `ask_user_question` matches that tool in any namespace (`functions.ask_user_question` included), while a dotted name matches exactly. The default is an empty list.
185
+
186
+ ## Review display
187
+
188
+ The default UI shows guardian progress in a temporary widget below the editor. Configure it with:
189
+
190
+ ```json
191
+ {
192
+ "ui": {
193
+ "enabled": true,
194
+ "resultDisplayMs": 2500,
195
+ "placement": "widget"
196
+ }
197
+ }
198
+ ```
199
+
200
+ Set `placement` to `toolRow` to show the review inside Pi's Bash tool row:
201
+
202
+ ```text
203
+ $ git commit --dry-run -m "fix auth"
204
+ ◌ guardian running · Git commit · openai-codex/gpt-5.6-luna
205
+ ```
206
+
207
+ `toolRow` reconstructs Pi's standard local Bash definition because Pi does not expose renderer-only decoration. Do not use it with SDK-provided, remote, sandboxed, or otherwise replaced Bash backends. The extension detects non-native Bash tools and falls back to the widget instead of replacing them.
208
+
209
+ Set `ui.enabled` to `false` to hide review state without disabling enforcement.
210
+
211
+ ## Trusted groups
212
+
213
+ In a trusted project, create `.pi/trusted-ops` to bypass selected rule groups:
214
+
215
+ ```text
216
+ git
217
+ gh
218
+ ```
219
+
220
+ Group names come from your configured rules. A trusted group bypasses all review for that group, so use it only in projects you control.
221
+
222
+ ## Failure behavior
223
+
224
+ A missing reviewer model, unavailable credentials, malformed response, timeout, cancellation, or oversized review context never auto-approves a command.
225
+
226
+ - Interactive sessions fall back to user confirmation.
227
+ - Non-interactive sessions block the command.
228
+ - Invalid configuration blocks Bash calls until corrected.
229
+
230
+ ## Security boundary
231
+
232
+ Rules match raw shell text. Quoting, variables, aliases, generated scripts, or other indirection can evade a regex, while quoted command text can cause false positives.
233
+
234
+ Pi Auto Permissions is a permission layer for normal agent behavior. It is not an operating-system sandbox or a defense against hostile shell input. Pair it with sandboxing when commands need a hard security boundary.
235
+
236
+ `reviewEvidence.userAnswerTools` widens what counts as user authorization: any code that can record a tool result under an allowlisted tool name can mint `USER (dialog answer)` evidence. Allowlist only tool names served by extensions you trust.
237
+
238
+ ## License
239
+
240
+ MIT
package/config.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import type { Gate, GateLevel } from "./gates.js";
6
+ import { AUTO_PERMISSIONS_SYSTEM_PROMPT } from "./review.js";
7
+
8
+ export const CONFIG_FILENAME = "pi-auto-permissions/config.json";
9
+
10
+ const REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
11
+ export type ReasoningEffort = typeof REASONING_EFFORTS[number];
12
+
13
+ export interface AutoPermissionsConfig {
14
+ enabled: boolean;
15
+ reviewer?: {
16
+ provider: string;
17
+ model: string;
18
+ reasoningEffort: ReasoningEffort;
19
+ timeoutMs: number;
20
+ };
21
+ systemPrompt: string;
22
+ reviewEvidence: {
23
+ projectInstructions: boolean;
24
+ userAnswerTools: string[];
25
+ };
26
+ rules: Gate[];
27
+ ui: {
28
+ enabled: boolean;
29
+ resultDisplayMs: number;
30
+ placement: "widget" | "toolRow";
31
+ };
32
+ }
33
+
34
+ interface RuleInput {
35
+ pattern?: unknown;
36
+ flags?: unknown;
37
+ level?: unknown;
38
+ group?: unknown;
39
+ label?: unknown;
40
+ message?: unknown;
41
+ }
42
+
43
+ function configPath(): string {
44
+ return process.env.PI_AUTO_PERMISSIONS_CONFIG
45
+ ? resolve(process.env.PI_AUTO_PERMISSIONS_CONFIG)
46
+ : join(getAgentDir(), CONFIG_FILENAME);
47
+ }
48
+
49
+ function readObject(path: string): Record<string, unknown> {
50
+ if (!existsSync(path)) return {};
51
+ const value = JSON.parse(readFileSync(path, "utf8"));
52
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
53
+ throw new Error("auto permissions config must be a JSON object");
54
+ }
55
+ return value as Record<string, unknown>;
56
+ }
57
+
58
+ function optionalString(value: unknown, name: string): string | undefined {
59
+ if (value === undefined) return undefined;
60
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${name} must be a non-empty string`);
61
+ return value.trim();
62
+ }
63
+
64
+ function compileRule(value: unknown, index: number): Gate {
65
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
66
+ throw new Error(`rules[${index}] must be an object`);
67
+ }
68
+ const input = value as RuleInput;
69
+ const pattern = optionalString(input.pattern, `rules[${index}].pattern`);
70
+ const group = optionalString(input.group, `rules[${index}].group`);
71
+ const label = optionalString(input.label, `rules[${index}].label`);
72
+ if (!pattern || !group || !label) throw new Error(`rules[${index}] requires pattern, group, and label`);
73
+ const flags = input.flags === undefined ? "i" : input.flags;
74
+ if (typeof flags !== "string") throw new Error(`rules[${index}].flags must be a string`);
75
+ const level: GateLevel = input.level === undefined ? "guarded" : input.level as GateLevel;
76
+ if (level !== "guarded" && level !== "convention") {
77
+ throw new Error(`rules[${index}].level must be guarded or convention`);
78
+ }
79
+ const message = optionalString(input.message, `rules[${index}].message`);
80
+ if (level === "convention" && !message) throw new Error(`rules[${index}].message is required for convention rules`);
81
+
82
+ return {
83
+ pattern: new RegExp(pattern, flags),
84
+ level,
85
+ group,
86
+ label,
87
+ message,
88
+ };
89
+ }
90
+
91
+ function resolvePrompt(raw: Record<string, unknown>, path: string): string {
92
+ const inline = optionalString(raw.systemPrompt, "systemPrompt");
93
+ const file = optionalString(raw.systemPromptFile, "systemPromptFile");
94
+ if (inline && file) throw new Error("set only one of systemPrompt and systemPromptFile");
95
+ if (inline) return inline;
96
+ if (!file) return AUTO_PERMISSIONS_SYSTEM_PROMPT;
97
+
98
+ const expanded = file.startsWith("~/") ? join(homedir(), file.slice(2)) : file;
99
+ const resolved = isAbsolute(expanded) ? expanded : resolve(dirname(path), expanded);
100
+ const prompt = readFileSync(resolved, "utf8").trim();
101
+ if (!prompt) throw new Error("systemPromptFile is empty");
102
+ return prompt;
103
+ }
104
+
105
+ function resolveReviewEvidence(raw: Record<string, unknown>): AutoPermissionsConfig["reviewEvidence"] {
106
+ if (raw.reviewEvidence === undefined) return { projectInstructions: false, userAnswerTools: [] };
107
+ if (!raw.reviewEvidence || typeof raw.reviewEvidence !== "object" || Array.isArray(raw.reviewEvidence)) {
108
+ throw new Error("reviewEvidence must be an object");
109
+ }
110
+ const evidence = raw.reviewEvidence as Record<string, unknown>;
111
+ if (evidence.projectInstructions !== undefined && typeof evidence.projectInstructions !== "boolean") {
112
+ throw new Error("reviewEvidence.projectInstructions must be boolean");
113
+ }
114
+ const rawTools = evidence.userAnswerTools === undefined ? [] : evidence.userAnswerTools;
115
+ if (!Array.isArray(rawTools) || rawTools.some((tool) => typeof tool !== "string" || !tool.trim())) {
116
+ throw new Error("reviewEvidence.userAnswerTools must be an array of non-empty strings");
117
+ }
118
+ return {
119
+ projectInstructions: evidence.projectInstructions === true,
120
+ userAnswerTools: [...new Set((rawTools as string[]).map((tool) => tool.trim()))],
121
+ };
122
+ }
123
+
124
+ function resolveUi(raw: Record<string, unknown>): AutoPermissionsConfig["ui"] {
125
+ if (raw.ui === undefined) return { enabled: true, resultDisplayMs: 2500, placement: "widget" };
126
+ if (!raw.ui || typeof raw.ui !== "object" || Array.isArray(raw.ui)) {
127
+ throw new Error("ui must be an object");
128
+ }
129
+ const ui = raw.ui as Record<string, unknown>;
130
+ if (ui.enabled !== undefined && typeof ui.enabled !== "boolean") {
131
+ throw new Error("ui.enabled must be boolean");
132
+ }
133
+ const resultDisplayMs = ui.resultDisplayMs === undefined ? 2500 : ui.resultDisplayMs;
134
+ if (!Number.isInteger(resultDisplayMs) || Number(resultDisplayMs) < 0 || Number(resultDisplayMs) > 30_000) {
135
+ throw new Error("ui.resultDisplayMs must be an integer between 0 and 30000");
136
+ }
137
+ const placement = ui.placement ?? "widget";
138
+ if (placement !== "widget" && placement !== "toolRow") {
139
+ throw new Error("ui.placement must be widget or toolRow");
140
+ }
141
+ return { enabled: ui.enabled !== false, resultDisplayMs: Number(resultDisplayMs), placement };
142
+ }
143
+
144
+ function resolveReviewer(raw: Record<string, unknown>): AutoPermissionsConfig["reviewer"] {
145
+ if (raw.reviewer === undefined) return undefined;
146
+ if (!raw.reviewer || typeof raw.reviewer !== "object" || Array.isArray(raw.reviewer)) {
147
+ throw new Error("reviewer must be an object");
148
+ }
149
+ const reviewer = raw.reviewer as Record<string, unknown>;
150
+ const provider = optionalString(reviewer.provider, "reviewer.provider");
151
+ const model = optionalString(reviewer.model, "reviewer.model");
152
+ if (!provider || !model) throw new Error("reviewer requires both provider and model");
153
+ const reasoningEffort = (optionalString(reviewer.reasoningEffort, "reviewer.reasoningEffort") ?? "low") as ReasoningEffort;
154
+ if (!REASONING_EFFORTS.includes(reasoningEffort)) {
155
+ throw new Error("reviewer.reasoningEffort is invalid");
156
+ }
157
+ const timeoutMs = reviewer.timeoutMs === undefined ? 30_000 : reviewer.timeoutMs;
158
+ if (!Number.isInteger(timeoutMs) || Number(timeoutMs) < 1_000 || Number(timeoutMs) > 300_000) {
159
+ throw new Error("reviewer.timeoutMs must be an integer between 1000 and 300000");
160
+ }
161
+ return { provider, model, reasoningEffort, timeoutMs: Number(timeoutMs) };
162
+ }
163
+
164
+ export function loadAutoPermissionsConfig(path = configPath()): AutoPermissionsConfig {
165
+ const raw = readObject(path);
166
+ if (raw.enabled !== undefined && typeof raw.enabled !== "boolean") throw new Error("enabled must be boolean");
167
+ if (raw.rules !== undefined && !Array.isArray(raw.rules)) throw new Error("rules must be an array");
168
+ const customRules = (raw.rules ?? []).map(compileRule);
169
+
170
+ return {
171
+ enabled: raw.enabled !== false,
172
+ reviewer: resolveReviewer(raw),
173
+ systemPrompt: resolvePrompt(raw, path),
174
+ reviewEvidence: resolveReviewEvidence(raw),
175
+ rules: customRules,
176
+ ui: resolveUi(raw),
177
+ };
178
+ }
package/gates.ts ADDED
@@ -0,0 +1,21 @@
1
+ export type GateLevel = "guarded" | "convention";
2
+
3
+ export interface Gate {
4
+ pattern: RegExp;
5
+ level: GateLevel;
6
+ group: string;
7
+ label: string;
8
+ message?: string;
9
+ suggest?: (command: string) => string;
10
+ }
11
+
12
+ export function findGates(command: string, rules: readonly Gate[]): Gate[] {
13
+ return rules.filter((gate) => {
14
+ gate.pattern.lastIndex = 0;
15
+ return gate.pattern.test(command);
16
+ });
17
+ }
18
+
19
+ export function findGate(command: string, rules: readonly Gate[]): Gate | undefined {
20
+ return findGates(command, rules)[0];
21
+ }