@adeildo/pi-ask-permission 4.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.
Files changed (53) hide show
  1. package/CONTRIBUTING.md +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +274 -0
  4. package/assets/preview.png +0 -0
  5. package/package.json +63 -0
  6. package/src/core/always-yes.ts +183 -0
  7. package/src/core/answer.ts +8 -0
  8. package/src/core/config/decode.ts +118 -0
  9. package/src/core/config/patterns.ts +51 -0
  10. package/src/core/config/schema.ts +72 -0
  11. package/src/core/config/settings.ts +374 -0
  12. package/src/core/config/store.ts +57 -0
  13. package/src/core/decide.ts +106 -0
  14. package/src/core/judge/backends/factory.ts +30 -0
  15. package/src/core/judge/backends/jev.ts +227 -0
  16. package/src/core/judge/backends/pi-model.ts +174 -0
  17. package/src/core/judge/compose.ts +91 -0
  18. package/src/core/judge/config.ts +60 -0
  19. package/src/core/judge/decode.ts +51 -0
  20. package/src/core/judge/gate.ts +70 -0
  21. package/src/core/judge/pipeline.ts +106 -0
  22. package/src/core/judge/policy.ts +114 -0
  23. package/src/core/judge/probe.ts +51 -0
  24. package/src/core/judge/report.ts +73 -0
  25. package/src/core/judge/request.ts +78 -0
  26. package/src/core/judge/types.ts +72 -0
  27. package/src/core/mode.ts +42 -0
  28. package/src/core/readonly-bash.ts +689 -0
  29. package/src/core/tools.ts +208 -0
  30. package/src/core/workspace.ts +66 -0
  31. package/src/identity.ts +5 -0
  32. package/src/index.ts +29 -0
  33. package/src/pi/api.ts +44 -0
  34. package/src/pi/bash-timer.ts +49 -0
  35. package/src/pi/commands.ts +193 -0
  36. package/src/pi/events.ts +260 -0
  37. package/src/pi/mode.ts +48 -0
  38. package/src/pi/preview.ts +82 -0
  39. package/src/pi/provider.ts +11 -0
  40. package/src/pi/session-entries.ts +74 -0
  41. package/src/pi/session.ts +129 -0
  42. package/src/ui/clipboard.ts +51 -0
  43. package/src/ui/decision-options.ts +23 -0
  44. package/src/ui/dialog.ts +395 -0
  45. package/src/ui/judge-entry.ts +130 -0
  46. package/src/ui/paste.ts +27 -0
  47. package/src/ui/picker.ts +90 -0
  48. package/src/ui/selector.ts +42 -0
  49. package/src/ui/settings/judge.ts +299 -0
  50. package/src/ui/settings/screen.ts +268 -0
  51. package/src/ui/settings/status.ts +53 -0
  52. package/src/ui/typing.ts +72 -0
  53. package/src/util/primitives.ts +11 -0
