@adeildo/pi-ask-permission 4.0.0 → 5.0.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 +1 -1
- package/README.md +187 -72
- package/package.json +6 -3
- package/src/core/answer.ts +8 -0
- package/src/core/config/decode.ts +40 -6
- package/src/core/config/patterns.ts +0 -6
- package/src/core/config/schema.ts +13 -0
- package/src/core/config/settings.ts +318 -226
- package/src/core/config/store.ts +11 -4
- package/src/core/decide.ts +131 -19
- package/src/core/folders.ts +159 -0
- package/src/core/judge/backends/factory.ts +2 -2
- package/src/core/judge/backends/pi-model.ts +2 -1
- package/src/core/judge/compose.ts +0 -1
- package/src/core/judge/config.ts +34 -11
- package/src/core/judge/decode.ts +0 -3
- package/src/core/judge/gate.ts +9 -4
- package/src/core/judge/pipeline.ts +7 -4
- package/src/core/judge/policy.ts +1 -1
- package/src/core/judge/report.ts +5 -4
- package/src/core/judge/request.ts +19 -4
- package/src/core/judge/types.ts +11 -0
- package/src/core/mcp.ts +130 -0
- package/src/core/mode.ts +47 -25
- package/src/core/readonly-bash.ts +167 -4
- package/src/core/tools.ts +7 -3
- package/src/core/workspace.ts +107 -16
- package/src/index.ts +15 -2
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +87 -28
- package/src/pi/intent.ts +34 -0
- package/src/pi/mode.ts +63 -16
- package/src/pi/screen.ts +293 -0
- package/src/pi/session-entries.ts +46 -8
- package/src/pi/session.ts +52 -11
- package/src/ui/decision-options.ts +58 -12
- package/src/ui/dialog.ts +135 -23
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +170 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/picker.ts +0 -90
- package/src/ui/settings/judge.ts +0 -299
- package/src/ui/settings/screen.ts +0 -268
- package/src/ui/settings/status.ts +0 -53
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ Answer `yes`, `always yes`, or `deny`, and add a note if you want. The note reac
|
|
|
24
24
|
pi install npm:@adeildo/pi-ask-permission
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
Then start pi as usual. There is nothing to configure.
|
|
27
|
+
Then start pi as usual. There is nothing to configure. It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
|
|
28
28
|
|
|
29
29
|
`pi-ask-permission` on npm is this same extension under an older name, and it reads the same config, grants and sessions. To switch:
|
|
30
30
|
|
|
@@ -38,7 +38,7 @@ pi install npm:@adeildo/pi-ask-permission
|
|
|
38
38
|
Ask the agent to do something that writes, like `run the tests`. The dialog opens before the command runs:
|
|
39
39
|
|
|
40
40
|
```
|
|
41
|
-
╭─ permission
|
|
41
|
+
╭─ permission bash ────────────────────────────────────╮
|
|
42
42
|
│ pnpm test │
|
|
43
43
|
│ │
|
|
44
44
|
│ ❯ 1 yes │
|
|
@@ -53,6 +53,32 @@ Ask the agent to do something that writes, like `run the tests`. The dialog open
|
|
|
53
53
|
|
|
54
54
|
Reads inside the project never ask. `read`, `grep`, `find`, `ls`, and bash commands that only read, like `cat`, `git log`, or `rg`, run on their own. An `edit` or `write` shows the diff it would make.
|
|
55
55
|
|
|
56
|
+
## One dialog for everything
|
|
57
|
+
|
|
58
|
+
With [`@adeildo/pi-ask-questions`](../ask-questions) installed, this package does not draw this dialog itself: it asks through the questions package, so the permission ask and the questions the agent asks are one dialog, with the same keys and a note on any option.
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
╭─ permission ─────────────────────────────────────────╮
|
|
62
|
+
│ bash wants to run: │
|
|
63
|
+
│ │
|
|
64
|
+
│ git push --force origin main │
|
|
65
|
+
│ │
|
|
66
|
+
│ ▲ the judge leaned deny but was only 61% confident │
|
|
67
|
+
│ │
|
|
68
|
+
│ ❯ 1 yes, run it │
|
|
69
|
+
│ Run this call now, and ask again next time. │
|
|
70
|
+
│ 2 always yes │
|
|
71
|
+
│ 3 no │
|
|
72
|
+
│ 4 Type something. │
|
|
73
|
+
│ │
|
|
74
|
+
│ ↑↓ or 1-4 move enter choose tab note esc cancel │
|
|
75
|
+
╰──────────────────────────────────────────────────────╯
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Everything the dialog showed before is still there, as an option or in the question: the call, the summary, the reason it is asking and the diff of the change. `always yes` asks two more questions, which calls to remember and for how long. An answer typed in your own words instead of a pick blocks the call and becomes the reason the model reads.
|
|
79
|
+
|
|
80
|
+
Without that package the dialog below is the one you get, and RPC hosts always get it, since they cannot draw a terminal component.
|
|
81
|
+
|
|
56
82
|
## Everyday use
|
|
57
83
|
|
|
58
84
|
### Stop answering the same question
|
|
@@ -60,7 +86,7 @@ Reads inside the project never ask. `read`, `grep`, `find`, `ls`, and bash comma
|
|
|
60
86
|
Pick `always yes`, then choose how much to remember and for how long:
|
|
61
87
|
|
|
62
88
|
```
|
|
63
|
-
╭─ permission
|
|
89
|
+
╭─ permission bash ────────────────────────────────────╮
|
|
64
90
|
│ pnpm test │
|
|
65
91
|
│ │
|
|
66
92
|
│ always yes for... │
|
|
@@ -81,19 +107,78 @@ We recommend the narrowest level, which is preselected. `pnpm` would also approv
|
|
|
81
107
|
| this project | every session in this project | `.pi/extensions/pi-ask-permission/always-yes.json` |
|
|
82
108
|
| everywhere | every session | `~/.pi/agent/extensions/pi-ask-permission/always-yes.json` |
|
|
83
109
|
|
|
84
|
-
|
|
110
|
+
The `Always yes` section of the settings screen shows how many rules each scope holds, and forgets them.
|
|
85
111
|
|
|
86
112
|
### Let the agent work
|
|
87
113
|
|
|
88
114
|
Press `Alt+M` to switch modes. The status bar shows the one you are in.
|
|
89
115
|
|
|
90
|
-
| Mode
|
|
91
|
-
|
|
|
92
|
-
| `manual`
|
|
93
|
-
| `
|
|
94
|
-
| `
|
|
116
|
+
| Mode | Runs without asking |
|
|
117
|
+
| -------- | -------------------------------------------------------------------------- |
|
|
118
|
+
| `manual` | reads (`allow` and read-only bash) and always yes |
|
|
119
|
+
| `edits` | the same, plus file edits and writes |
|
|
120
|
+
| `judge` | the same as `edits`, and the [judge](#let-a-model-decide) decides the rest |
|
|
121
|
+
| `full` | everything |
|
|
122
|
+
|
|
123
|
+
The workspace comes before the mode. A call outside `workspace.roots` asks you in every mode, even `full`. `Alt+W` lets calls outside through for this session, and a second press puts the check back. The status bar shows `anywhere` in red while it is off.
|
|
124
|
+
|
|
125
|
+
A resumed session keeps its mode and its `Alt+W` choice. A new one starts from `mode` and `workspace.outside` in the settings.
|
|
126
|
+
|
|
127
|
+
### Read a folder next door
|
|
128
|
+
|
|
129
|
+
In a monorepo, a session in `apps/api` that reads `apps/web` leaves the workspace. The dialog says so and offers to open the repository for reads. The cursor starts on that answer:
|
|
130
|
+
|
|
131
|
+
```text
|
|
132
|
+
╭─ permission bash ────────────────────────────────────────────╮
|
|
133
|
+
│ cd ~/Projects/grace/apps/web && git status --short | head │
|
|
134
|
+
│ ▲ reads outside the workspace │
|
|
135
|
+
│ │
|
|
136
|
+
│ 1 yes │
|
|
137
|
+
│ ❯ 2 yes, and allow reads in ~/Projects/grace repo root │
|
|
138
|
+
│ 3 always yes │
|
|
139
|
+
│ 4 deny │
|
|
140
|
+
│ │
|
|
141
|
+
│ ←→ folder s keep for this project tab note esc deny │
|
|
142
|
+
╰──────────────────────────────────────────────────────────────╯
|
|
143
|
+
```
|
|
95
144
|
|
|
96
|
-
|
|
145
|
+
Press `enter` and the next reads in `~/Projects/grace` run without asking, until the session ends. `←` and `→` move the folder one level up or down. `s` keeps it for every session in this project, in `.pi/extensions/pi-ask-permission/folders.json`. An untrusted project keeps it only until pi exits.
|
|
146
|
+
|
|
147
|
+
A write outside gets `yes, and add … to the workspace` instead, and the cursor stays on `yes`. A folder opened for reads never lets a write through. The dialog never offers your home or a folder above it.
|
|
148
|
+
|
|
149
|
+
The status bar counts the open folders, like `+1 folder`. The `Folders` section of the settings screen closes them.
|
|
150
|
+
|
|
151
|
+
### Calls to an MCP server
|
|
152
|
+
|
|
153
|
+
Pi registers each tool an MCP server offers as `mcp__<server>__<tool>`. The settings screen has a row per server under `MCP servers`, and each one follows a policy of its own:
|
|
154
|
+
|
|
155
|
+
| Policy | Runs without asking |
|
|
156
|
+
| ------------- | -------------------------------------------------------------------------- |
|
|
157
|
+
| `ask me` | Nothing, in any mode. Every call comes to you |
|
|
158
|
+
| `trust hints` | A call the server declares read-only. The rest follows the mode you are in |
|
|
159
|
+
| `allow` | Every call |
|
|
160
|
+
| `deny` | Nothing. Every call is blocked, whatever the mode says |
|
|
161
|
+
|
|
162
|
+
`trust hints` is the default, and the hint is what the server claims about its own tool. Pi does not verify it, so a server you do not fully trust belongs on `ask me`. A server that declares nothing reads as a writer, so with `trust hints` the mode decides: `manual` and `edits` ask, `judge` judges, `full` runs.
|
|
163
|
+
|
|
164
|
+
`deny` and `ask me` outrank the mode, `full` included. The `allow` list does not bring a denied server back.
|
|
165
|
+
|
|
166
|
+
The resource tools pi adds for reading resources name the server in their arguments rather than in the tool name, so no server policy covers them: `list_mcp_resources`, `list_mcp_resource_templates` and `read_mcp_resource`. They declare themselves read-only, so the read-only layer runs them whatever the mode.
|
|
167
|
+
|
|
168
|
+
The dialog names the server and repeats what it declares, and says when a script issued the call:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
╭─ permission sauron:delete_dashboard ────────────────────────╮
|
|
172
|
+
│ {"uid":"abc","id":12} │
|
|
173
|
+
│ sauron: destructive │
|
|
174
|
+
│ │
|
|
175
|
+
│ 1 yes │
|
|
176
|
+
│ 2 always yes │
|
|
177
|
+
│ ❯ 3 deny │
|
|
178
|
+
╰─────────────────────────────────────────────────────────────╯
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Pi's `codemode` tool runs a script that calls other tools, and `tool_search` loads a tool for the next call. Both run without asking, because every call they make reaches this gate on its own, and the dialog says it came from a script.
|
|
97
182
|
|
|
98
183
|
### Correct the agent
|
|
99
184
|
|
|
@@ -103,13 +188,33 @@ If you are typing in the editor when a call arrives, the dialog waits until you
|
|
|
103
188
|
|
|
104
189
|
## Let a model decide
|
|
105
190
|
|
|
106
|
-
|
|
191
|
+
In the `judge` mode, a model answers every call that is not a read or an edit. You only see the ones it is unsure about. We recommend this setup:
|
|
107
192
|
|
|
108
193
|
1. Run `/login typesafe` to use Jev, a fast model that answers with a confidence. Any model you set up in pi works too.
|
|
109
|
-
2.
|
|
194
|
+
2. Switch to `judge` with `Alt+M`, open the settings with `Alt+S`, and turn on `Dry run` in `Judge`. The judge now shows its verdict as a card, and you still decide.
|
|
110
195
|
3. Pick a policy. `Standard development` allows edits, tests, builds, and local git, and asks about installs, network, and anything destructive.
|
|
111
196
|
4. After a few sessions of agreeing with it, turn off `Dry run`.
|
|
112
197
|
|
|
198
|
+
The `Judge` section has five settings, then `Test the judge` and the session's verdicts.
|
|
199
|
+
|
|
200
|
+
| Row | Does |
|
|
201
|
+
| ------------- | ------------------------------------------------------------------------------------------- |
|
|
202
|
+
| Model | Who judges. A Jev name goes to TypeSafe, and `provider/model` is any model you set up in pi |
|
|
203
|
+
| Policy | The rules the judge follows, in plain English |
|
|
204
|
+
| Always ask me | Patterns the judge never approves, like `git push*` |
|
|
205
|
+
| Rigor | How sure the judge must be before a call runs without you |
|
|
206
|
+
| Dry run | Show the verdict, and still ask you |
|
|
207
|
+
|
|
208
|
+
A call runs when the judge approves it with at least the rigor's confidence, and its risk stays at or below the ceiling. Anything else asks you.
|
|
209
|
+
|
|
210
|
+
| Rigor | Confidence | Risk ceiling |
|
|
211
|
+
| ---------------------- | ---------- | ------------ |
|
|
212
|
+
| `cautious` | 85% | 0.45 |
|
|
213
|
+
| `balanced`, by default | 70% | 0.50 |
|
|
214
|
+
| `relaxed` | 55% | 0.60 |
|
|
215
|
+
|
|
216
|
+
The judge also reads your last message, to tell whether a call is a step of what you asked. The policy still decides, and nothing in the message overrides `Always ask me`. The card says `saw your last message` when the request carried it.
|
|
217
|
+
|
|
113
218
|
The policy is plain text, so you can start from a preset and edit it:
|
|
114
219
|
|
|
115
220
|
```text
|
|
@@ -125,23 +230,25 @@ The policy is plain text, so you can start from a preset and edit it:
|
|
|
125
230
|
Ask me.
|
|
126
231
|
```
|
|
127
232
|
|
|
128
|
-
If calls come back as `the judge could not decide`, run
|
|
233
|
+
If calls come back as `the judge could not decide`, run `Test the judge` in the `Judge` section. It sends one request and reports the model, the latency, and the error.
|
|
234
|
+
|
|
235
|
+
## Settings
|
|
129
236
|
|
|
130
|
-
|
|
237
|
+
`Alt+S` opens them. The Permission tab:
|
|
131
238
|
|
|
132
|
-
|
|
239
|
+
| Section | Has |
|
|
240
|
+
| ------------ | ------------------------------------------------- |
|
|
241
|
+
| This session | Mode and outside policy for this session |
|
|
242
|
+
| New sessions | The mode and outside policy a session starts with |
|
|
243
|
+
| Workspace | The project folders |
|
|
244
|
+
| MCP servers | One row per server, with its policy |
|
|
245
|
+
| Reads | Tools that never ask, read-only bash |
|
|
246
|
+
| Dialog | Notes, no-dialog behavior, typing pause |
|
|
247
|
+
| Judge | Model, policy, rigor, dry run, a test, the log |
|
|
248
|
+
| Always yes | Rule counts, and forget |
|
|
249
|
+
| Folders | Folders opened from the dialog, and close |
|
|
133
250
|
|
|
134
|
-
|
|
135
|
-
| ---------------------- | ----------------------------------------------------------- |
|
|
136
|
-
| `/perm` | Open the settings |
|
|
137
|
-
| `/perm mode` | Switch to the next mode (also `Alt+M`) |
|
|
138
|
-
| `/perm mode auto` | Switch to a mode (also `manual`, `accept-edits`) |
|
|
139
|
-
| `/perm status` | Show the config, always yes, and file paths |
|
|
140
|
-
| `/perm forget` | Forget this session's always yes |
|
|
141
|
-
| `/perm forget project` | Forget this project's always yes (also `everywhere`, `all`) |
|
|
142
|
-
| `/perm judge on` | Turn the judge on (also `off`) |
|
|
143
|
-
| `/perm judge log` | Show this session's judge decisions |
|
|
144
|
-
| `/perm judge test` | Send one real request and report what happened |
|
|
251
|
+
Type to search. `Delete` resets a value.
|
|
145
252
|
|
|
146
253
|
## Reference
|
|
147
254
|
|
|
@@ -151,6 +258,8 @@ Type `/perm ` and the editor suggests the rest.
|
|
|
151
258
|
| ---------------------- | ---------------------------------------------------- |
|
|
152
259
|
| `↑` `↓` or `1` `2` `3` | Move the highlight |
|
|
153
260
|
| `enter` | Confirm the highlighted row |
|
|
261
|
+
| `←` `→` | Pick the folder to open, on the folder row |
|
|
262
|
+
| `s` | Keep the folder for this project, on the folder row |
|
|
154
263
|
| `tab` | Open or close a note, or change the always yes scope |
|
|
155
264
|
| `esc` | Close the note, or deny |
|
|
156
265
|
| `ctrl+v` | Paste a clipboard image as its file path |
|
|
@@ -159,7 +268,7 @@ A long paste collapses to `[paste #1 +48 lines]` and expands when you confirm.
|
|
|
159
268
|
|
|
160
269
|
### Configuration
|
|
161
270
|
|
|
162
|
-
The settings live in the file every
|
|
271
|
+
The settings live in the file every piece shares, `~/.pi/agent/extensions/pi-harness/settings.json`, under a `permission.` prefix. Most keys have a row on the settings screen, which shows the key under its description. `mcp.servers` gets one row per connected server, and the finer judge keys below live in the file only. Only what you change is written, so a new default reaches you.
|
|
163
272
|
|
|
164
273
|
```json
|
|
165
274
|
{
|
|
@@ -168,71 +277,77 @@ The settings live in the file every pi-harness package shares, `~/.pi/agent/exte
|
|
|
168
277
|
"mode": "manual",
|
|
169
278
|
"readOnlyBash": true,
|
|
170
279
|
"workspace": { "roots": ["."], "outside": "ask" },
|
|
171
|
-
"judge": { "
|
|
280
|
+
"judge": { "model": "jev-latest" }
|
|
172
281
|
}
|
|
173
282
|
}
|
|
174
283
|
```
|
|
175
284
|
|
|
176
285
|
The ids below leave out the `permission.` prefix.
|
|
177
286
|
|
|
178
|
-
| Key | Does
|
|
179
|
-
| ------------------- |
|
|
180
|
-
| `allow` | Tools that never ask. `
|
|
181
|
-
| `mode` | The mode a new session starts in
|
|
182
|
-
| `readOnlyBash` | Run bash commands that only read without asking
|
|
183
|
-
| `notes` | `"result"` adds a note to the tool result. `"message"` sends it as its own message
|
|
184
|
-
| `noUI` | `"allow"` or `"deny"` when nobody can answer, as in print mode or a subagent. Takes a per-tool map: `{ "*": "allow", "bash": "deny" }`
|
|
185
|
-
| `workspace.roots` | Paths that count as the project. Relative, absolute, and `~` work
|
|
186
|
-
| `workspace.outside` |
|
|
187
|
-
| `typing.pause` | Milliseconds of quiet before the dialog opens while you type
|
|
188
|
-
| `typing.maxWait` | The longest the dialog waits for you to stop typing. `null` waits forever
|
|
287
|
+
| Key | Does |
|
|
288
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
289
|
+
| `allow` | Tools that never ask. `mcp__*` matches a family. It matches the tool name, so `bash` allows every command. An MCP server policy comes first |
|
|
290
|
+
| `mode` | The mode a new session starts in: `"manual"`, `"edits"`, `"judge"`, or `"full"` |
|
|
291
|
+
| `readOnlyBash` | Run bash commands that only read without asking |
|
|
292
|
+
| `notes` | `"result"` adds a note to the tool result. `"message"` sends it as its own message |
|
|
293
|
+
| `noUI` | `"allow"` or `"deny"` when nobody can answer, as in print mode or a subagent. Takes a per-tool map: `{ "*": "allow", "bash": "deny" }` |
|
|
294
|
+
| `workspace.roots` | Paths that count as the project. Relative, absolute, and `~` work |
|
|
295
|
+
| `workspace.outside` | Where a new session starts for a call outside the roots: `"ask"` you, `"deny"` it, or `"allow"` it like any other |
|
|
296
|
+
| `typing.pause` | Milliseconds of quiet before the dialog opens while you type |
|
|
297
|
+
| `typing.maxWait` | The longest the dialog waits for you to stop typing. `null` waits forever |
|
|
298
|
+
| `mcp.servers` | The policy per MCP server, like `{ "sauron": "deny" }`. The row per server on the settings screen writes this one |
|
|
189
299
|
|
|
190
300
|
The `judge` block:
|
|
191
301
|
|
|
192
|
-
| Key | Default | Does
|
|
193
|
-
| ------------------- | -------------- |
|
|
194
|
-
| `
|
|
195
|
-
| `
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `noUI` | `false` | Also judge print, JSON, and subagent runs
|
|
205
|
-
| `rememberApprovals` | `false` | A judge approval becomes always yes for this session
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
302
|
+
| Key | Default | Does |
|
|
303
|
+
| ------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
304
|
+
| `model` | `"jev-latest"` | A name that starts with `jev` goes to TypeSafe. Anything else is a pi model, as `provider/modelId` or a bare id |
|
|
305
|
+
| `policy` | Standard | The rules the judge follows |
|
|
306
|
+
| `alwaysAsk` | `[]` | Patterns the judge never approves, like `"git push*"`. A pattern matches the tool name too, so `mcp__*` catches every MCP call |
|
|
307
|
+
| `rigor` | `"balanced"` | `"cautious"`, `"balanced"`, or `"relaxed"`. Sets `thresholds` and `riskCeiling` |
|
|
308
|
+
| `dryRun` | `false` | Show the verdict, and still ask you |
|
|
309
|
+
| `thresholds` | from `rigor` | Confidence needed to allow / deny. File only. Set here, it wins over `rigor`, and the Rigor row reads `custom` until you pick a rigor |
|
|
310
|
+
| `riskCeiling` | from `rigor` | Highest risk the judge may approve. File only, and it wins over `rigor` the same way |
|
|
311
|
+
| `canDeny` | `true` | A confident no blocks the call. Off, it asks you. File only |
|
|
312
|
+
| `whenUnsure` | `"ask"` | `"ask"`, `"allow"`, or `"deny"`. File only |
|
|
313
|
+
| `whenItFails` | `"ask"` | The same, for a timeout, an error, or a missing key. File only |
|
|
314
|
+
| `noUI` | `false` | Also judge print, JSON, and subagent runs. File only |
|
|
315
|
+
| `rememberApprovals` | `false` | A judge approval becomes always yes for this session. File only |
|
|
316
|
+
| `timeoutMs` | `5000` | How long to wait for an answer. File only |
|
|
317
|
+
| `cache` | `true` | Reuse a verdict for the same call and the same last message in one session. File only |
|
|
318
|
+
|
|
319
|
+
`judge.enabled`, `judge.tools`, and `judge.provider` are gone. A session that finds them removes them from the file and says so. If `judge.enabled` was `true` and no `mode` was set, it writes `"mode": "judge"`.
|
|
320
|
+
|
|
321
|
+
A malformed value falls back and says what it dropped, so a typo never lets more through. Keys under an older name are read under the current one. `PI_CODING_AGENT_DIR` moves the file with the rest of the agent directory.
|
|
322
|
+
|
|
323
|
+
If `~/.pi/agent/extensions/pi-ask-permission/config.json` exists, the next session reads it, writes what you set there into the shared settings, and renames it to `config.json.bak`.
|
|
213
324
|
|
|
214
325
|
### How a call is decided
|
|
215
326
|
|
|
216
327
|
The first step that answers wins.
|
|
217
328
|
|
|
218
329
|
1. **Always yes** matches the tool and level: run it.
|
|
219
|
-
2. **
|
|
220
|
-
3. **
|
|
221
|
-
4. **
|
|
222
|
-
5. **Read-only
|
|
223
|
-
6. **
|
|
224
|
-
7. **
|
|
225
|
-
8. **
|
|
226
|
-
9. **
|
|
330
|
+
2. **Codemode**: `codemode` and `tool_search` themselves run. Every call inside is decided on its own.
|
|
331
|
+
3. **Workspace**: a call outside `workspace.roots` asks you, or is blocked with this session's outside set to `deny`. Nothing below can approve it. The call goes on with `allow`, or when every path it reaches is in a folder you opened.
|
|
332
|
+
4. **MCP**: the policy of the server. `deny` blocks, `allow` runs, `ask` asks, and `trust hints` decides nothing here.
|
|
333
|
+
5. **Read-only hint**: the tool declares that it only reads, run it.
|
|
334
|
+
6. **Mode**: `full` runs it, `edits` and `judge` run an edit.
|
|
335
|
+
7. **Allow list**: the tool is in `allow`, run it.
|
|
336
|
+
8. **Read-only bash**: the command only reads, and its paths can be read, run it.
|
|
337
|
+
9. **Judge**, in the `judge` mode: a confident yes runs it, a confident no blocks it.
|
|
338
|
+
10. **No UI**: `noUI` decides.
|
|
339
|
+
11. **Edit check**: an `edit` that cannot apply is blocked with pi's own error, so you never approve a failure.
|
|
340
|
+
12. **You**, in the dialog.
|
|
227
341
|
|
|
228
342
|
The judge answers three questions: a verdict, how reversible the call is, and whether it touches secrets. Code combines them into `risk = 0.6 × reversibility + 0.4 × sensitive` and approves only when the verdict is `allow`, confidence clears `thresholds.allow`, and risk is at most `riskCeiling`. The judge treats the tool call as data, so a command cannot talk its way past the policy or `alwaysAsk`.
|
|
229
343
|
|
|
230
344
|
### Limits
|
|
231
345
|
|
|
232
346
|
- Always yes matches text. `cd /repo && pnpm test` offers `cd`, `cd /repo`, and the whole line, not `pnpm test`.
|
|
233
|
-
- A bash path
|
|
347
|
+
- A bash path hidden behind `$HOME`, `$SECRET`, or `"$@"` counts as outside in `manual` and `edits`. In `judge` the judge decides it, even when the command only reads. In `full` it runs. Redirects to `/dev/null` and the other device files stay inside.
|
|
234
348
|
- The read-only check is a classifier, not a sandbox. It trusts the command name as written and does not resolve `PATH`. It refuses anything it cannot prove harmless, so a few safe commands still ask.
|
|
235
|
-
- The judge is a model, and it can be wrong. It sees the tool call, so do not judge calls that carry secrets you would not send to its provider.
|
|
349
|
+
- The judge is a model, and it can be wrong. It sees the tool call and your last message, so do not judge calls or messages that carry secrets you would not send to its provider.
|
|
350
|
+
- Pi's `codemode` tool runs a script that calls other tools. The script itself does not ask, and every call it makes goes through the same steps on its own, saying so in the dialog. The judge reads the arguments of an MCP call as data, like any other call.
|
|
236
351
|
- If you want deterministic rules and no human in the loop, use a sandbox instead.
|
|
237
352
|
|
|
238
353
|
### For other extensions
|
|
@@ -247,7 +362,7 @@ pi.events.on("pi-ask-permission:decided", (decided) => {
|
|
|
247
362
|
|
|
248
363
|
`by` names the step above that decided, `you` for the dialog, or `no UI`. Dialog answers are also saved in the session as `pi-ask-permission:answer` entries.
|
|
249
364
|
|
|
250
|
-
A custom tool can say what it touches, so the workspace and `
|
|
365
|
+
A custom tool can say what it touches, so the workspace and the `edits` mode treat it like `edit`. Emit from `session_start`, after every extension has loaded:
|
|
251
366
|
|
|
252
367
|
```ts
|
|
253
368
|
pi.on("session_start", () => {
|
|
@@ -271,4 +386,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). Commits follow [Conventional Commits](ht
|
|
|
271
386
|
|
|
272
387
|
## License
|
|
273
388
|
|
|
274
|
-
[MIT](LICENSE) ©
|
|
389
|
+
[MIT](LICENSE) © adeildo
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-ask-permission",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "A permission dialog for the Pi coding agent. Approve, always approve, or deny a tool call, and attach a note the model reads with the result.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"approval",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"bugs": "https://github.com/felipeadeildo/pi-harness/issues",
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"author": {
|
|
18
|
-
"name": "
|
|
18
|
+
"name": "adeildo",
|
|
19
19
|
"email": "contato@felipeadeildo.com",
|
|
20
20
|
"url": "https://github.com/felipeadeildo"
|
|
21
21
|
},
|
|
@@ -39,6 +39,9 @@
|
|
|
39
39
|
"#ui/*": "./src/ui/*",
|
|
40
40
|
"#util/*": "./src/util/*"
|
|
41
41
|
},
|
|
42
|
+
"exports": {
|
|
43
|
+
".": "./src/index.ts"
|
|
44
|
+
},
|
|
42
45
|
"publishConfig": {
|
|
43
46
|
"access": "public"
|
|
44
47
|
},
|
|
@@ -46,7 +49,7 @@
|
|
|
46
49
|
"prepublishOnly": "cd ../.. && bun run verify"
|
|
47
50
|
},
|
|
48
51
|
"dependencies": {
|
|
49
|
-
"@adeildo/pi-kit": "
|
|
52
|
+
"@adeildo/pi-kit": "5.0.0",
|
|
50
53
|
"shell-quote": "^1.10.0"
|
|
51
54
|
},
|
|
52
55
|
"peerDependencies": {
|
package/src/core/answer.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { Scope } from "#core/always-yes.ts";
|
|
2
|
+
import type { Access } from "#core/folders.ts";
|
|
3
|
+
import type { FolderChoices } from "#core/workspace.ts";
|
|
4
|
+
|
|
5
|
+
/** A call that left the workspace, with the folders the dialog can open for it. */
|
|
6
|
+
export interface FolderOffer extends FolderChoices {
|
|
7
|
+
access: Access;
|
|
8
|
+
}
|
|
2
9
|
|
|
3
10
|
export interface DialogAnswer {
|
|
4
11
|
decision: "allow" | "deny";
|
|
5
12
|
note?: string;
|
|
6
13
|
remember?: string;
|
|
7
14
|
scope?: Scope;
|
|
15
|
+
open?: { path: string; access: Access; scope: Scope };
|
|
8
16
|
}
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
DEFAULT_TYPING,
|
|
23
23
|
DEFAULT_WORKSPACE,
|
|
24
24
|
defaultConfig,
|
|
25
|
+
isMcpPolicy,
|
|
25
26
|
type NoUIMode,
|
|
26
27
|
type PermissionConfig,
|
|
27
28
|
type TypingConfig,
|
|
@@ -29,7 +30,8 @@ import {
|
|
|
29
30
|
} from "#core/config/schema.ts";
|
|
30
31
|
import { defaultJudge } from "#core/judge/config.ts";
|
|
31
32
|
import { judgeConfig } from "#core/judge/decode.ts";
|
|
32
|
-
import {
|
|
33
|
+
import { MCP_POLICIES, type McpPolicy } from "#core/mcp.ts";
|
|
34
|
+
import { DEFAULT_MODE, parseMode, PERMISSION_MODES, type PermissionMode } from "#core/mode.ts";
|
|
33
35
|
|
|
34
36
|
export type NoUIConfig = NoUIMode | Record<string, NoUIMode>;
|
|
35
37
|
|
|
@@ -48,6 +50,32 @@ export const noUI: Decoder<NoUIConfig> = {
|
|
|
48
50
|
},
|
|
49
51
|
};
|
|
50
52
|
|
|
53
|
+
export const mode: Decoder<PermissionMode> = {
|
|
54
|
+
decode(input, path) {
|
|
55
|
+
const value = typeof input === "string" ? parseMode(input) : undefined;
|
|
56
|
+
if (value !== undefined) return pass(value);
|
|
57
|
+
return fail(problem(path, `expected one of ${PERMISSION_MODES.join(", ")}`));
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The policy per MCP server, keyed by server name. */
|
|
62
|
+
export const mcpPolicies: Decoder<Record<string, McpPolicy>> = {
|
|
63
|
+
decode(input, path) {
|
|
64
|
+
if (!isObject(input)) return fail(problem(path, "expected a map of server to policy"));
|
|
65
|
+
|
|
66
|
+
const problems: Problem[] = [];
|
|
67
|
+
const value: Record<string, McpPolicy> = {};
|
|
68
|
+
for (const [server, entry] of Object.entries(input)) {
|
|
69
|
+
if (isMcpPolicy(entry)) value[server] = entry;
|
|
70
|
+
else
|
|
71
|
+
problems.push(
|
|
72
|
+
problem(fieldPath(path, server), `expected one of ${MCP_POLICIES.join(", ")}`),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return pass(value, problems);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
51
79
|
const typing: Decoder<TypingConfig> = object({
|
|
52
80
|
pause: withDefault(duration, DEFAULT_TYPING.pause),
|
|
53
81
|
maxWait: withDefault(nullable(duration), DEFAULT_TYPING.maxWait),
|
|
@@ -62,7 +90,7 @@ const config: Decoder<PermissionConfig> = object({
|
|
|
62
90
|
allow: withDefaultOf(stringList("tool names"), () => [...DEFAULT_CONFIG.allow]),
|
|
63
91
|
noUI: withDefaultOf(noUI, () => DEFAULT_CONFIG.noUI),
|
|
64
92
|
notes: withDefault(literal("result", "message"), DEFAULT_CONFIG.notes),
|
|
65
|
-
mode: withDefault(
|
|
93
|
+
mode: withDefault(mode, DEFAULT_MODE),
|
|
66
94
|
readOnlyBash: withDefault(boolean, DEFAULT_CONFIG.readOnlyBash),
|
|
67
95
|
workspace: withDefaultOf(workspace, () => ({
|
|
68
96
|
...DEFAULT_WORKSPACE,
|
|
@@ -70,6 +98,7 @@ const config: Decoder<PermissionConfig> = object({
|
|
|
70
98
|
})),
|
|
71
99
|
typing: withDefaultOf(typing, () => ({ ...DEFAULT_TYPING })),
|
|
72
100
|
judge: withDefaultOf(judgeConfig, defaultJudge),
|
|
101
|
+
mcp: withDefaultOf(object({ servers: withDefault(mcpPolicies, {}) }), () => ({ servers: {} })),
|
|
73
102
|
});
|
|
74
103
|
|
|
75
104
|
export function decodeConfig(input: unknown, warnings: string[] = []): PermissionConfig {
|
|
@@ -82,7 +111,6 @@ export function decodeConfig(input: unknown, warnings: string[] = []): Permissio
|
|
|
82
111
|
const RENAMED: [section: "judge" | undefined, from: string, to: string][] = [
|
|
83
112
|
[undefined, "followup", "notes"],
|
|
84
113
|
[undefined, "headless", "noUI"],
|
|
85
|
-
["judge", "backend", "provider"],
|
|
86
114
|
["judge", "autoDeny", "canDeny"],
|
|
87
115
|
["judge", "onUncertain", "whenUnsure"],
|
|
88
116
|
["judge", "onError", "whenItFails"],
|
|
@@ -91,7 +119,6 @@ const RENAMED: [section: "judge" | undefined, from: string, to: string][] = [
|
|
|
91
119
|
["judge", "grant", "rememberApprovals"],
|
|
92
120
|
];
|
|
93
121
|
|
|
94
|
-
// A rename keeps the value and says nothing. The migration writes the new names.
|
|
95
122
|
function migrate(input: unknown, warnings: string[]): unknown {
|
|
96
123
|
if (!isObject(input)) return input;
|
|
97
124
|
|
|
@@ -107,8 +134,15 @@ function migrate(input: unknown, warnings: string[]): unknown {
|
|
|
107
134
|
}
|
|
108
135
|
|
|
109
136
|
if (next.mode === "yolo") {
|
|
110
|
-
next.mode = "
|
|
111
|
-
warnings.push('mode "yolo" is now "
|
|
137
|
+
next.mode = "full";
|
|
138
|
+
warnings.push('mode "yolo" is now "full", with workspace.outside "allow" for the same reach');
|
|
139
|
+
}
|
|
140
|
+
// A judge switched on in 3.x becomes the judge mode.
|
|
141
|
+
if (isObject(next.judge) && "enabled" in next.judge) {
|
|
142
|
+
if (next.judge.enabled === true && (next.mode === undefined || next.mode === "manual")) {
|
|
143
|
+
next.mode = "judge";
|
|
144
|
+
}
|
|
145
|
+
delete next.judge.enabled;
|
|
112
146
|
}
|
|
113
147
|
if ("yolo" in next) {
|
|
114
148
|
delete next.yolo;
|
|
@@ -21,12 +21,6 @@ export function isAllowed(config: PermissionConfig, toolName: string): boolean {
|
|
|
21
21
|
return config.allow.some((pattern) => matchesPattern(pattern, toolName));
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
export function isJudged(config: PermissionConfig, toolName: string): boolean {
|
|
25
|
-
return (
|
|
26
|
-
config.judge.enabled && config.judge.tools.some((pattern) => matchesPattern(pattern, toolName))
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
24
|
export function noUIMode(config: PermissionConfig, toolName: string): NoUIMode {
|
|
31
25
|
if (typeof config.noUI === "string") return config.noUI;
|
|
32
26
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_JUDGE, defaultJudge, type JudgeConfig } from "#core/judge/config.ts";
|
|
2
|
+
import { MCP_POLICIES, type McpPolicy } from "#core/mcp.ts";
|
|
2
3
|
import { DEFAULT_MODE, type PermissionMode } from "#core/mode.ts";
|
|
3
4
|
|
|
4
5
|
export type NoUIMode = "allow" | "deny";
|
|
@@ -17,6 +18,11 @@ export interface TypingConfig {
|
|
|
17
18
|
maxWait: number | null;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
export interface McpConfig {
|
|
22
|
+
/** The policy per server, by server name. A server not listed follows the default. */
|
|
23
|
+
servers: Record<string, McpPolicy>;
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
export interface PermissionConfig {
|
|
21
27
|
allow: string[];
|
|
22
28
|
noUI: NoUIMode | Record<string, NoUIMode>;
|
|
@@ -26,6 +32,7 @@ export interface PermissionConfig {
|
|
|
26
32
|
workspace: WorkspaceConfig;
|
|
27
33
|
typing: TypingConfig;
|
|
28
34
|
judge: JudgeConfig;
|
|
35
|
+
mcp: McpConfig;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export const DEFAULT_TYPING: TypingConfig = {
|
|
@@ -47,6 +54,7 @@ export const DEFAULT_CONFIG: PermissionConfig = {
|
|
|
47
54
|
workspace: DEFAULT_WORKSPACE,
|
|
48
55
|
typing: DEFAULT_TYPING,
|
|
49
56
|
judge: DEFAULT_JUDGE,
|
|
57
|
+
mcp: { servers: {} },
|
|
50
58
|
};
|
|
51
59
|
|
|
52
60
|
export function defaultConfig(): PermissionConfig {
|
|
@@ -56,9 +64,14 @@ export function defaultConfig(): PermissionConfig {
|
|
|
56
64
|
workspace: { ...DEFAULT_WORKSPACE, roots: [...DEFAULT_WORKSPACE.roots] },
|
|
57
65
|
typing: { ...DEFAULT_CONFIG.typing },
|
|
58
66
|
judge: defaultJudge(),
|
|
67
|
+
mcp: { servers: {} },
|
|
59
68
|
};
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
export function isMcpPolicy(value: unknown): value is McpPolicy {
|
|
72
|
+
return MCP_POLICIES.some((policy) => policy === value);
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
export function isNoUIMode(value: unknown): value is NoUIMode {
|
|
63
76
|
return value === "allow" || value === "deny";
|
|
64
77
|
}
|