@adeildo/pi-ask-permission 4.1.0 → 5.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/CONTRIBUTING.md +1 -1
- package/README.md +114 -193
- package/assets/preview.png +0 -0
- package/package.json +3 -2
- 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 +19 -3
- package/src/pi/commands.ts +13 -185
- package/src/pi/events.ts +83 -46
- 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 +56 -12
- package/src/ui/judge-entry.ts +6 -16
- package/src/ui/questions.ts +194 -0
- package/src/ui/selector.ts +13 -5
- package/src/ui/clipboard.ts +0 -51
- package/src/ui/dialog.ts +0 -395
- package/src/ui/paste.ts +0 -27
- 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/CONTRIBUTING.md
CHANGED
|
@@ -19,7 +19,7 @@ src/
|
|
|
19
19
|
core/ # policy and judging, no pi and no TUI
|
|
20
20
|
config/ # schema, decode, store, patterns
|
|
21
21
|
judge/ # pipeline, compose, request, policy, backends
|
|
22
|
-
ui/ #
|
|
22
|
+
ui/ # the permission question for the questions dialog, the host selector, the judge entry
|
|
23
23
|
util/ # decoders and primitives
|
|
24
24
|
```
|
|
25
25
|
|
package/README.md
CHANGED
|
@@ -3,19 +3,16 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://github.com/felipeadeildo/pi-harness/actions/workflows/ci.yml"><img src="https://github.com/felipeadeildo/pi-harness/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/npm/v/@adeildo/pi-ask-permission" alt="npm"></a>
|
|
6
|
-
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/npm/dm/@adeildo/pi-ask-permission" alt="downloads"></a>
|
|
7
6
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="license"></a>
|
|
8
|
-
<a href="https://www.npmjs.com/package/@adeildo/pi-ask-permission"><img src="https://img.shields.io/badge/provenance-signed-success" alt="provenance"></a>
|
|
9
|
-
<a href="https://pi.dev/packages/@adeildo/pi-ask-permission"><img src="https://img.shields.io/badge/pi--package-6E56CF" alt="pi package"></a>
|
|
10
7
|
<a href="https://pi.dev"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Ffelipeadeildo%2Fpi-harness%2Fmain%2Fpackage.json&query=%24.devDependencies%5B%22%40earendil-works%2Fpi-coding-agent%22%5D&label=pi%20SDK&color=6E56CF" alt="pi SDK"></a>
|
|
11
8
|
</p>
|
|
12
9
|
|
|
13
10
|
Pi runs every tool call without asking. This extension asks first.
|
|
14
11
|
|
|
15
|
-
Answer `yes`, `always yes
|
|
12
|
+
Answer `yes`, `always yes` or `no`, and leave a note if you want. The note reaches the model with the result, so denying `npm install` with "use pnpm instead" corrects the agent without stopping it. A judge model can answer the routine calls for you, so you only see the ones it doubts.
|
|
16
13
|
|
|
17
14
|
<p align="center">
|
|
18
|
-
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/ask-permission/assets/preview.png" alt="The permission dialog for npm install, with the
|
|
15
|
+
<img src="https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/ask-permission/assets/preview.png" alt="The permission dialog for npm install, with the reason it asks, the three answers, and a note on no: use pnpm instead." width="860">
|
|
19
16
|
</p>
|
|
20
17
|
|
|
21
18
|
## Install
|
|
@@ -24,56 +21,63 @@ Answer `yes`, `always yes`, or `deny`, and add a note if you want. The note reac
|
|
|
24
21
|
pi install npm:@adeildo/pi-ask-permission
|
|
25
22
|
```
|
|
26
23
|
|
|
27
|
-
|
|
24
|
+
There is nothing to configure, and the dialog comes with it. It also comes in [`@adeildo/pi-harness`](../harness), with the rest of the pieces.
|
|
28
25
|
|
|
29
|
-
`pi-ask-permission` on npm is this same extension under an older name, and it reads the same
|
|
26
|
+
`pi-ask-permission` on npm is this same extension under an older name, and it reads the same settings, grants and sessions. To switch, run `pi remove npm:pi-ask-permission` and install this one.
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
pi remove npm:pi-ask-permission
|
|
33
|
-
pi install npm:@adeildo/pi-ask-permission
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## First run
|
|
28
|
+
## The dialog
|
|
37
29
|
|
|
38
30
|
Ask the agent to do something that writes, like `run the tests`. The dialog opens before the command runs:
|
|
39
31
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
│
|
|
45
|
-
│
|
|
46
|
-
│
|
|
47
|
-
│
|
|
48
|
-
│
|
|
49
|
-
|
|
32
|
+
<!-- docs:ask-permission/ask -->
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
╭─ permission ─────────────────────────────────────────────────────────────────────────────────────╮
|
|
36
|
+
│ │
|
|
37
|
+
│ ▎ bash wants to run: │
|
|
38
|
+
│ ▎ npm install │
|
|
39
|
+
│ ▎ │
|
|
40
|
+
│ ▎ ▲ the judge leaned deny but was only 61% confident │
|
|
41
|
+
│ │
|
|
42
|
+
│ 1 yes, run it │ Block it. A note on this answer becomes the reason the │
|
|
43
|
+
│ 2 always yes │ model reads. │
|
|
44
|
+
│ ❯ 3 no › │ │
|
|
45
|
+
│ │ › use pnpm instead │
|
|
46
|
+
│ │
|
|
47
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
48
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
50
49
|
```
|
|
51
50
|
|
|
52
|
-
|
|
51
|
+
<!-- /docs -->
|
|
53
52
|
|
|
54
|
-
|
|
53
|
+
`enter` picks the highlighted answer and `esc` cancels, which denies. `tab` writes a note on the highlighted answer, and the note on `no` is the reason the model reads. A command longer than three lines shows its first three and how many are left.
|
|
55
54
|
|
|
56
|
-
|
|
55
|
+
Reads inside the project never ask: `read`, `grep`, `find`, `ls`, and bash commands that only read, like `cat`, `git log` or `rg`. An `edit` or `write` shows the diff it would make.
|
|
57
56
|
|
|
58
|
-
|
|
57
|
+
This is the dialog of [`@adeildo/pi-ask-questions`](../ask-questions), which comes with this package, so the permission ask and the model's questions share the same keys and notes. If you switch that feature off in `pi config`, and in RPC hosts that cannot draw a terminal component, Pi asks through its own selector, with the same answers and no panel.
|
|
59
58
|
|
|
60
|
-
|
|
59
|
+
## Stop answering the same question
|
|
61
60
|
|
|
61
|
+
Pick `always yes` and it asks what to remember, then for how long:
|
|
62
|
+
|
|
63
|
+
<!-- docs:ask-permission/remember -->
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
╭─ remember ───────────────────────────────────────────────────────────────────────────────────────╮
|
|
67
|
+
│ │
|
|
68
|
+
│ ▎ Always say yes to which calls, from bash? │
|
|
69
|
+
│ │
|
|
70
|
+
│ ❯ 1 pnpm test │ Only this exact call. │
|
|
71
|
+
│ 2 pnpm │ │
|
|
72
|
+
│ │ │
|
|
73
|
+
│ │
|
|
74
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
75
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
62
76
|
```
|
|
63
|
-
╭─ permission · bash ──────────────────────────────────╮
|
|
64
|
-
│ pnpm test │
|
|
65
|
-
│ │
|
|
66
|
-
│ always yes for... │
|
|
67
|
-
│ pnpm │
|
|
68
|
-
│ ❯ pnpm test │
|
|
69
|
-
│ │
|
|
70
|
-
│ scope: this session (tab to change) │
|
|
71
|
-
│ │
|
|
72
|
-
│ ↑↓ depth tab scope enter confirm esc back │
|
|
73
|
-
╰──────────────────────────────────────────────────────╯
|
|
74
|
-
```
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
<!-- /docs -->
|
|
79
|
+
|
|
80
|
+
The first option is the narrowest, and that is the one to prefer: `pnpm` would also approve `pnpm publish`.
|
|
77
81
|
|
|
78
82
|
| Scope | Lasts | Stored in |
|
|
79
83
|
| ------------ | ---------------------------------------- | ---------------------------------------------------------- |
|
|
@@ -81,36 +85,81 @@ We recommend the narrowest level, which is preselected. `pnpm` would also approv
|
|
|
81
85
|
| this project | every session in this project | `.pi/extensions/pi-ask-permission/always-yes.json` |
|
|
82
86
|
| everywhere | every session | `~/.pi/agent/extensions/pi-ask-permission/always-yes.json` |
|
|
83
87
|
|
|
84
|
-
|
|
88
|
+
The `Always yes` section of the settings (`Alt+S`) shows how many rules each scope holds, and forgets them.
|
|
89
|
+
|
|
90
|
+
## Let the agent work
|
|
91
|
+
|
|
92
|
+
`Alt+M` switches modes, and the status bar shows the one you are in.
|
|
93
|
+
|
|
94
|
+
| Mode | Runs without asking |
|
|
95
|
+
| -------- | -------------------------------------------------------------------------- |
|
|
96
|
+
| `manual` | reads (`allow` and read-only bash) and always yes |
|
|
97
|
+
| `edits` | the same, plus file edits and writes |
|
|
98
|
+
| `judge` | the same as `edits`, and the [judge](#let-a-model-decide) decides the rest |
|
|
99
|
+
| `full` | everything |
|
|
85
100
|
|
|
86
|
-
|
|
101
|
+
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. A resumed session keeps its mode and its `Alt+W` choice.
|
|
87
102
|
|
|
88
|
-
|
|
103
|
+
### A folder next door
|
|
104
|
+
|
|
105
|
+
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:
|
|
106
|
+
|
|
107
|
+
<!-- docs:ask-permission/folder -->
|
|
108
|
+
|
|
109
|
+
```text
|
|
110
|
+
╭─ permission ─────────────────────────────────────────────────────────────────────────────────────╮
|
|
111
|
+
│ │
|
|
112
|
+
│ ▎ bash wants to run: │
|
|
113
|
+
│ ▎ cd ~/Projects/grace/apps/web && git status --short | head │
|
|
114
|
+
│ ▎ │
|
|
115
|
+
│ ▎ ▲ reads outside the workspace │
|
|
116
|
+
│ │
|
|
117
|
+
│ 1 yes, run it │ yes, and allow reads in /home/me/Projects/grace │
|
|
118
|
+
│ 2 always yes │ │
|
|
119
|
+
│ ❯ 3 yes, and allow reads in /home/me/Proj… │ Run it, and let later calls read there without │
|
|
120
|
+
│ 4 no │ asking. │
|
|
121
|
+
│ │ │
|
|
122
|
+
│ │ │
|
|
123
|
+
│ │
|
|
124
|
+
│ ↑↓ move enter choose tab note esc cancel │
|
|
125
|
+
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
|
|
126
|
+
```
|
|
89
127
|
|
|
90
|
-
|
|
91
|
-
| -------------- | ------------------------------------ |
|
|
92
|
-
| `manual` | nothing beyond reads and always yes |
|
|
93
|
-
| `accept edits` | file edits and writes in the project |
|
|
94
|
-
| `auto` | everything in the project |
|
|
128
|
+
<!-- /docs -->
|
|
95
129
|
|
|
96
|
-
A
|
|
130
|
+
Take it and the next reads there run without asking, for as long as you choose. A write outside gets `yes, and add … to the workspace` instead. A folder opened for reads never lets a write through, and the dialog never offers your home or a folder above it. The status bar counts the open folders, like `+1 folder`, and the `Folders` section of the settings closes them.
|
|
97
131
|
|
|
98
|
-
###
|
|
132
|
+
### Calls to an MCP server
|
|
99
133
|
|
|
100
|
-
|
|
134
|
+
Pi registers each tool an MCP server offers as `mcp__<server>__<tool>`. The settings have a row per server, and each follows a policy:
|
|
101
135
|
|
|
102
|
-
|
|
136
|
+
| Policy | Runs without asking |
|
|
137
|
+
| ------------- | -------------------------------------------------------------------------- |
|
|
138
|
+
| `ask me` | Nothing, in any mode. Every call comes to you |
|
|
139
|
+
| `trust hints` | A call the server declares read-only. The rest follows the mode you are in |
|
|
140
|
+
| `allow` | Every call |
|
|
141
|
+
| `deny` | Nothing. Every call is blocked, whatever the mode says |
|
|
142
|
+
|
|
143
|
+
`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`. `deny` and `ask me` outrank the mode, `full` included. The dialog names the server, repeats what it declares, and says when a script issued the call.
|
|
103
144
|
|
|
104
145
|
## Let a model decide
|
|
105
146
|
|
|
106
|
-
|
|
147
|
+
In the `judge` mode a model answers every call that is not a read or an edit, and you only see the ones it is unsure about. A good way in:
|
|
107
148
|
|
|
108
149
|
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.
|
|
110
|
-
3. Pick a policy. `Standard development` allows edits, tests, builds
|
|
150
|
+
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.
|
|
151
|
+
3. Pick a policy. `Standard development` allows edits, tests, builds and local git, and asks about installs, network and anything destructive.
|
|
111
152
|
4. After a few sessions of agreeing with it, turn off `Dry run`.
|
|
112
153
|
|
|
113
|
-
|
|
154
|
+
A call runs when the judge approves it with at least the confidence of the rigor you picked, and its risk stays under the ceiling. Anything else asks you.
|
|
155
|
+
|
|
156
|
+
| Rigor | Confidence | Risk ceiling |
|
|
157
|
+
| ---------------------- | ---------- | ------------ |
|
|
158
|
+
| `cautious` | 85% | 0.45 |
|
|
159
|
+
| `balanced`, by default | 70% | 0.50 |
|
|
160
|
+
| `relaxed` | 55% | 0.60 |
|
|
161
|
+
|
|
162
|
+
The policy is plain text, so start from a preset and edit it:
|
|
114
163
|
|
|
115
164
|
```text
|
|
116
165
|
# May run without asking
|
|
@@ -125,146 +174,18 @@ The policy is plain text, so you can start from a preset and edit it:
|
|
|
125
174
|
Ask me.
|
|
126
175
|
```
|
|
127
176
|
|
|
128
|
-
If calls come back as `the judge could not decide`, run
|
|
129
|
-
|
|
130
|
-
## Commands
|
|
131
|
-
|
|
132
|
-
Type `/perm ` and the editor suggests the rest.
|
|
133
|
-
|
|
134
|
-
| Command | Does |
|
|
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 |
|
|
145
|
-
|
|
146
|
-
## Reference
|
|
147
|
-
|
|
148
|
-
### Dialog keys
|
|
149
|
-
|
|
150
|
-
| Key | Does |
|
|
151
|
-
| ---------------------- | ---------------------------------------------------- |
|
|
152
|
-
| `↑` `↓` or `1` `2` `3` | Move the highlight |
|
|
153
|
-
| `enter` | Confirm the highlighted row |
|
|
154
|
-
| `tab` | Open or close a note, or change the always yes scope |
|
|
155
|
-
| `esc` | Close the note, or deny |
|
|
156
|
-
| `ctrl+v` | Paste a clipboard image as its file path |
|
|
157
|
-
|
|
158
|
-
A long paste collapses to `[paste #1 +48 lines]` and expands when you confirm.
|
|
177
|
+
`Always ask me` lists patterns the judge never approves, like `git push*`. The judge also reads your last message to tell whether a call is a step of what you asked, but nothing in the message overrides the policy or `Always ask me`. 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.
|
|
159
178
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
The settings live in the file every piece shares, `~/.pi/agent/extensions/pi-harness/settings.json`, under a `permission.` prefix. Every key has a row of the same name in `/perm`, and only what you change is written, so a new default reaches you.
|
|
163
|
-
|
|
164
|
-
```json
|
|
165
|
-
{
|
|
166
|
-
"permission": {
|
|
167
|
-
"allow": ["read", "grep", "find", "ls"],
|
|
168
|
-
"mode": "manual",
|
|
169
|
-
"readOnlyBash": true,
|
|
170
|
-
"workspace": { "roots": ["."], "outside": "ask" },
|
|
171
|
-
"judge": { "enabled": false, "model": "jev-latest" }
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
The ids below leave out the `permission.` prefix.
|
|
177
|
-
|
|
178
|
-
| Key | Does |
|
|
179
|
-
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
180
|
-
| `allow` | Tools that never ask. `mcp_*` matches a family. It matches the tool name, so `bash` allows every command |
|
|
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` | A call outside the roots: `"ask"` you, `"deny"` it, or `"allow"` it like any other |
|
|
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 |
|
|
189
|
-
|
|
190
|
-
The `judge` block:
|
|
191
|
-
|
|
192
|
-
| Key | Default | Does |
|
|
193
|
-
| ------------------- | -------------- | ----------------------------------------------------- |
|
|
194
|
-
| `enabled` | `false` | Turn the judge on |
|
|
195
|
-
| `provider` | `"jev"` | `"jev"`, or `"pi"` for a model you set up in pi |
|
|
196
|
-
| `model` | `"jev-latest"` | A Jev alias, or `provider/modelId` for a pi model |
|
|
197
|
-
| `tools` | `["bash"]` | Tools the judge decides. The rest ask you |
|
|
198
|
-
| `policy` | Standard | The rules the judge follows |
|
|
199
|
-
| `canDeny` | `true` | A confident no blocks the call. Off, it asks you |
|
|
200
|
-
| `whenUnsure` | `"ask"` | `"ask"`, `"allow"`, or `"deny"` |
|
|
201
|
-
| `whenItFails` | `"ask"` | The same, for a timeout, an error, or a missing key |
|
|
202
|
-
| `alwaysAsk` | `[]` | Patterns the judge never approves, like `"git push*"` |
|
|
203
|
-
| `dryRun` | `false` | Show the verdict, and still ask you |
|
|
204
|
-
| `noUI` | `false` | Also judge print, JSON, and subagent runs |
|
|
205
|
-
| `rememberApprovals` | `false` | A judge approval becomes always yes for this session |
|
|
206
|
-
| `thresholds` | `0.85` / `0.8` | Confidence needed to allow / deny |
|
|
207
|
-
| `riskCeiling` | `0.45` | Highest risk the judge may approve |
|
|
208
|
-
| `timeoutMs` | `5000` | How long to wait for an answer |
|
|
209
|
-
|
|
210
|
-
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.
|
|
211
|
-
|
|
212
|
-
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
|
-
|
|
214
|
-
### How a call is decided
|
|
215
|
-
|
|
216
|
-
The first step that answers wins.
|
|
217
|
-
|
|
218
|
-
1. **Always yes** matches the tool and level: run it.
|
|
219
|
-
2. **Workspace**: a call outside `workspace.roots` asks you, or is blocked with `outside: "deny"`. Nothing below can approve it.
|
|
220
|
-
3. **Mode**: `auto` runs it, `accept edits` runs an edit.
|
|
221
|
-
4. **Allow list**: the tool is in `allow`, run it.
|
|
222
|
-
5. **Read-only bash**: the command only reads, run it.
|
|
223
|
-
6. **Judge**: a confident yes runs it, a confident no blocks it.
|
|
224
|
-
7. **No UI**: `noUI` decides.
|
|
225
|
-
8. **Edit check**: an `edit` that cannot apply is blocked with pi's own error, so you never approve a failure.
|
|
226
|
-
9. **You**, in the dialog.
|
|
227
|
-
|
|
228
|
-
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
|
-
|
|
230
|
-
### Limits
|
|
231
|
-
|
|
232
|
-
- Always yes matches text. `cd /repo && pnpm test` offers `cd`, `cd /repo`, and the whole line, not `pnpm test`.
|
|
233
|
-
- A bash path the check cannot read counts as outside. `$HOME`, `$SECRET`, and `"$@"` ask for that reason.
|
|
234
|
-
- 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.
|
|
236
|
-
- Pi's `codemode` tool runs a script that calls other tools. The dialog asks about the script, and every call the script makes goes through the same steps on its own.
|
|
237
|
-
- If you want deterministic rules and no human in the loop, use a sandbox instead.
|
|
238
|
-
|
|
239
|
-
### For other extensions
|
|
240
|
-
|
|
241
|
-
Every decision goes out on `pi.events`:
|
|
242
|
-
|
|
243
|
-
```ts
|
|
244
|
-
pi.events.on("pi-ask-permission:decided", (decided) => {
|
|
245
|
-
// { toolCallId, toolName, summary, action: "allow" | "block", by, reason?, note? }
|
|
246
|
-
});
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
`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.
|
|
250
|
-
|
|
251
|
-
A custom tool can say what it touches, so the workspace and `accept edits` treat it like `edit`. Emit from `session_start`, after every extension has loaded:
|
|
252
|
-
|
|
253
|
-
```ts
|
|
254
|
-
pi.on("session_start", () => {
|
|
255
|
-
pi.events.emit("pi-ask-permission:tool", {
|
|
256
|
-
name: "apply_patch",
|
|
257
|
-
edits: true,
|
|
258
|
-
paths: (input) => input.files,
|
|
259
|
-
});
|
|
260
|
-
});
|
|
261
|
-
```
|
|
179
|
+
## Limits
|
|
262
180
|
|
|
263
|
-
|
|
181
|
+
- Always yes matches text. `cd /repo && pnpm test` offers `cd`, `cd /repo` and the whole line, not `pnpm test`.
|
|
182
|
+
- The read-only check is a classifier, not a sandbox. It refuses anything it cannot prove harmless, so a few safe commands still ask.
|
|
183
|
+
- The judge is a model and can be wrong. It sees the tool call and your last message, so do not judge calls that carry secrets you would not send to its provider.
|
|
184
|
+
- For deterministic rules and no human in the loop, use a sandbox.
|
|
264
185
|
|
|
265
|
-
|
|
186
|
+
## More
|
|
266
187
|
|
|
267
|
-
|
|
188
|
+
The [reference](docs/reference.md) has every setting, the exact order in which a call is decided, the full list of limits, and the events other extensions can listen to. The settings screen (`Alt+S`) covers the same keys without opening the file.
|
|
268
189
|
|
|
269
190
|
## Contributing
|
|
270
191
|
|
package/assets/preview.png
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-ask-permission",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.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",
|
|
@@ -49,7 +49,8 @@
|
|
|
49
49
|
"prepublishOnly": "cd ../.. && bun run verify"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@adeildo/pi-
|
|
52
|
+
"@adeildo/pi-ask-questions": "5.1.0",
|
|
53
|
+
"@adeildo/pi-kit": "5.1.0",
|
|
53
54
|
"shell-quote": "^1.10.0"
|
|
54
55
|
},
|
|
55
56
|
"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
|
}
|