@@ -0,0 +1,43 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ This package lives in the [pi-harness](https://github.com/felipeadeildo/pi-harness) monorepo. Setup, scripts, hooks and releases are shared and described in the [root CONTRIBUTING.md](../../CONTRIBUTING.md). Run every command from the repository root.
6
+
7
+ To run only this package's tests:
8
+
9
+ ```bash
10
+ bun test ./packages/ask-permission
11
+ ```
12
+
13
+ ## Layout
14
+
15
+ ```text
16
+ src/
17
+ index.ts # wiring only
18
+ pi/ # pi boundary: events, commands, provider, session
19
+ core/ # policy and judging, no pi and no TUI
20
+ config/ # schema, decode, store, patterns
21
+ judge/ # pipeline, compose, request, policy, backends
22
+ ui/ # TUI: dialog, selector, settings, judge entry
23
+ util/ # decoders and primitives
24
+ ```
25
+
26
+ `core/` never imports `pi/` or `ui/`. The permission pipeline is in `pi/events.ts`, the judging pipeline in `core/judge`.
27
+
28
+ All untrusted input (config, grants, model answers) goes through `util/decode.ts`, the only module that inspects `typeof`. Decoders return a value or a list of problems, and never throw.
29
+
30
+ ## Imports
31
+
32
+ Use `#core`, `#ui`, `#pi`, `#util`, and `#identity`, declared in `package.json` `imports`. They resolve in tsc, Bun, and pi's jiti loader, so moving a file does not rewrite relative paths. tsconfig `paths` only works while pi runs from source, so it is not used.
33
+
34
+ ## Adding a config key
35
+
36
+ 1. Add the type and the default in `core/config/schema.ts`.
37
+ 2. Declare the leaf in `core/config/settings.ts`, with the decoder and the row it shows on the settings screen. A missing key takes the default, and an invalid one is ignored with a warning.
38
+ 3. Read it in `readConfig` and write it in `toEntries`, so saving the settings keeps it.
39
+ 4. Add a test in `test/config.test.ts`. A round trip through `writeConfig` and `readConfig` is what proves the mapping.
40
+
41
+ ## Open ideas
42
+
43
+ The judge sees one call, the policy, and the project root. It does not see the session goal. Sending a short goal summary would make the verdict more accurate. The last user message alone was a poor source, often just "continue", and gating on it escalated most calls. A summary assembled from the session would fix the signal without the false positives.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felipe Adeildo
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/README.md ADDED
@@ -0,0 +1,274 @@
1
+ <h1 align="center">@adeildo/pi-ask-permission</h1>
2
+
3
+ <p align="center">
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
+ <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
+ <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
+ <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
+ </p>
12
+
13
+ Pi runs every tool call without asking. This extension asks first.
14
+
15
+ Answer `yes`, `always yes`, or `deny`, and add 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.
16
+
17
+ <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 judge card above it and a note typed on the deny row: use pnpm instead." width="860">
19
+ </p>
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ pi install npm:@adeildo/pi-ask-permission
25
+ ```
26
+
27
+ Then start pi as usual. There is nothing to configure.
28
+
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
+
31
+ ```bash
32
+ pi remove npm:pi-ask-permission
33
+ pi install npm:@adeildo/pi-ask-permission
34
+ ```
35
+
36
+ ## First run
37
+
38
+ Ask the agent to do something that writes, like `run the tests`. The dialog opens before the command runs:
39
+
40
+ ```
41
+ ╭─ permission · bash ──────────────────────────────────╮
42
+ │ pnpm test │
43
+ │ │
44
+ │ ❯ 1 yes │
45
+ │ 2 always yes │
46
+ │ 3 deny │
47
+ │ │
48
+ │ ↑↓ or 1-3 pick enter confirm tab note esc deny │
49
+ ╰──────────────────────────────────────────────────────╯
50
+ ```
51
+
52
+ `enter` approves. `esc` denies. `tab` opens a note on the highlighted row.
53
+
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
+
56
+ ## Everyday use
57
+
58
+ ### Stop answering the same question
59
+
60
+ Pick `always yes`, then choose how much to remember and for how long:
61
+
62
+ ```
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
+
76
+ We recommend the narrowest level, which is preselected. `pnpm` would also approve `pnpm publish`.
77
+
78
+ | Scope | Lasts | Stored in |
79
+ | ------------ | ---------------------------------------- | ---------------------------------------------------------- |
80
+ | this session | until the session ends, reloads included | the session file |
81
+ | this project | every session in this project | `.pi/extensions/pi-ask-permission/always-yes.json` |
82
+ | everywhere | every session | `~/.pi/agent/extensions/pi-ask-permission/always-yes.json` |
83
+
84
+ Run `/perm forget` to drop this session's, or `/perm forget project` for the project's.
85
+
86
+ ### Let the agent work
87
+
88
+ Press `Alt+M` to switch modes. The status bar shows the one you are in.
89
+
90
+ | Mode | Runs without asking |
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 |
95
+
96
+ A call that leaves the project still asks, in every mode. The mode lasts for the session and never changes the settings file.
97
+
98
+ ### Correct the agent
99
+
100
+ A note on `deny` tells the agent what to do instead. A note on `yes` adds context, like `and update the snapshot`. Both reach the model with the tool result.
101
+
102
+ If you are typing in the editor when a call arrives, the dialog waits until you pause.
103
+
104
+ ## Let a model decide
105
+
106
+ The judge answers first, and only the calls it is unsure about reach you. It is off by default. We recommend this setup:
107
+
108
+ 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. Open `/perm`, turn on `Judge`, and turn on `Dry run`. The judge now shows its verdict as a card, and you still decide.
110
+ 3. Pick a policy. `Standard development` allows edits, tests, builds, and local git, and asks about installs, network, and anything destructive.
111
+ 4. After a few sessions of agreeing with it, turn off `Dry run`.
112
+
113
+ The policy is plain text, so you can start from a preset and edit it:
114
+
115
+ ```text
116
+ # May run without asking
117
+ - Running tests, linters, type checks, and builds
118
+ - git status, diff, log
119
+
120
+ # Must always ask first
121
+ - sudo, or anything that changes system-wide state
122
+ - Anything that reaches the network
123
+
124
+ # When in doubt
125
+ Ask me.
126
+ ```
127
+
128
+ If calls come back as `the judge could not decide`, run `/perm judge test`. It sends one request and reports the model, the latency, and the error.
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.
159
+
160
+ ### Configuration
161
+
162
+ The settings live in the file every pi-harness package 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. A key from before 3.0 is read with the new name. `PI_CODING_AGENT_DIR` moves the file with the rest of the agent directory.
211
+
212
+ Up to 3.0 the config was `~/.pi/agent/extensions/pi-ask-permission/config.json`. The first session after this version reads it, writes what you changed into the shared settings, and keeps the old file as `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
+ - If you want deterministic rules and no human in the loop, use a sandbox instead.
237
+
238
+ ### For other extensions
239
+
240
+ Every decision goes out on `pi.events`:
241
+
242
+ ```ts
243
+ pi.events.on("pi-ask-permission:decided", (decided) => {
244
+ // { toolCallId, toolName, summary, action: "allow" | "block", by, reason?, note? }
245
+ });
246
+ ```
247
+
248
+ `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
+
250
+ 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:
251
+
252
+ ```ts
253
+ pi.on("session_start", () => {
254
+ pi.events.emit("pi-ask-permission:tool", {
255
+ name: "apply_patch",
256
+ edits: true,
257
+ paths: (input) => input.files,
258
+ });
259
+ });
260
+ ```
261
+
262
+ A tool that says nothing has no paths and is not an edit. Built-in tools cannot be redescribed.
263
+
264
+ ### Compatibility
265
+
266
+ Tested against the pi SDK pinned in `devDependencies`, which the `pi SDK` badge shows. CI checks each new pi release. The peer range is `*` because pi, not npm, picks the SDK that loads the extension.
267
+
268
+ ## Contributing
269
+
270
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Commits follow [Conventional Commits](https://www.conventionalcommits.org), and [release-please](https://github.com/googleapis/release-please) publishes.
271
+
272
+ ## License
273
+
274
+ [MIT](LICENSE) © Felipe Adeildo
Binary file
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@adeildo/pi-ask-permission",
3
+ "version": "4.0.0",
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
+ "keywords": [
6
+ "approval",
7
+ "permission",
8
+ "permissions",
9
+ "pi",
10
+ "pi-coding-agent",
11
+ "pi-extension",
12
+ "pi-package"
13
+ ],
14
+ "homepage": "https://pi.dev/packages/@adeildo/pi-ask-permission",
15
+ "bugs": "https://github.com/felipeadeildo/pi-harness/issues",
16
+ "license": "MIT",
17
+ "author": {
18
+ "name": "Felipe Adeildo",
19
+ "email": "contato@felipeadeildo.com",
20
+ "url": "https://github.com/felipeadeildo"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/felipeadeildo/pi-harness.git",
25
+ "directory": "packages/ask-permission"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "assets/preview.png",
30
+ "README.md",
31
+ "CONTRIBUTING.md",
32
+ "LICENSE"
33
+ ],
34
+ "type": "module",
35
+ "imports": {
36
+ "#core/*": "./src/core/*",
37
+ "#identity": "./src/identity.ts",
38
+ "#pi/*": "./src/pi/*",
39
+ "#ui/*": "./src/ui/*",
40
+ "#util/*": "./src/util/*"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "prepublishOnly": "cd ../.. && bun run verify"
47
+ },
48
+ "dependencies": {
49
+ "@adeildo/pi-kit": "4.0.0",
50
+ "shell-quote": "^1.10.0"
51
+ },
52
+ "peerDependencies": {
53
+ "@earendil-works/pi-ai": "*",
54
+ "@earendil-works/pi-coding-agent": "*",
55
+ "@earendil-works/pi-tui": "*"
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./src/index.ts"
60
+ ],
61
+ "image": "https://raw.githubusercontent.com/felipeadeildo/pi-harness/main/packages/ask-permission/assets/preview.png"
62
+ }
63
+ }
@@ -0,0 +1,183 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { describe, isRecord } from "#util/primitives.ts";
5
+
6
+ export type Scope = "session" | "project" | "global";
7
+ type SavedScope = Exclude<Scope, "session">;
8
+
9
+ export const SCOPES: Scope[] = ["session", "project", "global"];
10
+ const SAVED_SCOPES: SavedScope[] = ["project", "global"];
11
+
12
+ export const SCOPE_LABEL: Record<Scope, string> = {
13
+ session: "this session",
14
+ project: "this project",
15
+ global: "everywhere",
16
+ };
17
+
18
+ export const ALWAYS_YES_FILE = "always-yes.json";
19
+ const LEGACY_FILE_NAME = "grants.json";
20
+
21
+ export interface AlwaysYesFiles {
22
+ global: string;
23
+ /** Undefined when the project is not trusted. */
24
+ project?: string;
25
+ }
26
+
27
+ // A tool name cannot hold a NUL, so ("bash\0git", "") never matches ("bash", "git").
28
+ const SEPARATOR = "\u0000";
29
+
30
+ export class AlwaysYes {
31
+ private readonly levels: Record<Scope, Set<string>> = {
32
+ session: new Set(),
33
+ project: new Set(),
34
+ global: new Set(),
35
+ };
36
+ private files: AlwaysYesFiles | undefined;
37
+
38
+ open(files: AlwaysYesFiles): string[] {
39
+ this.files = files;
40
+ const warnings: string[] = [];
41
+ for (const scope of SAVED_SCOPES) {
42
+ const path = files[scope];
43
+ if (path === undefined) {
44
+ this.levels[scope] = new Set();
45
+ continue;
46
+ }
47
+ const read = readLevels(adoptLegacyFile(path));
48
+ this.levels[scope] = read.levels;
49
+ if (read.warning) warnings.push(read.warning);
50
+ }
51
+ return warnings;
52
+ }
53
+
54
+ has(toolName: string, levels: string[]): boolean {
55
+ return levels.some((level) => {
56
+ const key = keyOf(toolName, level);
57
+ return SCOPES.some((scope) => this.levels[scope].has(key));
58
+ });
59
+ }
60
+
61
+ size(scope: Scope): number {
62
+ return this.levels[scope].size;
63
+ }
64
+
65
+ total(): number {
66
+ return SCOPES.reduce((sum, scope) => sum + this.size(scope), 0);
67
+ }
68
+
69
+ /** Returns why it was not saved. It still holds until pi exits. */
70
+ add(scope: Scope, toolName: string, level: string): string | undefined {
71
+ const key = keyOf(toolName, level);
72
+ this.levels[scope].add(key);
73
+ if (scope === "session") return undefined;
74
+
75
+ const path = this.files?.[scope];
76
+ if (path === undefined) return "this project is not trusted, so it was not saved";
77
+
78
+ // Another pi may have saved since this one loaded, and a file that does not
79
+ // parse is someone's hand edit, not something to overwrite.
80
+ const read = readLevels(path);
81
+ if (read.warning) return read.warning;
82
+
83
+ read.levels.add(key);
84
+ this.levels[scope] = read.levels;
85
+ return writeLevels(path, read.levels);
86
+ }
87
+
88
+ forget(scope: Scope | "all"): { removed: number; errors: string[] } {
89
+ const scopes = scope === "all" ? SCOPES : [scope];
90
+ let removed = 0;
91
+ const errors: string[] = [];
92
+
93
+ for (const target of scopes) {
94
+ removed += this.levels[target].size;
95
+ this.levels[target].clear();
96
+ if (target === "session") continue;
97
+
98
+ const path = this.files?.[target];
99
+ if (path === undefined) continue;
100
+ try {
101
+ rmSync(path, { force: true });
102
+ } catch (error) {
103
+ errors.push(describe(error));
104
+ }
105
+ }
106
+
107
+ return { removed, errors };
108
+ }
109
+ }
110
+
111
+ export function savedFileExists(path: string): boolean {
112
+ return existsSync(path) || existsSync(legacyPath(path));
113
+ }
114
+
115
+ export function readLevels(path: string): { levels: Set<string>; warning?: string } {
116
+ if (!existsSync(path)) return { levels: new Set() };
117
+
118
+ let raw: unknown;
119
+ try {
120
+ raw = JSON.parse(readFileSync(path, "utf8"));
121
+ } catch (error) {
122
+ return { levels: new Set(), warning: `could not parse ${path}: ${describe(error)}` };
123
+ }
124
+ if (!isRecord(raw)) return { levels: new Set(), warning: `${path} must contain a JSON object` };
125
+
126
+ const levels = new Set<string>();
127
+ for (const [tool, list] of Object.entries(raw)) {
128
+ if (!Array.isArray(list)) continue;
129
+ for (const level of list) {
130
+ if (typeof level === "string" && level !== "") levels.add(keyOf(tool, level));
131
+ }
132
+ }
133
+ return { levels };
134
+ }
135
+
136
+ function writeLevels(path: string, levels: Set<string>): string | undefined {
137
+ try {
138
+ mkdirSync(dirname(path), { recursive: true });
139
+ writeFileSync(path, `${JSON.stringify(toFile(levels), null, 2)}\n`, "utf8");
140
+ return undefined;
141
+ } catch (error) {
142
+ return describe(error);
143
+ }
144
+ }
145
+
146
+ // Before 3.0 the file was grants.json. A failed rename reads it in place.
147
+ function adoptLegacyFile(path: string): string {
148
+ const legacy = legacyPath(path);
149
+ if (existsSync(path) || !existsSync(legacy)) return path;
150
+
151
+ try {
152
+ renameSync(legacy, path);
153
+ return path;
154
+ } catch {
155
+ return legacy;
156
+ }
157
+ }
158
+
159
+ function legacyPath(path: string): string {
160
+ return join(dirname(path), LEGACY_FILE_NAME);
161
+ }
162
+
163
+ function keyOf(toolName: string, level: string): string {
164
+ return `${toolName}${SEPARATOR}${level}`;
165
+ }
166
+
167
+ function toFile(levels: Set<string>): Record<string, string[]> {
168
+ const byTool = new Map<string, string[]>();
169
+
170
+ for (const key of levels) {
171
+ const separator = key.indexOf(SEPARATOR);
172
+ const tool = key.slice(0, separator);
173
+ const list = byTool.get(tool) ?? [];
174
+ list.push(key.slice(separator + 1));
175
+ byTool.set(tool, list);
176
+ }
177
+
178
+ return Object.fromEntries(
179
+ [...byTool]
180
+ .toSorted(([left], [right]) => left.localeCompare(right))
181
+ .map(([tool, list]) => [tool, list.toSorted()]),
182
+ );
183
+ }
@@ -0,0 +1,8 @@
1
+ import type { Scope } from "#core/always-yes.ts";
2
+
3
+ export interface DialogAnswer {
4
+ decision: "allow" | "deny";
5
+ note?: string;
6
+ remember?: string;
7
+ scope?: Scope;
8
+ }