@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 +21 -0
- package/NOTICE.md +7 -0
- package/README.md +240 -0
- package/config.ts +178 -0
- package/gates.ts +21 -0
- package/index.ts +751 -0
- package/package.json +54 -0
- package/review.ts +367 -0
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
|
+
}
|