@gotgenes/pi-permission-system 40.1.4 → 40.2.1
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/CHANGELOG.md +24 -0
- package/README.md +1 -0
- package/dist/public.d.ts +5 -0
- package/docs/configuration.md +34 -6
- package/docs/cross-extension-api.md +15 -14
- package/docs/subagent-integration.md +2 -0
- package/docs/troubleshooting.md +1 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +7 -0
- package/src/access-intent/bash/wrapper-analysis.ts +29 -13
- package/src/authority/answer-window.ts +86 -0
- package/src/authority/decision-resolution.ts +2 -0
- package/src/authority/decision-source.ts +19 -0
- package/src/authority/local-user-authorizer.ts +2 -5
- package/src/authority/permission-dialog.ts +58 -14
- package/src/authority/permission-prompt-component.ts +109 -14
- package/src/config/config-loader.ts +1 -0
- package/src/config/config-schema.ts +6 -0
- package/src/config/extension-config.ts +5 -0
- package/src/index.ts +1 -0
- package/src/presentation/agent-renderer.ts +36 -0
- package/src/service/permission-events.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [40.2.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.2.0...pi-permission-system-v40.2.1) (2026-10-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** a computed word in a wrapper's options keeps the indirection floor ([754a5fa](https://github.com/gotgenes/pi-packages/commit/754a5fadcab0aeac765ecb8cf423b1fb55e8d4d5))
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** one literal-word rule for both wrapper floor exemptions ([334318a](https://github.com/gotgenes/pi-packages/commit/334318a0a918557e92741d442cba7728b66cc92e))
|
|
18
|
+
|
|
19
|
+
## [40.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.4...pi-permission-system-v40.2.0) (2026-10-12)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* **pi-permission-system:** deny a permission prompt nobody answers within promptTimeoutMs ([10812e8](https://github.com/gotgenes/pi-packages/commit/10812e83e8c766198d1b8a7eceafee7743bc6b23)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
|
|
25
|
+
* **pi-permission-system:** keep a permission prompt open once you start answering it ([39f7e3f](https://github.com/gotgenes/pi-packages/commit/39f7e3fba80d7c7235dc20c574430fb967779f0b)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** document promptTimeoutMs and the unanswered decider ([38eb4a6](https://github.com/gotgenes/pi-packages/commit/38eb4a6fe651ebc566b4d119a24a1d7645412dd7)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
|
|
30
|
+
* **pi-permission-system:** fix stale releasedDecision reference and note the fallback window spans follow-up dialogs ([4cc6b10](https://github.com/gotgenes/pi-packages/commit/4cc6b10327a504823d921fa11bc1b6bfa5bbb82e)), closes [#735](https://github.com/gotgenes/pi-packages/issues/735)
|
|
31
|
+
|
|
8
32
|
## [40.1.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v40.1.3...pi-permission-system-v40.1.4) (2026-10-10)
|
|
9
33
|
|
|
10
34
|
|
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ When the dialog prompts, you can approve once or approve a pattern for the rest
|
|
|
68
68
|
In an interactive TUI session the prompt is an inline keybind dialog — `y` approve, `s` approve for this session, `n` deny, `r` deny with a reason — where each hotkey arms and a second press confirms (configurable via `doublePressToConfirm`).
|
|
69
69
|
The hotkeys themselves are remappable through `permissionDialogKeys`, which matters if you type with an input method editor: composition mode swallows letter keys before they reach the terminal, and digits do not.
|
|
70
70
|
Set `promptNotifications` (for example `["bell"]`) and the dialog also rings the terminal as it opens, so a session waiting on you in another tab or pane gets flagged.
|
|
71
|
+
For an unattended run, set `promptTimeoutMs` and a prompt nobody answers within that window is denied, with the agent told nobody answered; by default a prompt waits indefinitely, and pressing any key stops the clock ([details](docs/configuration.md#unanswered-prompts)).
|
|
71
72
|
A file-access ask that proves a single direction offers `b` as well, granting the session both directions instead of only the one the gate proved.
|
|
72
73
|
The prompt shows one fact per line — who is asking, the tool, the matched rule, the value being decided — within a row budget, so a large tool input cannot take over the transcript; `Ctrl+O` (`app.tools.expand`) expands it to the complete request.
|
|
73
74
|
See [docs/configuration.md](docs/configuration.md#inline-permission-dialog-tui) for the hotkeys and [docs/session-approvals.md](docs/session-approvals.md) for session-scoped rules and pattern suggestions.
|
package/dist/public.d.ts
CHANGED
|
@@ -313,6 +313,11 @@ interface PermissionUiPromptEvent {
|
|
|
313
313
|
}
|
|
314
314
|
/** How a permission decision was reached. */
|
|
315
315
|
type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable"
|
|
316
|
+
/**
|
|
317
|
+
* A human was shown the prompt and nobody answered within the configured
|
|
318
|
+
* `promptTimeoutMs`, so the request was denied.
|
|
319
|
+
*/
|
|
320
|
+
| "prompt_unanswered"
|
|
316
321
|
/** A registered `authorizerChain` link granted the ask; no human was asked. */
|
|
317
322
|
| "authorizer_allowed"
|
|
318
323
|
/** A registered `authorizerChain` link refused the ask; no human was asked. */
|
package/docs/configuration.md
CHANGED
|
@@ -36,7 +36,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
|
|
|
36
36
|
4. Project agent frontmatter
|
|
37
37
|
|
|
38
38
|
The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
|
|
39
|
-
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
|
|
39
|
+
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
|
|
40
40
|
`permissionDialogKeys` replaces the whole map rather than merging entry by entry, so the map that was validated is the map that applies.
|
|
41
41
|
`promptNotifications` likewise replaces the whole list, so a project `[]` turns off a global list.
|
|
42
42
|
|
|
@@ -60,6 +60,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
60
60
|
"yoloMode": false,
|
|
61
61
|
"doublePressToConfirm": true,
|
|
62
62
|
"forwardingTimeoutMs": 600000,
|
|
63
|
+
// "promptTimeoutMs": 60000, // opt-in: deny a prompt nobody answers (unset = no timer)
|
|
63
64
|
"piInfrastructureReadPaths": [],
|
|
64
65
|
|
|
65
66
|
// Non-bash tools that carry shell semantics
|
|
@@ -108,6 +109,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
108
109
|
| `permissionDialogKeys` | — | Remaps the inline TUI dialog's decision hotkeys (see below). One printable character per decision; omitted decisions keep `y` / `s` / `b` / `n` / `r`. |
|
|
109
110
|
| `promptNotifications` | `[]` | Terminal notifications to emit when the inline TUI dialog opens: `"bell"`, `"osc9"`, `"osc777"` (see below). Empty for none. |
|
|
110
111
|
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
|
|
112
|
+
| `promptTimeoutMs` | — | How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Unset (the default) means no timer. See [Unanswered prompts](#unanswered-prompts). |
|
|
111
113
|
| `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
|
|
112
114
|
| `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
|
|
113
115
|
| `reviewLogFieldMaxWidth` | `1000` | Max characters of any one value written to the review log. A longer value is stored shortened, marked with an ellipsis. Raise it to keep longer values; this is a length bound, not redaction. |
|
|
@@ -229,6 +231,29 @@ Inside tmux, prefer `"bell"`: tmux passes a BEL through to its own bell handling
|
|
|
229
231
|
|
|
230
232
|
To run a program instead (Herdr, `cmux notify`, a push to your phone), write a small extension on the `permissions:ui_prompt` and `permissions:decision` broadcasts; see the [recipe](cross-extension-api.md#recipe-bridging-a-prompt-to-an-external-notifier).
|
|
231
233
|
|
|
234
|
+
### Unanswered prompts
|
|
235
|
+
|
|
236
|
+
By default a permission prompt waits until someone answers it.
|
|
237
|
+
For a run you leave unattended, set `promptTimeoutMs` so a prompt nobody answers is denied instead of holding the agent indefinitely:
|
|
238
|
+
|
|
239
|
+
```jsonc
|
|
240
|
+
{
|
|
241
|
+
"promptTimeoutMs": 60000
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
When the window expires, the prompt is dismissed and the request is **denied**; the timeout never approves anything.
|
|
246
|
+
The agent is told that nobody answered the approval prompt within the window, not that you refused it, so it can try another approach or carry on with other work.
|
|
247
|
+
The review log records the decision as `decidedBy: { "kind": "unanswered", "timeoutMs": 60000 }`, and the `permissions:decision` broadcast reports `prompt_unanswered`.
|
|
248
|
+
|
|
249
|
+
The clock starts when a prompt is shown, not when the request is raised.
|
|
250
|
+
A request waiting behind another open prompt gets its own full window once it is shown, so several unanswered requests take about one window each.
|
|
251
|
+
In the inline TUI dialog, the hint row names the window while it runs, and pressing any key stops the clock: once you start answering (moving the highlight, typing a denial reason), the dialog waits for you to finish.
|
|
252
|
+
The `select` fallback outside TUI mode has no keystroke to watch, so its window runs until you finish answering or it expires, and it spans the follow-up dialogs too: a window that expires while the grant-scope choice or the denial-reason input is open discards the half-made answer and denies the request as unanswered.
|
|
253
|
+
|
|
254
|
+
A request forwarded from a subagent is shown in the parent session, so the **parent's** `promptTimeoutMs` applies to it, and the subagent receives the same unanswered denial.
|
|
255
|
+
The subagent separately stops waiting after `forwardingTimeoutMs`; keep `promptTimeoutMs` below it, or the subagent can give up while the parent's prompt is still open.
|
|
256
|
+
|
|
232
257
|
### What a prompt shows
|
|
233
258
|
|
|
234
259
|
The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as `xargs` — the command that will actually run.
|
|
@@ -1069,15 +1094,18 @@ The floor guards unknowability of *scope*, and scope stays the path surfaces' jo
|
|
|
1069
1094
|
Such a unit is therefore **not** floored.
|
|
1070
1095
|
It resolves by the inner command's own `bash` rules instead, and the review log records `floorExemption: "core-reader"` beside the rule that decided, so an allow the floor would once have prompted for is auditable to the reason that let it through.
|
|
1071
1096
|
|
|
1072
|
-
All
|
|
1097
|
+
All five of these must hold, and each is a way the floor's reason could still apply:
|
|
1073
1098
|
|
|
1074
1099
|
1. The unit is an indirection wrapper.
|
|
1075
1100
|
An `sh -c`/`eval` payload is not one — see below.
|
|
1076
1101
|
2. The command it runs can be established without passing through an inline shell.
|
|
1077
1102
|
`watch` and `parallel` hand their whole command line to a shell, and `rush` and `rust-parallel` build theirs from a template, so a unit with any of the four as a layer never qualifies: `watch cat x \; rm y` runs `rm`.
|
|
1078
|
-
3.
|
|
1103
|
+
3. Every word the wrapper itself takes (an option, its value, an operand, a `NAME=value` word) is written literally.
|
|
1104
|
+
The shell splits or expands a computed word before the wrapper runs, so `sudo -u $U cat x` with `U="root rm y"` runs `rm y cat x`; quoting does not exempt it, since `"$@"` can vanish and a quoted variable can name an array.
|
|
1105
|
+
A computed word of the command it runs is fine: `sudo -u root cat $X` still qualifies.
|
|
1106
|
+
4. That command **proves** a read: a bare-basename core word with no option that withdraws the claim.
|
|
1079
1107
|
So `xargs sort -o /tmp/x` and `xargs find . -delete` stay floored, and so does `xargs ./grep foo`.
|
|
1080
|
-
|
|
1108
|
+
5. The enclosing statement provably writes no file through a redirect.
|
|
1081
1109
|
A destination the parse cannot resolve — `> $OUT`, `> $(mktemp)` — counts against the exemption rather than for it.
|
|
1082
1110
|
|
|
1083
1111
|
So `xargs grep -l foo`, `xargs wc -l`, `xargs sed -n 1p`, `find . -name '*.ts' -exec cat {} +`, and the no-op probe `sudo -n true` stop prompting under a matching `bash` allow, while `xargs rm`, `xargs sed -i`, and `find . -exec sh -c '…' \;` still prompt.
|
|
@@ -1090,7 +1118,7 @@ Three things this does **not** change:
|
|
|
1090
1118
|
Under `bash: {"*": "allow", "grep *": "deny"}`, `xargs grep foo` is denied rather than merely prompted.
|
|
1091
1119
|
- Path gating is untouched.
|
|
1092
1120
|
The exemption decides the `bash` surface only, and every path token the command projects still goes through `path` and `external_directory` with the direction its command proved.
|
|
1093
|
-
Clause
|
|
1121
|
+
Clause 5 is what keeps that from being a weaker promise than it sounds: a redirect destination the parse cannot resolve (`> $OUT`, `> $(mktemp)`) is not projected onto those surfaces either, so the wrapper keeps its floor rather than relying on a gate that would not see the write.
|
|
1094
1122
|
|
|
1095
1123
|
##### A wrapper that only modifies execution
|
|
1096
1124
|
|
|
@@ -1102,7 +1130,7 @@ All four of these must hold, and each keeps the decision about the command that
|
|
|
1102
1130
|
|
|
1103
1131
|
1. **Every** wrapper layer is one of the five.
|
|
1104
1132
|
`time sudo rm -rf x` and `sudo time pnpm test` stay floored, as do `nohup` (it may write `nohup.out`) and `flock` (it creates its lock file).
|
|
1105
|
-
2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, and
|
|
1133
|
+
2. Every option on each layer is one that wrapper is known to take, spelled in full, and every option, value, operand, and `NAME=value` word is written literally.
|
|
1106
1134
|
A word the shell rewrites (`timeout $D …`, `timeout {5,sudo} …`) may become several, one of them a wrapper, so it keeps the floor.
|
|
1107
1135
|
So `timeout --sig KILL 5 …` (an abbreviation) and `nice -5 …` stay floored, and so do `time`'s file-writing `-o`, `--output`, and `-a`.
|
|
1108
1136
|
The admitted options are `time -p`/`-l`/`-h`/`-f`; `timeout -s`/`-k`/`-f`/`-p`/`-v` and their long forms; `nice -n`/`--adjustment`; and `stdbuf -i`/`-o`/`-e` and their long forms.
|
|
@@ -502,20 +502,21 @@ pi.events.on("permissions:decision", (raw) => {
|
|
|
502
502
|
|
|
503
503
|
### Resolution Values
|
|
504
504
|
|
|
505
|
-
| Value | Meaning
|
|
506
|
-
| ----------------------------- |
|
|
507
|
-
| `policy_allow` | Config rule said allow — no prompt shown
|
|
508
|
-
| `policy_deny` | Config rule said deny — blocked immediately
|
|
509
|
-
| `session_approved` | Covered by a session-level approval from earlier in the same session
|
|
510
|
-
| `infrastructure_auto_allowed` | Read of a Pi infrastructure path — auto-allowed
|
|
511
|
-
| `user_approved` | User approved once via dialog
|
|
512
|
-
| `user_approved_for_session` | User approved for the rest of the session
|
|
513
|
-
| `user_denied` | User denied via dialog
|
|
514
|
-
| `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked
|
|
515
|
-
| `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked
|
|
516
|
-
| `auto_approved` | Yolo mode — approved automatically without dialog
|
|
517
|
-
| `confirmation_unavailable` | State was `ask` but no UI was available — blocked
|
|
518
|
-
| `
|
|
505
|
+
| Value | Meaning |
|
|
506
|
+
| ----------------------------- | ------------------------------------------------------------------------ |
|
|
507
|
+
| `policy_allow` | Config rule said allow — no prompt shown |
|
|
508
|
+
| `policy_deny` | Config rule said deny — blocked immediately |
|
|
509
|
+
| `session_approved` | Covered by a session-level approval from earlier in the same session |
|
|
510
|
+
| `infrastructure_auto_allowed` | Read of a Pi infrastructure path — auto-allowed |
|
|
511
|
+
| `user_approved` | User approved once via dialog |
|
|
512
|
+
| `user_approved_for_session` | User approved for the rest of the session |
|
|
513
|
+
| `user_denied` | User denied via dialog |
|
|
514
|
+
| `authorizer_allowed` | A registered `authorizerChain` link granted the ask — no human asked |
|
|
515
|
+
| `authorizer_denied` | A registered `authorizerChain` link refused the ask — no human asked |
|
|
516
|
+
| `auto_approved` | Yolo mode — approved automatically without dialog |
|
|
517
|
+
| `confirmation_unavailable` | State was `ask` but no UI was available — blocked |
|
|
518
|
+
| `prompt_unanswered` | A prompt was shown and nobody answered within `promptTimeoutMs` — denied |
|
|
519
|
+
| `gate_error` | The gate threw, or an escalation failed — blocked, fail-closed |
|
|
519
520
|
|
|
520
521
|
### Recipe: bridging a prompt to an external notifier
|
|
521
522
|
|
|
@@ -186,6 +186,8 @@ permission requests.
|
|
|
186
186
|
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
187
187
|
Absence of a record is deliberately **not** read as unknown: a cleanly exited parent leaves nothing behind, so treating absence as "maybe serving" would restore the full-timeout stall that the heartbeat exists to end.
|
|
188
188
|
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
189
|
+
The exception is the serving session's own `promptTimeoutMs`: when it is set and nobody answers the forwarded prompt within it, the serving session denies the request as unanswered and the child receives that decision, telling its agent that nobody answered rather than that the user refused.
|
|
190
|
+
Keep `promptTimeoutMs` below `forwardingTimeoutMs`, or the child can give up while the parent's prompt is still open.
|
|
189
191
|
That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
|
|
190
192
|
|
|
191
193
|
Every other way the forwarding path can give up — an unresolvable parent session, forwarding directories that cannot be created, a request that cannot be written, an unreadable response, and the timeout itself — is reported the same way: as approval being unavailable, with a reason naming the specific failure.
|
package/docs/troubleshooting.md
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
| External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
|
|
12
12
|
| Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
|
|
13
13
|
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
|
|
14
|
+
| Tool blocked with "Nobody answered the approval prompt … within Ns" | `promptTimeoutMs` is set and the prompt was left unanswered for that long | Answer prompts within the window, press any key in the inline dialog to stop its clock, or raise or unset `promptTimeoutMs` (see [Unanswered prompts](configuration.md#unanswered-prompts)). |
|
|
14
15
|
| Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
|
|
15
16
|
|
|
16
17
|
## Diagnostic Logging
|
package/package.json
CHANGED
|
@@ -86,6 +86,13 @@
|
|
|
86
86
|
"minimum": 1,
|
|
87
87
|
"maximum": 9007199254740991
|
|
88
88
|
},
|
|
89
|
+
"promptTimeoutMs": {
|
|
90
|
+
"description": "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Omit for no timer (the default). Pressing any key in the inline dialog stops the timer.",
|
|
91
|
+
"markdownDescription": "How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds.\n\nOmit for no timer (the default): a prompt waits until someone answers it. The clock starts when the prompt is shown, not when the request is raised, so a request queued behind another prompt gets its own full window. In the inline TUI dialog, pressing any key stops the timer, so a prompt you have started answering is never cut off.\n\nThe timeout only ever **denies**, and the agent is told nobody answered. It applies to prompts this session shows, including requests forwarded from its subagents; keep it below `forwardingTimeoutMs` so a forwarded request is answered before the subagent stops waiting.",
|
|
92
|
+
"type": "integer",
|
|
93
|
+
"minimum": 1,
|
|
94
|
+
"maximum": 9007199254740991
|
|
95
|
+
},
|
|
89
96
|
"promptMaxRows": {
|
|
90
97
|
"description": "Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
|
|
91
98
|
"markdownDescription": "Maximum rows a permission prompt renders before eliding its evidence.\n\nOmit to use the default (24). The request's own facts — the requesting agent, the tool, the matched rule, the decision-relevant value — are never elided by this budget; what gives way is the supporting evidence, and `Ctrl+O` expands the prompt to the complete request.",
|
|
@@ -158,7 +158,7 @@ export function executedUnitOf(
|
|
|
158
158
|
* alike. Argument-independence is the core's admission bar, so there are no
|
|
159
159
|
* arguments that make `grep` write a file.
|
|
160
160
|
*
|
|
161
|
-
*
|
|
161
|
+
* Five things must hold for it, and each is a way the reason could still hold:
|
|
162
162
|
*
|
|
163
163
|
* 1. The unit is an indirection wrapper — an ordinary command has no floor.
|
|
164
164
|
* 2. Unwrapping reached the inner command without passing through an opaque
|
|
@@ -168,16 +168,21 @@ export function executedUnitOf(
|
|
|
168
168
|
* line to a shell or builds it from a template
|
|
169
169
|
* ({@link OPAQUE_COMMAND_LINE_WRAPPERS}) is that payload in another shape:
|
|
170
170
|
* `watch cat x \; rm y` runs `rm`, so no layer may be one.
|
|
171
|
-
* 3.
|
|
171
|
+
* 3. Every word a peeled layer passes its wrapper is literal: each option,
|
|
172
|
+
* value, operand, and `NAME=value` word. The shell splits or expands a
|
|
173
|
+
* computed one before the wrapper runs, so one of the words it becomes may
|
|
174
|
+
* be the command that really runs — `sudo -u $U cat x` with
|
|
175
|
+
* `U="root rm y"` runs `rm y cat x` as root.
|
|
176
|
+
* 4. The inner command *proves* a read — bare-basename core word, retraction
|
|
172
177
|
* guards applied, so `xargs sort -o /tmp/x` and `xargs find . -delete` are
|
|
173
178
|
* not transparent.
|
|
174
|
-
*
|
|
179
|
+
* 5. The unit writes no file through a redirect, which the caller reads off
|
|
175
180
|
* the parse tree and this module never sees.
|
|
176
181
|
*
|
|
177
182
|
* `"execution-modifier"`: every wrapper layer only changes *how* the visible
|
|
178
183
|
* inner command runs, so the unit resolves by that command's own rule whatever
|
|
179
|
-
* it does (see {@link onlyModifiesExecution}). The first
|
|
180
|
-
* hold for it too; the core-reader reason is preferred when both apply.
|
|
184
|
+
* it does (see {@link onlyModifiesExecution}). The first three conditions
|
|
185
|
+
* above hold for it too; the core-reader reason is preferred when both apply.
|
|
181
186
|
*/
|
|
182
187
|
export function floorExemptionOf(
|
|
183
188
|
words: readonly CommandWord[],
|
|
@@ -191,7 +196,8 @@ export function floorExemptionOf(
|
|
|
191
196
|
if (
|
|
192
197
|
unwrapped.kind === "opaque" ||
|
|
193
198
|
unwrapped.peeled.length === 0 ||
|
|
194
|
-
unwrapped.peeled.some(runsOpaqueCommandLine)
|
|
199
|
+
unwrapped.peeled.some(runsOpaqueCommandLine) ||
|
|
200
|
+
unwrapped.peeled.some(carriesComputedWord)
|
|
195
201
|
) {
|
|
196
202
|
return undefined;
|
|
197
203
|
}
|
|
@@ -211,6 +217,17 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
|
|
|
211
217
|
return name !== undefined && OPAQUE_COMMAND_LINE_WRAPPERS.has(name);
|
|
212
218
|
}
|
|
213
219
|
|
|
220
|
+
/**
|
|
221
|
+
* True when the shell may rewrite a word a peeled layer passes its wrapper.
|
|
222
|
+
*
|
|
223
|
+
* The wrapper name is skipped: it is matched by its text against a fixed
|
|
224
|
+
* vocabulary, so a computed spelling never classifies as a wrapper, and the
|
|
225
|
+
* word adapter reads a `command_name` node as computed whatever it spells.
|
|
226
|
+
*/
|
|
227
|
+
function carriesComputedWord(layer: readonly CommandWord[]): boolean {
|
|
228
|
+
return layer.slice(1).some((word) => word.computed);
|
|
229
|
+
}
|
|
230
|
+
|
|
214
231
|
/**
|
|
215
232
|
* True when every peeled layer only changes *how* the inner command runs, and
|
|
216
233
|
* that command is a literal name the gate can resolve by its own rule.
|
|
@@ -230,9 +247,9 @@ function runsOpaqueCommandLine(layer: readonly CommandWord[]): boolean {
|
|
|
230
247
|
* 2. Every option in every layer is on that modifier's allowlist. The real
|
|
231
248
|
* tools accept abbreviations (`timeout --sig KILL 5 rm`) the inner-command
|
|
232
249
|
* search does not know, and one of them misplaces where the command starts.
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
250
|
+
* That every word is also literal is {@link floorExemptionOf}'s condition,
|
|
251
|
+
* shared with the core-reader reason: the shell splits `timeout $D pnpm` or
|
|
252
|
+
* expands `timeout {5,sudo} rm` into extra words before the modifier runs.
|
|
236
253
|
* 3. The peel ended at an ordinary command, not a wrapper it could not see
|
|
237
254
|
* past.
|
|
238
255
|
* 4. The inner head is a literal command name. The grammar has no `time`
|
|
@@ -267,6 +284,9 @@ function reachedLiteralCommand(inner: readonly CommandWord[]): boolean {
|
|
|
267
284
|
*
|
|
268
285
|
* Walks the words with the same value-taking table {@link innerCommandIndex}
|
|
269
286
|
* skips by, so an admitted option's value can never be taken for the command.
|
|
287
|
+
* It reads option shapes only: each caller refuses a computed word before it
|
|
288
|
+
* gets here ({@link floorExemptionOf}'s literal-layer condition, and
|
|
289
|
+
* {@link sessionCommandIndex}'s refusal of any token with shell syntax).
|
|
270
290
|
*/
|
|
271
291
|
function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
|
|
272
292
|
const name = wrapperName(layer);
|
|
@@ -278,15 +298,11 @@ function isAdmittedModifierLayer(layer: readonly CommandWord[]): boolean {
|
|
|
278
298
|
for (let index = 1; index < layer.length; index++) {
|
|
279
299
|
const word = layer[index].text;
|
|
280
300
|
if (isEnvironmentAssignment(word)) continue;
|
|
281
|
-
// A word the shell rewrites (`$D`, `{5,sudo}`, `*`) may become several, one
|
|
282
|
-
// of them a wrapper or the real command, so only a literal word is admitted.
|
|
283
|
-
if (layer[index].computed) return false;
|
|
284
301
|
if (word === "--") continue;
|
|
285
302
|
if (!word.startsWith("-")) continue;
|
|
286
303
|
if (flags.has(word)) continue;
|
|
287
304
|
if (valueTaking.has(word)) {
|
|
288
305
|
index++;
|
|
289
|
-
if (layer[index]?.computed) return false;
|
|
290
306
|
continue;
|
|
291
307
|
}
|
|
292
308
|
if (!hasAttachedValue(word, valueTaking)) return false;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How long a shown permission prompt waits for a human before it denies.
|
|
5
|
+
*
|
|
6
|
+
* Opened when a prompt is presented — never when its ask is admitted, so an
|
|
7
|
+
* ask queued behind another dialog gets its own full window — and closed when
|
|
8
|
+
* a human answers. A human who starts answering engages it, which stops the
|
|
9
|
+
* clock without settling anything: the window bounds an unattended prompt and
|
|
10
|
+
* never cuts off someone mid-answer. If neither happens first, the window aborts its signal,
|
|
11
|
+
* which dismisses the prompt, and the window itself is what decided: it stamps
|
|
12
|
+
* the `unanswered` decider rather than leaving the caller to infer one.
|
|
13
|
+
*
|
|
14
|
+
* The timer is `unref`ed, so a window still open when the session ends never
|
|
15
|
+
* holds the process alive.
|
|
16
|
+
*/
|
|
17
|
+
export class AnswerWindow {
|
|
18
|
+
private timer: NodeJS.Timeout | undefined;
|
|
19
|
+
|
|
20
|
+
private constructor(
|
|
21
|
+
private readonly timeoutMs: number | undefined,
|
|
22
|
+
private readonly controller: AbortController | undefined,
|
|
23
|
+
) {
|
|
24
|
+
if (timeoutMs !== undefined && controller !== undefined) {
|
|
25
|
+
this.timer = setTimeout(() => {
|
|
26
|
+
this.timer = undefined;
|
|
27
|
+
controller.abort();
|
|
28
|
+
}, timeoutMs);
|
|
29
|
+
this.timer.unref();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Opens a window, or a null window that never expires when `timeoutMs` is undefined. */
|
|
34
|
+
static open(timeoutMs: number | undefined): AnswerWindow {
|
|
35
|
+
return timeoutMs === undefined
|
|
36
|
+
? new AnswerWindow(undefined, undefined)
|
|
37
|
+
: new AnswerWindow(timeoutMs, new AbortController());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Aborts when the window expires; `undefined` when no window is configured. */
|
|
41
|
+
get signal(): AbortSignal | undefined {
|
|
42
|
+
return this.controller?.signal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The window's length while its clock is running; `undefined` once stopped, expired, or never opened. */
|
|
46
|
+
get pendingTimeoutMs(): number | undefined {
|
|
47
|
+
return this.timer === undefined ? undefined : this.timeoutMs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A human answered: stop the clock. Idempotent. */
|
|
51
|
+
close(): void {
|
|
52
|
+
this.stopClock();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A human started answering: stop the clock and let them finish. Idempotent. */
|
|
56
|
+
engage(): void {
|
|
57
|
+
this.stopClock();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The decision for a prompt this window dismissed.
|
|
62
|
+
*
|
|
63
|
+
* `confirmationUnavailable` holds because no human ruled. A null window
|
|
64
|
+
* cannot have dismissed anything, so asking it is a caller defect, and the
|
|
65
|
+
* throw fails the ask closed at the gate boundary rather than inventing a
|
|
66
|
+
* decider.
|
|
67
|
+
*/
|
|
68
|
+
expiredDecision(): PermissionPromptDecision {
|
|
69
|
+
if (this.timeoutMs === undefined) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"no answer window was open, so no prompt can have expired",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
approved: false,
|
|
76
|
+
state: "denied",
|
|
77
|
+
confirmationUnavailable: true,
|
|
78
|
+
decidedBy: { kind: "unanswered", timeoutMs: this.timeoutMs },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private stopClock(): void {
|
|
83
|
+
clearTimeout(this.timer);
|
|
84
|
+
this.timer = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -47,6 +47,8 @@ export function resolutionFor(
|
|
|
47
47
|
return "confirmation_unavailable";
|
|
48
48
|
case "gate_error":
|
|
49
49
|
return "gate_error";
|
|
50
|
+
case "unanswered":
|
|
51
|
+
return "prompt_unanswered";
|
|
50
52
|
case "user":
|
|
51
53
|
// A `forwarded` frame reaches here only when the responder named no
|
|
52
54
|
// decider — an older parent — so the hop is the only fact available and
|
|
@@ -51,6 +51,12 @@ export type DecisionSource =
|
|
|
51
51
|
| { kind: "unavailable"; reason: string }
|
|
52
52
|
/** A gate threw, and the boundary blocked rather than allowed. */
|
|
53
53
|
| { kind: "gate_error"; reason: string }
|
|
54
|
+
/**
|
|
55
|
+
* A human was shown the prompt and nobody answered it within the configured
|
|
56
|
+
* `promptTimeoutMs` window, so the window denied. Distinct from
|
|
57
|
+
* `unavailable`, where no prompt could be shown at all.
|
|
58
|
+
*/
|
|
59
|
+
| { kind: "unanswered"; timeoutMs: number }
|
|
54
60
|
/**
|
|
55
61
|
* Another session decided. Recursive by design: the requesting side records
|
|
56
62
|
* both that the decider was elsewhere and what, within that session, decided
|
|
@@ -147,6 +153,8 @@ function narrowSource(
|
|
|
147
153
|
return typeof candidate.reason === "string"
|
|
148
154
|
? { kind: "gate_error", reason: candidate.reason }
|
|
149
155
|
: undefined;
|
|
156
|
+
case "unanswered":
|
|
157
|
+
return narrowUnanswered(candidate);
|
|
150
158
|
case "forwarded":
|
|
151
159
|
return narrowForwarded(candidate, depthBudget);
|
|
152
160
|
default:
|
|
@@ -161,6 +169,17 @@ function narrowUser(
|
|
|
161
169
|
return via ? { kind: "user", via } : undefined;
|
|
162
170
|
}
|
|
163
171
|
|
|
172
|
+
function narrowUnanswered(
|
|
173
|
+
candidate: Record<string, unknown>,
|
|
174
|
+
): DecisionSource | undefined {
|
|
175
|
+
const { timeoutMs } = candidate;
|
|
176
|
+
return typeof timeoutMs === "number" &&
|
|
177
|
+
Number.isFinite(timeoutMs) &&
|
|
178
|
+
timeoutMs > 0
|
|
179
|
+
? { kind: "unanswered", timeoutMs }
|
|
180
|
+
: undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
164
183
|
function narrowAuthorizer(
|
|
165
184
|
candidate: Record<string, unknown>,
|
|
166
185
|
): DecisionSource | undefined {
|
|
@@ -66,10 +66,7 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
66
66
|
authorize(
|
|
67
67
|
details: PromptPermissionDetails,
|
|
68
68
|
): Promise<PermissionPromptDecision> {
|
|
69
|
-
return this.deps.dialogs.run(
|
|
70
|
-
() => this.present(details),
|
|
71
|
-
unansweredDecision,
|
|
72
|
-
);
|
|
69
|
+
return this.deps.dialogs.run(() => this.present(details), releasedDecision);
|
|
73
70
|
}
|
|
74
71
|
|
|
75
72
|
/**
|
|
@@ -114,7 +111,7 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
114
111
|
* one string so what the model is told and what the log attributes cannot
|
|
115
112
|
* drift (#726).
|
|
116
113
|
*/
|
|
117
|
-
function
|
|
114
|
+
function releasedDecision(reason: string): PermissionPromptDecision {
|
|
118
115
|
return {
|
|
119
116
|
approved: false,
|
|
120
117
|
state: "denied",
|
|
@@ -17,7 +17,8 @@ export type PermissionPromptDecision = {
|
|
|
17
17
|
* reachable at all (`DenyingAuthorizer`, a no-UI non-subagent session) or the
|
|
18
18
|
* forwarding path gave up before reaching one (`ParentAuthorizer` — target
|
|
19
19
|
* unresolvable, request undeliverable, target not serving, or no answer
|
|
20
|
-
* within the timeout)
|
|
20
|
+
* within the timeout), or a prompt was shown and nobody answered it within
|
|
21
|
+
* its `promptTimeoutMs` window (`AnswerWindow`). Consumed by the gate (block reason) and
|
|
21
22
|
* `PermissionPrompter` (review-entry resolution) to report
|
|
22
23
|
* "confirmation_unavailable" rather than a plain user denial — a user who
|
|
23
24
|
* was never asked denied nothing (#719). The decision-event resolution
|
|
@@ -55,9 +56,22 @@ export type PermissionPromptDecision = {
|
|
|
55
56
|
*/
|
|
56
57
|
export type UnattributedDecision = Omit<PermissionPromptDecision, "decidedBy">;
|
|
57
58
|
|
|
59
|
+
/** What the fallback hands each host dialog: the signal that may dismiss it. */
|
|
60
|
+
export interface PermissionDialogOptions {
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
58
64
|
export interface PermissionDecisionUi {
|
|
59
|
-
select(
|
|
60
|
-
|
|
65
|
+
select(
|
|
66
|
+
title: string,
|
|
67
|
+
options: string[],
|
|
68
|
+
opts?: PermissionDialogOptions,
|
|
69
|
+
): Promise<string | undefined>;
|
|
70
|
+
input(
|
|
71
|
+
title: string,
|
|
72
|
+
placeholder?: string,
|
|
73
|
+
opts?: PermissionDialogOptions,
|
|
74
|
+
): Promise<string | undefined>;
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
const APPROVE_OPTION = "Yes";
|
|
@@ -140,12 +154,25 @@ export interface RequestPermissionOptions {
|
|
|
140
154
|
};
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Ask through the host's `select`/`input` dialogs.
|
|
159
|
+
*
|
|
160
|
+
* `signal` reaches every dialog, and the host resolves a dismissed one
|
|
161
|
+
* `undefined` — the same value a human cancel produces. The two are told apart
|
|
162
|
+
* by the signal: an `undefined` while it is aborted answers `undefined` (the
|
|
163
|
+
* prompt was dismissed, so no human ruled), and one while it is live keeps its
|
|
164
|
+
* meaning as the human's cancel.
|
|
165
|
+
*/
|
|
143
166
|
export async function requestPermissionDecisionFromUi(
|
|
144
167
|
ui: PermissionDecisionUi,
|
|
145
168
|
title: string,
|
|
146
169
|
message: string,
|
|
147
170
|
options?: RequestPermissionOptions,
|
|
148
|
-
|
|
171
|
+
signal?: AbortSignal,
|
|
172
|
+
): Promise<UnattributedDecision | undefined> {
|
|
173
|
+
const dialogOptions: PermissionDialogOptions = { signal };
|
|
174
|
+
const dismissed = (answer: string | undefined): boolean =>
|
|
175
|
+
answer === undefined && signal?.aborted === true;
|
|
149
176
|
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
150
177
|
const widthOption = options?.sessionWidth?.label;
|
|
151
178
|
const decisionOptions = [
|
|
@@ -156,7 +183,14 @@ export async function requestPermissionDecisionFromUi(
|
|
|
156
183
|
DENY_WITH_REASON_OPTION,
|
|
157
184
|
];
|
|
158
185
|
|
|
159
|
-
const selected = await ui.select(
|
|
186
|
+
const selected = await ui.select(
|
|
187
|
+
`${title}\n${message}`,
|
|
188
|
+
decisionOptions,
|
|
189
|
+
dialogOptions,
|
|
190
|
+
);
|
|
191
|
+
if (dismissed(selected)) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
160
194
|
|
|
161
195
|
if (selected === APPROVE_OPTION) {
|
|
162
196
|
return {
|
|
@@ -171,10 +205,17 @@ export async function requestPermissionDecisionFromUi(
|
|
|
171
205
|
const width: SessionGrantWidth =
|
|
172
206
|
selected === widthOption ? "family" : "proven";
|
|
173
207
|
if (options?.sessionScope) {
|
|
174
|
-
const scope = await ui.select(
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
208
|
+
const scope = await ui.select(
|
|
209
|
+
`${title}\nApply this session grant to:`,
|
|
210
|
+
[
|
|
211
|
+
options.sessionScope.subagentLabel,
|
|
212
|
+
options.sessionScope.servingSessionLabel,
|
|
213
|
+
],
|
|
214
|
+
dialogOptions,
|
|
215
|
+
);
|
|
216
|
+
if (dismissed(scope)) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
178
219
|
return sessionApproval(
|
|
179
220
|
// A cancelled scope select (undefined) falls back to the
|
|
180
221
|
// least-privilege subagent scope.
|
|
@@ -188,12 +229,15 @@ export async function requestPermissionDecisionFromUi(
|
|
|
188
229
|
}
|
|
189
230
|
|
|
190
231
|
if (selected === DENY_WITH_REASON_OPTION) {
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
),
|
|
232
|
+
const typed = await ui.input(
|
|
233
|
+
`${title}\nShare why this request was denied (optional).`,
|
|
234
|
+
"Reason shown back to the agent",
|
|
235
|
+
dialogOptions,
|
|
196
236
|
);
|
|
237
|
+
if (dismissed(typed)) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
const denialReason = normalizePermissionDenialReason(typed);
|
|
197
241
|
|
|
198
242
|
return createDeniedPermissionDecision(denialReason);
|
|
199
243
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
renderPromptNotification,
|
|
24
24
|
} from "#src/presentation/prompt-notification";
|
|
25
25
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
26
|
+
import { AnswerWindow } from "./answer-window";
|
|
26
27
|
import { collapsePastedNewlines } from "./bracketed-paste";
|
|
27
28
|
import type { DecisionSource, UserDecisionSurface } from "./decision-source";
|
|
28
29
|
import {
|
|
@@ -76,6 +77,8 @@ export interface PromptPreferences {
|
|
|
76
77
|
dialogKeys: DialogKeyBindings;
|
|
77
78
|
/** Terminal notifications the inline dialog emits as it opens; empty for none. */
|
|
78
79
|
promptNotifications: readonly PromptNotificationChannel[];
|
|
80
|
+
/** How long a shown prompt waits for an answer before it denies; absent for no timer. */
|
|
81
|
+
promptTimeoutMs?: number;
|
|
79
82
|
}
|
|
80
83
|
|
|
81
84
|
/**
|
|
@@ -89,6 +92,10 @@ export interface PromptPreferences {
|
|
|
89
92
|
* answered on, so it is where the decision is attributed to that surface
|
|
90
93
|
* (#726). Having the dialog model and the fallback each name themselves would
|
|
91
94
|
* be two sites that must agree with this branch.
|
|
95
|
+
*
|
|
96
|
+
* It also opens the prompt's {@link AnswerWindow} as the prompt is shown. A
|
|
97
|
+
* surface the window dismissed answers `undefined`, and the window, which is
|
|
98
|
+
* what decided, stamps the `unanswered` decision itself.
|
|
92
99
|
*/
|
|
93
100
|
export async function requestPermissionDecision(
|
|
94
101
|
view: PermissionPromptView,
|
|
@@ -96,12 +103,65 @@ export async function requestPermissionDecision(
|
|
|
96
103
|
payload: PromptPayload,
|
|
97
104
|
options?: RequestPermissionOptions,
|
|
98
105
|
): Promise<PermissionPromptDecision> {
|
|
106
|
+
const window = AnswerWindow.open(view.promptTimeoutMs);
|
|
107
|
+
const surface = chooseDecisionSurface(view, title, payload, options);
|
|
108
|
+
try {
|
|
109
|
+
const answer = await surface.ask(window);
|
|
110
|
+
return answer === undefined
|
|
111
|
+
? window.expiredDecision()
|
|
112
|
+
: attributeToHuman(answer, surface.via);
|
|
113
|
+
} finally {
|
|
114
|
+
window.close();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* One human-facing surface: how to ask on it, and the name it answers under.
|
|
120
|
+
*
|
|
121
|
+
* `ask` answers `undefined` only when the window dismissed the prompt.
|
|
122
|
+
*/
|
|
123
|
+
interface DecisionSurface {
|
|
124
|
+
via: UserDecisionSurface;
|
|
125
|
+
ask: (window: DialogClock) => Promise<UnattributedDecision | undefined>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The answer window as a surface uses it: the signal that dismisses the
|
|
130
|
+
* prompt, and — for the inline dialog, which sees keystrokes — the
|
|
131
|
+
* running length to show and the means to stop it once the human engages.
|
|
132
|
+
*/
|
|
133
|
+
export type DialogClock = Pick<
|
|
134
|
+
AnswerWindow,
|
|
135
|
+
"signal" | "pendingTimeoutMs" | "engage"
|
|
136
|
+
>;
|
|
137
|
+
|
|
138
|
+
function chooseDecisionSurface(
|
|
139
|
+
view: PermissionPromptView,
|
|
140
|
+
title: string,
|
|
141
|
+
payload: PromptPayload,
|
|
142
|
+
options?: RequestPermissionOptions,
|
|
143
|
+
): DecisionSurface {
|
|
99
144
|
if (view.mode === "tui") {
|
|
100
|
-
return
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
return {
|
|
146
|
+
via: "dialog",
|
|
147
|
+
ask: (window) =>
|
|
148
|
+
presentInlinePermissionPrompt(view, title, payload, options, window),
|
|
149
|
+
};
|
|
104
150
|
}
|
|
151
|
+
return {
|
|
152
|
+
via: "select",
|
|
153
|
+
ask: (window) =>
|
|
154
|
+
askThroughFallback(view, title, payload, options, window.signal),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function askThroughFallback(
|
|
159
|
+
view: PermissionPromptView,
|
|
160
|
+
title: string,
|
|
161
|
+
payload: PromptPayload,
|
|
162
|
+
options: RequestPermissionOptions | undefined,
|
|
163
|
+
signal: AbortSignal | undefined,
|
|
164
|
+
): Promise<UnattributedDecision | undefined> {
|
|
105
165
|
// The fallback renders once and cannot re-render, so it neither paints nor
|
|
106
166
|
// offers an expansion; it substitutes a nominal width for the terminal size
|
|
107
167
|
// it is never told, and the host's own select wraps from there.
|
|
@@ -109,14 +169,12 @@ export async function requestPermissionDecision(
|
|
|
109
169
|
...view.budget,
|
|
110
170
|
width: FALLBACK_RENDER_WIDTH,
|
|
111
171
|
});
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
),
|
|
119
|
-
"select",
|
|
172
|
+
return requestPermissionDecisionFromUi(
|
|
173
|
+
view.ui,
|
|
174
|
+
title,
|
|
175
|
+
rendered.lines.join("\n"),
|
|
176
|
+
options,
|
|
177
|
+
signal,
|
|
120
178
|
);
|
|
121
179
|
}
|
|
122
180
|
|
|
@@ -146,12 +204,18 @@ const OPTION_LABELS: Record<PromptAction, string> = {
|
|
|
146
204
|
denyWithReason: "No, provide reason",
|
|
147
205
|
};
|
|
148
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Show the inline dialog. The clock's signal dismisses it: an abort closes the
|
|
209
|
+
* dialog and answers `undefined`, so a caller can tell a dismissal from a
|
|
210
|
+
* human answer. The first keystroke engages the clock, stopping it.
|
|
211
|
+
*/
|
|
149
212
|
export function presentInlinePermissionPrompt(
|
|
150
213
|
view: PermissionPromptView,
|
|
151
214
|
title: string,
|
|
152
215
|
payload: PromptPayload,
|
|
153
216
|
options?: RequestPermissionOptions,
|
|
154
|
-
|
|
217
|
+
clock?: DialogClock,
|
|
218
|
+
): Promise<UnattributedDecision | undefined> {
|
|
155
219
|
const config: PromptModelConfig = {
|
|
156
220
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
157
221
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
@@ -159,8 +223,17 @@ export function presentInlinePermissionPrompt(
|
|
|
159
223
|
sessionScope: options?.sessionScope,
|
|
160
224
|
keys: view.dialogKeys,
|
|
161
225
|
};
|
|
162
|
-
return view.ui.custom<UnattributedDecision>(
|
|
226
|
+
return view.ui.custom<UnattributedDecision | undefined>(
|
|
163
227
|
(tui, theme, keybindings, done) => {
|
|
228
|
+
// The host's `done` is idempotent, so a human answer racing the abort
|
|
229
|
+
// settles whichever arrives first.
|
|
230
|
+
clock?.signal?.addEventListener(
|
|
231
|
+
"abort",
|
|
232
|
+
() => {
|
|
233
|
+
done(undefined);
|
|
234
|
+
},
|
|
235
|
+
{ once: true },
|
|
236
|
+
);
|
|
164
237
|
// The factory runs once, as the dialog mounts, which is the moment the
|
|
165
238
|
// human is being asked; a re-render does not come back through here.
|
|
166
239
|
const notification = renderPromptNotification(
|
|
@@ -181,6 +254,7 @@ export function presentInlinePermissionPrompt(
|
|
|
181
254
|
tui.requestRender();
|
|
182
255
|
},
|
|
183
256
|
done,
|
|
257
|
+
clock,
|
|
184
258
|
);
|
|
185
259
|
},
|
|
186
260
|
{ overlay: false },
|
|
@@ -228,6 +302,7 @@ class PermissionPromptComponent implements Component {
|
|
|
228
302
|
private readonly handleAppAction: (data: string) => boolean,
|
|
229
303
|
private readonly requestRender: () => void,
|
|
230
304
|
private readonly done: (decision: UnattributedDecision) => void,
|
|
305
|
+
private readonly clock: DialogClock | undefined,
|
|
231
306
|
) {
|
|
232
307
|
this.state = initialPromptState(config);
|
|
233
308
|
this.reason = this.createReasonEditor();
|
|
@@ -306,10 +381,15 @@ class PermissionPromptComponent implements Component {
|
|
|
306
381
|
} else if (view.elided) {
|
|
307
382
|
keys.push("ctrl+o full request");
|
|
308
383
|
}
|
|
384
|
+
const pendingTimeoutMs = this.clock?.pendingTimeoutMs;
|
|
385
|
+
if (pendingTimeoutMs !== undefined) {
|
|
386
|
+
keys.push(`unanswered, denies in ${pendingTimeoutMs / 1000}s`);
|
|
387
|
+
}
|
|
309
388
|
return this.theme.fg("muted", keys.join(" · "));
|
|
310
389
|
}
|
|
311
390
|
|
|
312
391
|
handleInput(data: string): void {
|
|
392
|
+
this.engageClock();
|
|
313
393
|
if (this.state.step === "reason") {
|
|
314
394
|
this.handleReasonInput(data);
|
|
315
395
|
return;
|
|
@@ -327,6 +407,21 @@ class PermissionPromptComponent implements Component {
|
|
|
327
407
|
}
|
|
328
408
|
}
|
|
329
409
|
|
|
410
|
+
/**
|
|
411
|
+
* Stop the answer window on the human's first keystroke.
|
|
412
|
+
*
|
|
413
|
+
* The window bounds a prompt nobody is attending to; a keystroke proves
|
|
414
|
+
* someone is, so the dialog waits for them to finish rather than vanishing
|
|
415
|
+
* mid-answer. Re-renders so the hint stops advertising a deadline.
|
|
416
|
+
*/
|
|
417
|
+
private engageClock(): void {
|
|
418
|
+
if (this.clock?.pendingTimeoutMs === undefined) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
this.clock.engage();
|
|
422
|
+
this.requestRender();
|
|
423
|
+
}
|
|
424
|
+
|
|
330
425
|
/**
|
|
331
426
|
* Hand the keystroke to the framework line editor.
|
|
332
427
|
*
|
|
@@ -388,6 +388,12 @@ export const unifiedConfigSchema = z
|
|
|
388
388
|
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds.\n\nOmit to use the default (`600000`, ten minutes). A child whose in-process parent is not draining its inbox at all gives up in a couple of seconds regardless of this value, so lower it only to bound how long you are willing to leave an *unanswered* prompt pending.",
|
|
389
389
|
default: 600000,
|
|
390
390
|
}),
|
|
391
|
+
promptTimeoutMs: z.number().int().min(1).optional().meta({
|
|
392
|
+
description:
|
|
393
|
+
"How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds. Omit for no timer (the default). Pressing any key in the inline dialog stops the timer.",
|
|
394
|
+
markdownDescription:
|
|
395
|
+
"How long a permission prompt, once shown, waits for an answer before it is dismissed and the request denied, in milliseconds.\n\nOmit for no timer (the default): a prompt waits until someone answers it. The clock starts when the prompt is shown, not when the request is raised, so a request queued behind another prompt gets its own full window. In the inline TUI dialog, pressing any key stops the timer, so a prompt you have started answering is never cut off.\n\nThe timeout only ever **denies**, and the agent is told nobody answered. It applies to prompts this session shows, including requests forwarded from its subagents; keep it below `forwardingTimeoutMs` so a forwarded request is answered before the subagent stops waiting.",
|
|
396
|
+
}),
|
|
391
397
|
promptMaxRows: z.number().int().min(1).optional().meta({
|
|
392
398
|
description:
|
|
393
399
|
"Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
|
|
@@ -24,6 +24,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
24
24
|
piInfrastructureReadPaths?: string[];
|
|
25
25
|
/** How long a subagent waits for the parent's answer to a forwarded ask, in ms. Defaults to 600000. */
|
|
26
26
|
forwardingTimeoutMs?: number;
|
|
27
|
+
/** How long a shown permission prompt waits for an answer before denying, in ms. Absent means no timer. */
|
|
28
|
+
promptTimeoutMs?: number;
|
|
27
29
|
/** Max rows a permission prompt renders before eliding its evidence. Defaults to 24. */
|
|
28
30
|
promptMaxRows?: number;
|
|
29
31
|
/** Max characters of any one field shown in a permission prompt. Defaults to 400. */
|
|
@@ -84,6 +86,9 @@ export function normalizePermissionSystemConfig(
|
|
|
84
86
|
if (raw.forwardingTimeoutMs !== undefined) {
|
|
85
87
|
result.forwardingTimeoutMs = raw.forwardingTimeoutMs;
|
|
86
88
|
}
|
|
89
|
+
if (raw.promptTimeoutMs !== undefined) {
|
|
90
|
+
result.promptTimeoutMs = raw.promptTimeoutMs;
|
|
91
|
+
}
|
|
87
92
|
if (raw.promptMaxRows !== undefined) {
|
|
88
93
|
result.promptMaxRows = raw.promptMaxRows;
|
|
89
94
|
}
|
package/src/index.ts
CHANGED
|
@@ -159,6 +159,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
159
159
|
budget: resolveRenderBudget(configStore.current()),
|
|
160
160
|
dialogKeys: resolveDialogKeys(configStore.current()).keys,
|
|
161
161
|
promptNotifications: configStore.current().promptNotifications ?? [],
|
|
162
|
+
promptTimeoutMs: configStore.current().promptTimeoutMs,
|
|
162
163
|
}),
|
|
163
164
|
requestPermissionDecision,
|
|
164
165
|
forwardingDir: paths.forwardingDir,
|
|
@@ -82,6 +82,13 @@ export function renderRefusal(
|
|
|
82
82
|
);
|
|
83
83
|
case "unavailable":
|
|
84
84
|
return renderUnavailableDenial(payload, denialReason, budget);
|
|
85
|
+
case "unanswered":
|
|
86
|
+
return renderUnansweredDenial(
|
|
87
|
+
payload,
|
|
88
|
+
{ timeoutMs: decider.timeoutMs, decidedElsewhere },
|
|
89
|
+
denialReason,
|
|
90
|
+
budget,
|
|
91
|
+
);
|
|
85
92
|
case "rule":
|
|
86
93
|
return renderEscalatedPolicyDenial(
|
|
87
94
|
payload,
|
|
@@ -231,6 +238,35 @@ export function renderUnavailableDenial(
|
|
|
231
238
|
);
|
|
232
239
|
}
|
|
233
240
|
|
|
241
|
+
/** The prompt window that expired, and where the prompt was shown. */
|
|
242
|
+
export interface UnansweredPrompt {
|
|
243
|
+
/** How long the prompt stayed open unanswered, in milliseconds. */
|
|
244
|
+
readonly timeoutMs: number;
|
|
245
|
+
/** Whether the prompt was shown in a session reached through a forwarding hop. */
|
|
246
|
+
readonly decidedElsewhere: boolean;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The agent-facing render of a prompt nobody answered within its window.
|
|
251
|
+
*
|
|
252
|
+
* Unlike {@link renderUnavailableDenial}, a prompt *was* shown, so the sentence
|
|
253
|
+
* says nobody answered rather than that no UI exists; unlike
|
|
254
|
+
* {@link renderUserDenial}, it never claims the user refused. Like the
|
|
255
|
+
* unavailable render, it omits the escaped boundary: no rule ruled, so no
|
|
256
|
+
* retry shape would change the outcome.
|
|
257
|
+
*/
|
|
258
|
+
export function renderUnansweredDenial(
|
|
259
|
+
payload: PromptPayload,
|
|
260
|
+
prompt: UnansweredPrompt,
|
|
261
|
+
denialReason: string | null,
|
|
262
|
+
budget: AgentRenderBudget = DEFAULT_RENDER_BUDGET,
|
|
263
|
+
): string {
|
|
264
|
+
return tagged(
|
|
265
|
+
`Nobody${servingClause(prompt.decidedElsewhere)} answered the approval prompt for this ${identification(payload, budget, "call", askRuleClause(payload))} within ${prompt.timeoutMs / 1000}s.`,
|
|
266
|
+
denialReason,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
234
270
|
// ── Sentence assembly ──────────────────────────────────────────────────────
|
|
235
271
|
|
|
236
272
|
/**
|
|
@@ -139,6 +139,11 @@ export type PermissionDecisionResolution =
|
|
|
139
139
|
| "user_denied"
|
|
140
140
|
| "auto_approved"
|
|
141
141
|
| "confirmation_unavailable"
|
|
142
|
+
/**
|
|
143
|
+
* A human was shown the prompt and nobody answered within the configured
|
|
144
|
+
* `promptTimeoutMs`, so the request was denied.
|
|
145
|
+
*/
|
|
146
|
+
| "prompt_unanswered"
|
|
142
147
|
/** A registered `authorizerChain` link granted the ask; no human was asked. */
|
|
143
148
|
| "authorizer_allowed"
|
|
144
149
|
/** A registered `authorizerChain` link refused the ask; no human was asked. */
|