@gotgenes/pi-permission-system 32.0.6 → 33.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -0
- package/config/config.example.json +7 -0
- package/docs/configuration.md +81 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +37 -0
- package/src/access-intent/input-normalizer.ts +6 -4
- package/src/access-intent/mcp-targets.ts +62 -8
- package/src/authority/permission-prompt-component.ts +41 -21
- package/src/authority/permission-prompt-decision.ts +76 -59
- package/src/config/config-loader.ts +39 -0
- package/src/config/config-schema.ts +47 -0
- package/src/config/dialog-keys.ts +172 -0
- package/src/config/extension-config.ts +6 -0
- package/src/index.ts +2 -0
- package/src/policy/permission-manager.ts +6 -11
- package/src/policy/rule.ts +9 -39
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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
|
+
## [33.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.1.0...pi-permission-system-v33.0.0) (2026-09-18)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **breaking:** apply MCP server rules to prefix-named tools such as github_search_code ([1994b32](https://github.com/gotgenes/pi-packages/commit/1994b3227e16898c8ca4ff0d7a17b258dc306bcb)), closes [#929](https://github.com/gotgenes/pi-packages/issues/929)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **breaking:** honor last-match-wins for MCP rules instead of stopping at the first candidate ([c573696](https://github.com/gotgenes/pi-packages/commit/c57369648a4ffd64de9b07999e2687ceaa96e867))
|
|
18
|
+
* stop deriving unmatchable re-prefixed MCP candidates for already-qualified tool names ([72b3b0b](https://github.com/gotgenes/pi-packages/commit/72b3b0b6859511f9ce41d55aa803e57e5886077c)), closes [#929](https://github.com/gotgenes/pi-packages/issues/929)
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
|
|
22
|
+
* describe how MCP tool names derive server and tool permission targets ([49484a6](https://github.com/gotgenes/pi-packages/commit/49484a635d69292e8e0ad17dbe792019acd97269))
|
|
23
|
+
|
|
24
|
+
## [32.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.6...pi-permission-system-v32.1.0) (2026-09-16)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** let the permission dialog's hotkeys be remapped ([e8fa79b](https://github.com/gotgenes/pi-packages/commit/e8fa79b6a30b925441aa2afb21c13fb10ff0b133)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
|
|
30
|
+
* **pi-permission-system:** report a refused permission-dialog key binding ([6798c63](https://github.com/gotgenes/pi-packages/commit/6798c6359cee2abed5f3e1cf28f10002d22d765d)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** document permissionDialogKeys ([63d3221](https://github.com/gotgenes/pi-packages/commit/63d3221c5b3eac76843c63b4d1e81e8afde60c8f)), closes [#927](https://github.com/gotgenes/pi-packages/issues/927)
|
|
35
|
+
|
|
8
36
|
## [32.0.6](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.5...pi-permission-system-v32.0.6) (2026-09-16)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ All permissions use one of three states:
|
|
|
66
66
|
|
|
67
67
|
When the dialog prompts, you can approve once or approve a pattern for the rest of the session.
|
|
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
|
+
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.
|
|
69
70
|
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.
|
|
70
71
|
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.
|
|
71
72
|
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.
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
"permissionReviewLog": true,
|
|
6
6
|
"yoloMode": false,
|
|
7
7
|
"doublePressToConfirm": true,
|
|
8
|
+
"permissionDialogKeys": {
|
|
9
|
+
"approve": "y",
|
|
10
|
+
"approveSession": "s",
|
|
11
|
+
"approveSessionBoth": "b",
|
|
12
|
+
"deny": "n",
|
|
13
|
+
"denyWithReason": "r"
|
|
14
|
+
},
|
|
8
15
|
|
|
9
16
|
"forwardingTimeoutMs": 600000,
|
|
10
17
|
|
package/docs/configuration.md
CHANGED
|
@@ -37,6 +37,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
|
|
|
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
39
|
Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
|
|
40
|
+
`permissionDialogKeys` replaces the whole map rather than merging entry by entry, so the map that was validated is the map that applies.
|
|
40
41
|
|
|
41
42
|
**Invalid higher-precedence scope fails closed.**
|
|
42
43
|
If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
|
|
@@ -103,6 +104,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
103
104
|
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings, masked only where a name binds the secret — see [Log file sensitivity](#log-file-sensitivity) |
|
|
104
105
|
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
105
106
|
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
107
|
+
| `permissionDialogKeys` | — | Remaps the inline TUI dialog's decision hotkeys (see below). One printable character per decision; omitted decisions keep `y` / `s` / `b` / `n` / `r`. |
|
|
106
108
|
| `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. |
|
|
107
109
|
| `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. |
|
|
108
110
|
| `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. |
|
|
@@ -132,12 +134,50 @@ Every other ask shows the four options above without it.
|
|
|
132
134
|
See [session-approvals.md](session-approvals.md#grant-direction) for what the two widths grant.
|
|
133
135
|
|
|
134
136
|
Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option, and `esc` denies.
|
|
135
|
-
With `doublePressToConfirm` enabled (the default), a
|
|
137
|
+
With `doublePressToConfirm` enabled (the default), a hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
|
|
136
138
|
Set `doublePressToConfirm` to `false` to commit on the first press.
|
|
137
139
|
|
|
140
|
+
#### Remapping the hotkeys
|
|
141
|
+
|
|
142
|
+
Set `permissionDialogKeys` to bind any decision to a different key:
|
|
143
|
+
|
|
144
|
+
```jsonc
|
|
145
|
+
{
|
|
146
|
+
"permissionDialogKeys": {
|
|
147
|
+
"approve": "1",
|
|
148
|
+
"approveSession": "2",
|
|
149
|
+
"approveSessionBoth": "3",
|
|
150
|
+
"deny": "4",
|
|
151
|
+
"denyWithReason": "5"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The five decision names above are the only keys the map accepts, and each is optional — a decision you do not name keeps its default letter.
|
|
157
|
+
|
|
158
|
+
This exists for input method editors.
|
|
159
|
+
While an IME is composing — Chinese Pinyin or Wubi, Japanese, Korean — a letter keypress is consumed by the candidate buffer and never reaches the terminal, so `y` and `n` do nothing and the dialog looks frozen.
|
|
160
|
+
The usual way out of a candidate popup is `esc`, which *does* reach the terminal and which this dialog reads as a denial, so a call you meant to approve gets refused.
|
|
161
|
+
Digits are unaffected on essentially every layout, which is why `1`–`5` is the mapping to reach for.
|
|
162
|
+
|
|
163
|
+
Each value is a **single printable character**: a lowercase letter, a digit, or a symbol.
|
|
164
|
+
Three things are refused:
|
|
165
|
+
|
|
166
|
+
- `j` and `k`, which move the dialog's highlight — a decision bound to one would never fire.
|
|
167
|
+
- An uppercase letter.
|
|
168
|
+
Pi lowercases a key identifier, so `"Y"` would answer to a lowercase `y` rather than to the keystroke you asked for.
|
|
169
|
+
- A character two decisions would share, including one a decision you did **not** remap already holds.
|
|
170
|
+
Trading two decisions' keys is fine (`{"approve": "n", "deny": "y"}`), because neither keeps the other's.
|
|
171
|
+
|
|
172
|
+
A refused entry is a warning, never a policy event: that decision keeps its default letter, the rest of the map still applies, and your permission rules are untouched.
|
|
173
|
+
Named keys (`f1`, `pageUp`) and modifier combinations (`ctrl+g`) are not accepted.
|
|
174
|
+
|
|
175
|
+
One collision no check can see: if you rebind Pi's own `app.tools.expand` to a printable character that is also a dialog binding, expansion wins — the dialog offers that action first, before it maps a decision key.
|
|
176
|
+
|
|
138
177
|
Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) stays live while the dialog is open.
|
|
139
178
|
It expands both the prompt itself — to the complete request, unbounded by `promptMaxRows` and `promptFieldMaxWidth` — and the host's pending tool call, so one keystroke shows you everything before you decide.
|
|
140
179
|
It only toggles the display — it never resolves, commits, or arms the pending decision.
|
|
180
|
+
Because it is offered ahead of the decision keys, a printable rebinding of it shadows a `permissionDialogKeys` entry that names the same character.
|
|
141
181
|
While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
|
|
142
182
|
|
|
143
183
|
The reason editor is Pi's own line editor, so it behaves like the chat input: pasting works, as do cursor movement, word and line deletion, the kill ring, and undo.
|
|
@@ -483,6 +523,46 @@ MCP permissions match against derived targets from tool input:
|
|
|
483
523
|
|
|
484
524
|
> **Note:** Baseline discovery targets auto-allow when any explicit `mcp: allow` rule exists.
|
|
485
525
|
|
|
526
|
+
In that example `mcp_status` and `mcp_list` allow discovery, `myServer:*` prompts for each of that server's tools, and `dangerousServer` denies every call to it — including `dangerousServer_wipe` and a `{"tool": "wipe", "server": "dangerousServer"}` call — because each rule is written **after** the `"*"` catch-all.
|
|
527
|
+
|
|
528
|
+
#### How a call becomes targets
|
|
529
|
+
|
|
530
|
+
One MCP call is looked up under several names, and a rule may name any of them.
|
|
531
|
+
When the call carries no explicit `server`, the server is derived from the tool name against the servers in your MCP config, in this order — the first convention that matches settles the name:
|
|
532
|
+
|
|
533
|
+
1. **Qualified** — `server:tool` splits directly.
|
|
534
|
+
2. **Prefix** — the **longest** configured server that is the leading segment of `<server>_<tool>`.
|
|
535
|
+
This is the common case: the `mcp()` proxy carries names like `chrome_devtools_take_screenshot`, and aggregators expose `<server>_<tool>`.
|
|
536
|
+
`foo_bar_baz` belongs to `foo_bar`, never also to `foo`, and a prefix match settles the name — so `foo_bar_baz_github` derives `foo_bar` and not `github`.
|
|
537
|
+
3. **Suffix** — a legacy name like `search_code_github`, which also derives the qualified forms.
|
|
538
|
+
|
|
539
|
+
An explicit `server` argument skips derivation entirely.
|
|
540
|
+
|
|
541
|
+
| Call | Targets |
|
|
542
|
+
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
543
|
+
| `{"tool": "github_search_code"}`, `github` configured | `github_search_code`, `github`, `mcp_call` |
|
|
544
|
+
| `{"tool": "search_code_github"}`, `github` configured | `github_search_code_github`, `github:search_code_github`, `github`, `search_code_github`, `mcp_call` |
|
|
545
|
+
| `{"tool": "github:search_code"}` | `github_search_code`, `github:search_code`, `github`, `search_code`, `mcp_call` |
|
|
546
|
+
| `{"tool": "search_code", "server": "github"}` | `github_search_code`, `github:search_code`, `github`, `search_code`, `mcp_call` |
|
|
547
|
+
|
|
548
|
+
Deriving a server from a name is a heuristic, and it can attach the wrong rule: with `git` configured, a `git_lab_issues` tool from a different server derives `git`.
|
|
549
|
+
Longest-match only helps when both servers are configured.
|
|
550
|
+
Where the distinction matters, pass an explicit `server` argument or use a qualified `server:tool` name.
|
|
551
|
+
|
|
552
|
+
#### Which rule shape to write
|
|
553
|
+
|
|
554
|
+
| Rule shape | Matches | Use it for |
|
|
555
|
+
| -------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
556
|
+
| `"myServer"` | the bare-server target | **Server-level policy — the recommended form.** Fires for any call belonging to that server, whichever naming convention produced it. |
|
|
557
|
+
| `"myServer_*"` | the tool-name target | Tool-level policy for prefix-named tools. |
|
|
558
|
+
| `"myServer:*"` | the qualified target | Tool-level policy for qualified names and explicit-`server` calls. |
|
|
559
|
+
| `"*_myServer"` | the suffix targets | Only when you have suffix-named tools. |
|
|
560
|
+
|
|
561
|
+
**Rule position decides, not target order.**
|
|
562
|
+
The last rule matching *any* of a call's targets wins, exactly as on every other surface — so put broad catch-alls first and specific overrides after.
|
|
563
|
+
A `{"*": "allow", "github": "deny"}` config denies github calls; reversing the two lines makes the catch-all win instead.
|
|
564
|
+
The target order above decides only which name the decision is reported under in the prompt and the review log, where the most specific matching name is shown.
|
|
565
|
+
|
|
486
566
|
String shorthand grants broad MCP access — useful for per-agent overrides:
|
|
487
567
|
|
|
488
568
|
```yaml
|
package/package.json
CHANGED
|
@@ -31,6 +31,43 @@
|
|
|
31
31
|
"default": true,
|
|
32
32
|
"type": "boolean"
|
|
33
33
|
},
|
|
34
|
+
"permissionDialogKeys": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"properties": {
|
|
37
|
+
"approve": {
|
|
38
|
+
"description": "Key that approves the pending call once. Default: y.",
|
|
39
|
+
"type": "string"
|
|
40
|
+
},
|
|
41
|
+
"approveSession": {
|
|
42
|
+
"description": "Key that approves for the rest of the session. Default: s.",
|
|
43
|
+
"type": "string"
|
|
44
|
+
},
|
|
45
|
+
"approveSessionBoth": {
|
|
46
|
+
"description": "Key that approves for the session in both directions. Default: b.",
|
|
47
|
+
"type": "string"
|
|
48
|
+
},
|
|
49
|
+
"deny": {
|
|
50
|
+
"description": "Key that denies the pending call. Default: n.",
|
|
51
|
+
"type": "string"
|
|
52
|
+
},
|
|
53
|
+
"denyWithReason": {
|
|
54
|
+
"description": "Key that denies and opens the reason editor. Default: r.",
|
|
55
|
+
"type": "string"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"additionalProperties": false,
|
|
59
|
+
"description": "Remaps the inline TUI permission dialog's decision hotkeys. Each value is one printable character.",
|
|
60
|
+
"markdownDescription": "Remaps the inline **TUI** permission dialog's decision hotkeys, which default to `y` / `s` / `b` / `n` / `r`.\n\nEach value is a single printable character — a lowercase letter, a digit, or a symbol. Digits are the usual choice for input-method-editor (IME) users, whose composition mode swallows letter keys before they reach the terminal.\n\n`j` and `k` are reserved for moving the dialog's highlight, uppercase is rejected (pi lowercases a key identifier, so `\"Y\"` would answer to `y`), and two decisions may not share a character. An entry that breaks one of those rules is ignored with a warning and its decision keeps its default letter.\n\nA project config replaces a global one's map entirely rather than merging entry by entry.",
|
|
61
|
+
"examples": [
|
|
62
|
+
{
|
|
63
|
+
"approve": "1",
|
|
64
|
+
"approveSession": "2",
|
|
65
|
+
"approveSessionBoth": "3",
|
|
66
|
+
"deny": "4",
|
|
67
|
+
"denyWithReason": "5"
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
34
71
|
"forwardingTimeoutMs": {
|
|
35
72
|
"description": "How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
36
73
|
"markdownDescription": "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.",
|
|
@@ -102,15 +102,17 @@ function buildInputForSurface(
|
|
|
102
102
|
|
|
103
103
|
/**
|
|
104
104
|
* Surface-normalized representation of a tool invocation used by
|
|
105
|
-
* `checkPermission()` to feed a single `
|
|
105
|
+
* `checkPermission()` to feed a single `evaluateAnyValue()` call.
|
|
106
106
|
*/
|
|
107
107
|
export interface NormalizedInput {
|
|
108
108
|
/** The permission surface for `evaluate()` (e.g. "bash", "mcp", "skill"). */
|
|
109
109
|
surface: string;
|
|
110
110
|
/**
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
111
|
+
* Alternative lookup names for this one access, most-specific first.
|
|
112
|
+
* MCP is the only surface producing more than one; every other surface
|
|
113
|
+
* produces a single-element array. Order does not decide which rule wins
|
|
114
|
+
* (rule position does) — it decides which name the decision is reported
|
|
115
|
+
* under when the winning rule matches several.
|
|
114
116
|
*/
|
|
115
117
|
values: string[];
|
|
116
118
|
/**
|
|
@@ -51,6 +51,35 @@ export function parseQualifiedMcpToolName(
|
|
|
51
51
|
return { server, tool };
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Find the configured server that owns `toolName` by the prefix convention.
|
|
56
|
+
*
|
|
57
|
+
* Returns the **longest** configured name that is the tool's leading
|
|
58
|
+
* `<server>_` segment, so `foo_bar_baz` belongs to `foo_bar` and never also to
|
|
59
|
+
* `foo`. Selecting here rather than trusting the caller's ordering keeps the
|
|
60
|
+
* rule true for any caller: the production loader sorts longest-first, but the
|
|
61
|
+
* `mcpServerNames` option does not.
|
|
62
|
+
*/
|
|
63
|
+
function findLongestPrefixServer(
|
|
64
|
+
toolName: string,
|
|
65
|
+
configuredServerNames: readonly string[],
|
|
66
|
+
): string | null {
|
|
67
|
+
let longest: string | null = null;
|
|
68
|
+
|
|
69
|
+
for (const serverName of configuredServerNames) {
|
|
70
|
+
const trimmedServerName = serverName.trim();
|
|
71
|
+
if (!trimmedServerName || !toolName.startsWith(`${trimmedServerName}_`)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (longest === null || trimmedServerName.length > longest.length) {
|
|
76
|
+
longest = trimmedServerName;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return longest;
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
function addDerivedMcpServerTargets(
|
|
55
84
|
toolName: string,
|
|
56
85
|
configuredServerNames: readonly string[],
|
|
@@ -61,6 +90,23 @@ function addDerivedMcpServerTargets(
|
|
|
61
90
|
return;
|
|
62
91
|
}
|
|
63
92
|
|
|
93
|
+
// Prefix convention (`github_search_code`): the name already carries its
|
|
94
|
+
// server, so the bare server is the only candidate worth deriving. A prefix
|
|
95
|
+
// hit also settles the name's convention, which is why the suffix pass below
|
|
96
|
+
// does not run — a tool ending in another configured server's name is a
|
|
97
|
+
// coincidence, not a second owner.
|
|
98
|
+
const prefixServer = findLongestPrefixServer(
|
|
99
|
+
trimmedToolName,
|
|
100
|
+
configuredServerNames,
|
|
101
|
+
);
|
|
102
|
+
if (prefixServer) {
|
|
103
|
+
targets.add(trimmedToolName);
|
|
104
|
+
targets.add(prefixServer);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Suffix convention (`search_code_github`): the server is not part of any
|
|
109
|
+
// candidate the caller will add, so the qualified forms are derived too.
|
|
64
110
|
for (const serverName of configuredServerNames) {
|
|
65
111
|
const trimmedServerName = serverName.trim();
|
|
66
112
|
if (!trimmedServerName) {
|
|
@@ -71,10 +117,6 @@ function addDerivedMcpServerTargets(
|
|
|
71
117
|
continue;
|
|
72
118
|
}
|
|
73
119
|
|
|
74
|
-
if (trimmedToolName.startsWith(`${trimmedServerName}_`)) {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
120
|
targets.add(`${trimmedServerName}_${trimmedToolName}`);
|
|
79
121
|
targets.add(`${trimmedServerName}:${trimmedToolName}`);
|
|
80
122
|
targets.add(trimmedServerName);
|
|
@@ -92,8 +134,17 @@ function pushMcpToolPermissionTargets(
|
|
|
92
134
|
const resolvedTool = qualified?.tool ?? rawReference;
|
|
93
135
|
|
|
94
136
|
if (resolvedServer) {
|
|
95
|
-
|
|
96
|
-
|
|
137
|
+
// A name already carrying its server needs no re-prefixing: the qualified
|
|
138
|
+
// forms would be `github_github_search_code`, which no rule can usefully
|
|
139
|
+
// name, and which led the list as the reported target. The tool name is
|
|
140
|
+
// itself the qualified form, so it leads instead — matching what prefix
|
|
141
|
+
// derivation produces when no explicit server accompanies the call.
|
|
142
|
+
if (resolvedTool.startsWith(`${resolvedServer}_`)) {
|
|
143
|
+
targets.add(resolvedTool);
|
|
144
|
+
} else {
|
|
145
|
+
targets.add(`${resolvedServer}_${resolvedTool}`);
|
|
146
|
+
targets.add(`${resolvedServer}:${resolvedTool}`);
|
|
147
|
+
}
|
|
97
148
|
targets.add(resolvedServer);
|
|
98
149
|
} else {
|
|
99
150
|
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
|
|
@@ -107,8 +158,11 @@ function pushMcpToolPermissionTargets(
|
|
|
107
158
|
* Derive the ordered list of MCP permission-lookup candidates from a raw MCP
|
|
108
159
|
* tool invocation input.
|
|
109
160
|
*
|
|
110
|
-
* Candidates are ordered from most-specific to least-specific
|
|
111
|
-
* `
|
|
161
|
+
* Candidates are ordered from most-specific to least-specific. The order does
|
|
162
|
+
* not decide which rule wins — `evaluateAnyValue()` gives that to the last
|
|
163
|
+
* matching rule — but it decides which candidate a winning rule is reported
|
|
164
|
+
* against, so the most specific name the rule matches is the one the prompt
|
|
165
|
+
* and the review log show.
|
|
112
166
|
*/
|
|
113
167
|
export function createMcpPermissionTargets(
|
|
114
168
|
input: unknown,
|
|
@@ -3,7 +3,13 @@ import type {
|
|
|
3
3
|
ExtensionUIContext,
|
|
4
4
|
KeybindingsManager,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type Component,
|
|
8
|
+
Input,
|
|
9
|
+
type KeyId,
|
|
10
|
+
matchesKey,
|
|
11
|
+
} from "@earendil-works/pi-tui";
|
|
12
|
+
import type { DialogKeyBindings, PromptAction } from "#src/config/dialog-keys";
|
|
7
13
|
import {
|
|
8
14
|
completeViewBudget,
|
|
9
15
|
type DialogView,
|
|
@@ -23,11 +29,10 @@ import {
|
|
|
23
29
|
import {
|
|
24
30
|
initialPromptState,
|
|
25
31
|
type PromptEvent,
|
|
26
|
-
type PromptKey,
|
|
27
32
|
type PromptModelConfig,
|
|
28
33
|
type PromptViewState,
|
|
29
34
|
reducePrompt,
|
|
30
|
-
|
|
35
|
+
visibleActions,
|
|
31
36
|
} from "./permission-prompt-decision";
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -60,6 +65,8 @@ export interface PromptPreferences {
|
|
|
60
65
|
doublePressToConfirm: boolean;
|
|
61
66
|
/** How much room a render has; the terminal width is added per frame. */
|
|
62
67
|
budget: RenderBudget;
|
|
68
|
+
/** The character bound to each decision. */
|
|
69
|
+
dialogKeys: DialogKeyBindings;
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
/**
|
|
@@ -122,12 +129,12 @@ interface PromptTheme {
|
|
|
122
129
|
|
|
123
130
|
const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
|
124
131
|
|
|
125
|
-
const OPTION_LABELS: Record<
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
132
|
+
const OPTION_LABELS: Record<PromptAction, string> = {
|
|
133
|
+
approve: "Yes",
|
|
134
|
+
approveSession: DEFAULT_SESSION_LABEL,
|
|
135
|
+
approveSessionBoth: "Yes, for this session in both directions",
|
|
136
|
+
deny: "No",
|
|
137
|
+
denyWithReason: "No, provide reason",
|
|
131
138
|
};
|
|
132
139
|
|
|
133
140
|
export function presentInlinePermissionPrompt(
|
|
@@ -141,6 +148,7 @@ export function presentInlinePermissionPrompt(
|
|
|
141
148
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
142
149
|
widthLabel: options?.sessionWidth?.label,
|
|
143
150
|
sessionScope: options?.sessionScope,
|
|
151
|
+
keys: view.dialogKeys,
|
|
144
152
|
};
|
|
145
153
|
return view.ui.custom<UnattributedDecision>(
|
|
146
154
|
(tui, theme, keybindings, done) =>
|
|
@@ -328,11 +336,11 @@ class PermissionPromptComponent implements Component {
|
|
|
328
336
|
return { type: "cancel" };
|
|
329
337
|
}
|
|
330
338
|
if (this.state.step === "decision") {
|
|
331
|
-
const
|
|
332
|
-
matchesKey(data, option),
|
|
339
|
+
const action = visibleActions(this.config).find((option) =>
|
|
340
|
+
matchesKey(data, this.boundKey(option)),
|
|
333
341
|
);
|
|
334
|
-
if (
|
|
335
|
-
return { type: "hotkey",
|
|
342
|
+
if (action) {
|
|
343
|
+
return { type: "hotkey", action };
|
|
336
344
|
}
|
|
337
345
|
}
|
|
338
346
|
return undefined;
|
|
@@ -354,11 +362,11 @@ class PermissionPromptComponent implements Component {
|
|
|
354
362
|
private renderDecision(width: number): string[] {
|
|
355
363
|
const ask = this.renderAsk(width);
|
|
356
364
|
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
|
357
|
-
for (const
|
|
358
|
-
const label = this.labelFor(
|
|
359
|
-
const selected = this.state.
|
|
365
|
+
for (const action of visibleActions(this.config)) {
|
|
366
|
+
const label = this.labelFor(action);
|
|
367
|
+
const selected = this.state.highlightedAction === action;
|
|
360
368
|
const marker = selected ? "▶" : " ";
|
|
361
|
-
const row = `${marker} (${
|
|
369
|
+
const row = `${marker} (${this.boundKey(action)}) ${label}`;
|
|
362
370
|
lines.push(selected ? this.theme.fg("accent", row) : row);
|
|
363
371
|
}
|
|
364
372
|
lines.push("");
|
|
@@ -366,14 +374,26 @@ class PermissionPromptComponent implements Component {
|
|
|
366
374
|
return lines;
|
|
367
375
|
}
|
|
368
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The character that selects an option, as a key identifier.
|
|
379
|
+
*
|
|
380
|
+
* The cast is total by construction: a binding is one printable character,
|
|
381
|
+
* which is exactly what pi-tui's matcher accepts as a `KeyId`.
|
|
382
|
+
*/
|
|
383
|
+
private boundKey(action: PromptAction): KeyId {
|
|
384
|
+
return this.config.keys[action] as KeyId;
|
|
385
|
+
}
|
|
386
|
+
|
|
369
387
|
/**
|
|
370
388
|
* The row label for a key: the two session options carry ask-supplied text
|
|
371
389
|
* naming what they grant, and the rest are fixed.
|
|
372
390
|
*/
|
|
373
|
-
private labelFor(
|
|
374
|
-
if (
|
|
375
|
-
if (
|
|
376
|
-
|
|
391
|
+
private labelFor(action: PromptAction): string {
|
|
392
|
+
if (action === "approveSession") return this.config.sessionLabel;
|
|
393
|
+
if (action === "approveSessionBoth") {
|
|
394
|
+
return this.config.widthLabel ?? OPTION_LABELS.approveSessionBoth;
|
|
395
|
+
}
|
|
396
|
+
return OPTION_LABELS[action];
|
|
377
397
|
}
|
|
378
398
|
|
|
379
399
|
private renderReason(width: number): string[] {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DialogKeyBindings, PromptAction } from "#src/config/dialog-keys";
|
|
1
2
|
import type { SessionGrantWidth } from "#src/session/approval-grant";
|
|
2
3
|
import {
|
|
3
4
|
createDeniedPermissionDecision,
|
|
@@ -16,26 +17,30 @@ import {
|
|
|
16
17
|
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* The decision hotkeys, in display order.
|
|
21
|
-
*
|
|
22
|
-
* `b` is conditional: it appears only for an ask whose session grant can be
|
|
23
|
-
* widened to both directions (#813), so the roster an ask actually offers
|
|
24
|
-
* comes from {@link visibleOptionKeys} rather than from this type.
|
|
25
|
-
*/
|
|
26
|
-
export type PromptKey = "y" | "s" | "b" | "n" | "r";
|
|
27
|
-
|
|
28
20
|
/** Which sub-view the dialog is showing. */
|
|
29
21
|
export type PromptStep = "decision" | "reason" | "scope";
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
/**
|
|
24
|
+
* The decisions in display order.
|
|
25
|
+
*
|
|
26
|
+
* `approveSessionBoth` is conditional: it appears only for an ask whose session
|
|
27
|
+
* grant can be widened to both directions (#813), so the roster an ask actually
|
|
28
|
+
* offers comes from {@link visibleActions} rather than from this list.
|
|
29
|
+
*/
|
|
30
|
+
const OPTION_ORDER: readonly PromptAction[] = [
|
|
31
|
+
"approve",
|
|
32
|
+
"approveSession",
|
|
33
|
+
"approveSessionBoth",
|
|
34
|
+
"deny",
|
|
35
|
+
"denyWithReason",
|
|
36
|
+
];
|
|
32
37
|
|
|
33
|
-
const NARROW_OPTION_ORDER: readonly
|
|
34
|
-
(
|
|
38
|
+
const NARROW_OPTION_ORDER: readonly PromptAction[] = OPTION_ORDER.filter(
|
|
39
|
+
(action) => action !== "approveSessionBoth",
|
|
35
40
|
);
|
|
36
41
|
|
|
37
42
|
/**
|
|
38
|
-
* The decision step's
|
|
43
|
+
* The decision step's options, in display order.
|
|
39
44
|
*
|
|
40
45
|
* A function of the config rather than an exported constant, so which options
|
|
41
46
|
* an ask offers is decided in the model and the component renders whatever it
|
|
@@ -45,24 +50,31 @@ const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
|
|
|
45
50
|
* The width option is offered iff the ask supplied a label for it, so an ask
|
|
46
51
|
* that proves no single direction is rendered and navigated exactly as before.
|
|
47
52
|
*/
|
|
48
|
-
export function
|
|
53
|
+
export function visibleActions(
|
|
49
54
|
config: PromptModelConfig,
|
|
50
|
-
): readonly
|
|
55
|
+
): readonly PromptAction[] {
|
|
51
56
|
return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
|
-
const OPTION_VERBS: Record<
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
const OPTION_VERBS: Record<PromptAction, string> = {
|
|
60
|
+
approve: "approve",
|
|
61
|
+
approveSession: "approve for this session",
|
|
62
|
+
approveSessionBoth: "approve both directions for this session",
|
|
63
|
+
deny: "deny",
|
|
64
|
+
denyWithReason: "deny with a reason",
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
/** Static configuration for a single prompt presentation. */
|
|
63
68
|
export interface PromptModelConfig {
|
|
64
|
-
/** When true, a
|
|
69
|
+
/** When true, a hotkey arms first and commits only on a second press. */
|
|
65
70
|
doublePressToConfirm: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The character bound to each decision.
|
|
73
|
+
*
|
|
74
|
+
* What the dialog matches keystrokes against and what it renders, so an
|
|
75
|
+
* option's identity and the key that selects it are separate values.
|
|
76
|
+
*/
|
|
77
|
+
keys: DialogKeyBindings;
|
|
66
78
|
/** Label shown beside the approve-for-session option. */
|
|
67
79
|
sessionLabel: string;
|
|
68
80
|
/**
|
|
@@ -83,9 +95,9 @@ export interface PromptModelConfig {
|
|
|
83
95
|
/** The re-render view state the component draws from. */
|
|
84
96
|
export interface PromptViewState {
|
|
85
97
|
step: PromptStep;
|
|
86
|
-
|
|
98
|
+
highlightedAction: PromptAction;
|
|
87
99
|
/** Set only while awaiting the confirming second press of a hotkey. */
|
|
88
|
-
|
|
100
|
+
armedAction?: PromptAction;
|
|
89
101
|
/** "Press y again to approve." while armed; empty otherwise. */
|
|
90
102
|
hint: string;
|
|
91
103
|
/** Set when an empty reason submit is rejected. */
|
|
@@ -106,7 +118,7 @@ export interface PromptViewState {
|
|
|
106
118
|
/** An input event the reducer understands. */
|
|
107
119
|
export type PromptEvent =
|
|
108
120
|
| { type: "nav"; direction: "up" | "down" }
|
|
109
|
-
| { type: "hotkey";
|
|
121
|
+
| { type: "hotkey"; action: PromptAction }
|
|
110
122
|
| { type: "confirm" }
|
|
111
123
|
| { type: "cancel" }
|
|
112
124
|
| { type: "submitReason"; draft: string };
|
|
@@ -121,8 +133,8 @@ export function initialPromptState(
|
|
|
121
133
|
): PromptViewState {
|
|
122
134
|
return {
|
|
123
135
|
step: "decision",
|
|
124
|
-
|
|
125
|
-
|
|
136
|
+
highlightedAction: "approve",
|
|
137
|
+
armedAction: undefined,
|
|
126
138
|
hint: "",
|
|
127
139
|
reasonError: undefined,
|
|
128
140
|
scopeServing: false,
|
|
@@ -162,16 +174,20 @@ function reduceDecisionStep(
|
|
|
162
174
|
case "nav":
|
|
163
175
|
return render({
|
|
164
176
|
...state,
|
|
165
|
-
|
|
166
|
-
|
|
177
|
+
highlightedAction: shiftAction(
|
|
178
|
+
config,
|
|
179
|
+
state.highlightedAction,
|
|
180
|
+
event.direction,
|
|
181
|
+
),
|
|
182
|
+
armedAction: undefined,
|
|
167
183
|
hint: "",
|
|
168
184
|
});
|
|
169
185
|
case "hotkey":
|
|
170
|
-
return
|
|
171
|
-
? pressHotkey(config, state, event.
|
|
186
|
+
return visibleActions(config).includes(event.action)
|
|
187
|
+
? pressHotkey(config, state, event.action)
|
|
172
188
|
: render(state);
|
|
173
189
|
case "confirm":
|
|
174
|
-
return commit(config, state, state.
|
|
190
|
+
return commit(config, state, state.highlightedAction);
|
|
175
191
|
case "cancel":
|
|
176
192
|
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
|
177
193
|
case "submitReason":
|
|
@@ -182,52 +198,53 @@ function reduceDecisionStep(
|
|
|
182
198
|
function pressHotkey(
|
|
183
199
|
config: PromptModelConfig,
|
|
184
200
|
state: PromptViewState,
|
|
185
|
-
|
|
201
|
+
action: PromptAction,
|
|
186
202
|
): PromptOutcome {
|
|
187
|
-
if (!config.doublePressToConfirm || state.
|
|
188
|
-
return commit(config, state,
|
|
203
|
+
if (!config.doublePressToConfirm || state.armedAction === action) {
|
|
204
|
+
return commit(config, state, action);
|
|
189
205
|
}
|
|
190
206
|
return render({
|
|
191
207
|
...state,
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
hint: `Press ${
|
|
208
|
+
highlightedAction: action,
|
|
209
|
+
armedAction: action,
|
|
210
|
+
hint: `Press ${config.keys[action]} again to ${OPTION_VERBS[action]}.`,
|
|
195
211
|
});
|
|
196
212
|
}
|
|
197
213
|
|
|
198
214
|
function commit(
|
|
199
215
|
config: PromptModelConfig,
|
|
200
216
|
state: PromptViewState,
|
|
201
|
-
|
|
217
|
+
action: PromptAction,
|
|
202
218
|
): PromptOutcome {
|
|
203
|
-
switch (
|
|
204
|
-
case "
|
|
219
|
+
switch (action) {
|
|
220
|
+
case "approve":
|
|
205
221
|
return {
|
|
206
222
|
kind: "decision",
|
|
207
223
|
decision: { approved: true, state: "approved" },
|
|
208
224
|
};
|
|
209
|
-
case "
|
|
225
|
+
case "deny":
|
|
210
226
|
return { kind: "decision", decision: createDeniedPermissionDecision() };
|
|
211
|
-
case "
|
|
227
|
+
case "denyWithReason":
|
|
212
228
|
return render({
|
|
213
229
|
...state,
|
|
214
230
|
step: "reason",
|
|
215
|
-
|
|
216
|
-
|
|
231
|
+
highlightedAction: "denyWithReason",
|
|
232
|
+
armedAction: undefined,
|
|
217
233
|
hint: "",
|
|
218
234
|
reasonError: undefined,
|
|
219
235
|
});
|
|
220
|
-
case "
|
|
221
|
-
case "
|
|
236
|
+
case "approveSession":
|
|
237
|
+
case "approveSessionBoth": {
|
|
222
238
|
// The two session options differ only in the width they grant; which
|
|
223
239
|
// scope they land on is the forwarded scope step's separate question.
|
|
224
|
-
const grantWidth: SessionGrantWidth =
|
|
240
|
+
const grantWidth: SessionGrantWidth =
|
|
241
|
+
action === "approveSessionBoth" ? "family" : "proven";
|
|
225
242
|
if (config.sessionScope) {
|
|
226
243
|
return render({
|
|
227
244
|
...state,
|
|
228
245
|
step: "scope",
|
|
229
|
-
|
|
230
|
-
|
|
246
|
+
highlightedAction: action,
|
|
247
|
+
armedAction: undefined,
|
|
231
248
|
hint: "",
|
|
232
249
|
scopeServing: false,
|
|
233
250
|
grantWidth,
|
|
@@ -268,7 +285,7 @@ function reduceReasonStep(
|
|
|
268
285
|
return render({
|
|
269
286
|
...state,
|
|
270
287
|
step: "decision",
|
|
271
|
-
|
|
288
|
+
armedAction: undefined,
|
|
272
289
|
hint: "",
|
|
273
290
|
reasonError: undefined,
|
|
274
291
|
grantWidth: "proven",
|
|
@@ -311,7 +328,7 @@ function reduceScopeStep(
|
|
|
311
328
|
return render({
|
|
312
329
|
...state,
|
|
313
330
|
step: "decision",
|
|
314
|
-
|
|
331
|
+
armedAction: undefined,
|
|
315
332
|
hint: "",
|
|
316
333
|
grantWidth: "proven",
|
|
317
334
|
});
|
|
@@ -320,16 +337,16 @@ function reduceScopeStep(
|
|
|
320
337
|
}
|
|
321
338
|
}
|
|
322
339
|
|
|
323
|
-
function
|
|
340
|
+
function shiftAction(
|
|
324
341
|
config: PromptModelConfig,
|
|
325
|
-
current:
|
|
342
|
+
current: PromptAction,
|
|
326
343
|
direction: "up" | "down",
|
|
327
|
-
):
|
|
328
|
-
const
|
|
329
|
-
const index =
|
|
344
|
+
): PromptAction {
|
|
345
|
+
const actions = visibleActions(config);
|
|
346
|
+
const index = actions.indexOf(current);
|
|
330
347
|
const delta = direction === "down" ? 1 : -1;
|
|
331
|
-
const next = (index + delta +
|
|
332
|
-
return
|
|
348
|
+
const next = (index + delta + actions.length) % actions.length;
|
|
349
|
+
return actions[next] ?? current;
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
function render(state: PromptViewState): PromptOutcome {
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
type UnifiedPermissionConfig,
|
|
17
17
|
unifiedConfigSchema,
|
|
18
18
|
} from "./config-schema";
|
|
19
|
+
import { type DialogKeysConfig, resolveDialogKeys } from "./dialog-keys";
|
|
19
20
|
|
|
20
21
|
// The unified config shape is derived from the zod schema (config-schema.ts,
|
|
21
22
|
// the single source of truth) and re-exported so existing importers keep their
|
|
@@ -196,6 +197,7 @@ function formatConfigIssues(error: ZodError): string[] {
|
|
|
196
197
|
* present in the override.
|
|
197
198
|
* - Array fields (piInfrastructureReadPaths) replace the base when present in
|
|
198
199
|
* the override (override-wins, same as scalars).
|
|
200
|
+
* - `permissionDialogKeys` replaces the base map whole, unlike `shellTools`.
|
|
199
201
|
*/
|
|
200
202
|
// Scalar knobs merged by override-replaces-base; keep in sync with
|
|
201
203
|
// PermissionSystemExtensionConfig booleans (debugLog, permissionReviewLog,
|
|
@@ -242,6 +244,16 @@ export function mergeUnifiedConfigs(
|
|
|
242
244
|
}
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
// permissionDialogKeys: whole-object replacement. A key map is validated as
|
|
248
|
+
// a unit, so merging two individually valid maps could bind one character to
|
|
249
|
+
// two decisions with neither file's own validation able to see it. Dropping a
|
|
250
|
+
// base entry only restores a default letter, which is why this does not need
|
|
251
|
+
// the shellTools rule below.
|
|
252
|
+
const dialogKeys = override.permissionDialogKeys ?? base.permissionDialogKeys;
|
|
253
|
+
if (dialogKeys !== undefined) {
|
|
254
|
+
merged.permissionDialogKeys = dialogKeys;
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
// shellTools: shallow-merge by tool name so a project entry overrides a
|
|
246
258
|
// colliding tool's alias but never drops a global entry (a dropped alias is
|
|
247
259
|
// a silent enforcement regression).
|
|
@@ -378,6 +390,9 @@ export function loadAndMergeConfigs(
|
|
|
378
390
|
const deprecatedCapsIssue = detectDeprecatedPreviewCaps(merged);
|
|
379
391
|
if (deprecatedCapsIssue) allIssues.push(deprecatedCapsIssue);
|
|
380
392
|
|
|
393
|
+
const dialogKeysIssue = detectUnusableDialogKeys(merged);
|
|
394
|
+
if (dialogKeysIssue) allIssues.push(dialogKeysIssue);
|
|
395
|
+
|
|
381
396
|
return {
|
|
382
397
|
global: globalConfig,
|
|
383
398
|
project: projectConfig,
|
|
@@ -445,6 +460,30 @@ export function detectDeprecatedPreviewCaps(
|
|
|
445
460
|
);
|
|
446
461
|
}
|
|
447
462
|
|
|
463
|
+
/**
|
|
464
|
+
* Detect a `permissionDialogKeys` entry the dialog cannot honor.
|
|
465
|
+
*
|
|
466
|
+
* Deliberately a warning rather than a fail-closed rejection: a mistyped hotkey
|
|
467
|
+
* is cosmetic, and clamping the session's `allow` rules to `ask` over one would
|
|
468
|
+
* make a display preference a policy event. The decision keeps its default
|
|
469
|
+
* letter, and the message names the entry, the reason, and the letter kept.
|
|
470
|
+
*
|
|
471
|
+
* Where that message surfaces is the caller's problem and is currently a narrow
|
|
472
|
+
* one: `ConfigStore` dedupes against a warning recorded by a factory-time
|
|
473
|
+
* refresh with no ctx to notify, so an issue already on disk reaches the debug
|
|
474
|
+
* log alone. That predates this detector and swallows its two siblings the same
|
|
475
|
+
* way (#933).
|
|
476
|
+
*
|
|
477
|
+
* Pure, following {@link detectPermissiveBashFallback}: it takes the merged
|
|
478
|
+
* config and returns a message; the caller owns pushing it onto the issue list.
|
|
479
|
+
*/
|
|
480
|
+
export function detectUnusableDialogKeys(
|
|
481
|
+
config: DialogKeysConfig,
|
|
482
|
+
): string | undefined {
|
|
483
|
+
const { issues } = resolveDialogKeys(config);
|
|
484
|
+
return issues.length === 0 ? undefined : issues.join(" ");
|
|
485
|
+
}
|
|
486
|
+
|
|
448
487
|
/**
|
|
449
488
|
* Load and normalize a unified config file.
|
|
450
489
|
* Returns an empty config with no issues if the file does not exist.
|
|
@@ -245,6 +245,52 @@ function rejectUnusableSurfaceKeys(
|
|
|
245
245
|
}
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The inline dialog's hotkey bindings, one printable character per decision.
|
|
250
|
+
*
|
|
251
|
+
* The schema checks *shape* only — an unknown decision name or a non-string
|
|
252
|
+
* value is a load-time error like any other malformed field, because a
|
|
253
|
+
* misspelled key would otherwise sit inert with no feedback. Whether a
|
|
254
|
+
* well-formed string is a *usable* binding is `resolveDialogKeys`' question,
|
|
255
|
+
* and it answers tolerantly: an unusable, reserved, or colliding entry keeps
|
|
256
|
+
* its default letter and is reported, so a mistyped hotkey never reaches the
|
|
257
|
+
* permission policy.
|
|
258
|
+
*/
|
|
259
|
+
const dialogKeysSchema = z
|
|
260
|
+
.strictObject({
|
|
261
|
+
approve: z.string().optional().meta({
|
|
262
|
+
description: "Key that approves the pending call once. Default: y.",
|
|
263
|
+
}),
|
|
264
|
+
approveSession: z.string().optional().meta({
|
|
265
|
+
description: "Key that approves for the rest of the session. Default: s.",
|
|
266
|
+
}),
|
|
267
|
+
approveSessionBoth: z.string().optional().meta({
|
|
268
|
+
description:
|
|
269
|
+
"Key that approves for the session in both directions. Default: b.",
|
|
270
|
+
}),
|
|
271
|
+
deny: z.string().optional().meta({
|
|
272
|
+
description: "Key that denies the pending call. Default: n.",
|
|
273
|
+
}),
|
|
274
|
+
denyWithReason: z.string().optional().meta({
|
|
275
|
+
description: "Key that denies and opens the reason editor. Default: r.",
|
|
276
|
+
}),
|
|
277
|
+
})
|
|
278
|
+
.meta({
|
|
279
|
+
description:
|
|
280
|
+
"Remaps the inline TUI permission dialog's decision hotkeys. Each value is one printable character.",
|
|
281
|
+
markdownDescription:
|
|
282
|
+
"Remaps the inline **TUI** permission dialog's decision hotkeys, which default to `y` / `s` / `b` / `n` / `r`.\n\nEach value is a single printable character — a lowercase letter, a digit, or a symbol. Digits are the usual choice for input-method-editor (IME) users, whose composition mode swallows letter keys before they reach the terminal.\n\n`j` and `k` are reserved for moving the dialog's highlight, uppercase is rejected (pi lowercases a key identifier, so `\"Y\"` would answer to `y`), and two decisions may not share a character. An entry that breaks one of those rules is ignored with a warning and its decision keeps its default letter.\n\nA project config replaces a global one's map entirely rather than merging entry by entry.",
|
|
283
|
+
examples: [
|
|
284
|
+
{
|
|
285
|
+
approve: "1",
|
|
286
|
+
approveSession: "2",
|
|
287
|
+
approveSessionBoth: "3",
|
|
288
|
+
deny: "4",
|
|
289
|
+
denyWithReason: "5",
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
});
|
|
293
|
+
|
|
248
294
|
const shellToolAliasSchema = z
|
|
249
295
|
.strictObject({
|
|
250
296
|
commandArgument: z.string().min(1).meta({
|
|
@@ -322,6 +368,7 @@ export const unifiedConfigSchema = z
|
|
|
322
368
|
"Require a confirming second press of a decision hotkey (`y`/`s`/`n`/`r`) in the inline permission dialog before it commits — the first press arms the action and shows a `Press y again to approve.` hint.\n\nApplies to interactive **TUI** sessions only; the non-TUI (RPC/frontend) prompt keeps its single-select flow. Set to `false` to commit decisions on the first hotkey press.",
|
|
323
369
|
default: true,
|
|
324
370
|
}),
|
|
371
|
+
permissionDialogKeys: dialogKeysSchema.optional(),
|
|
325
372
|
forwardingTimeoutMs: z.number().int().min(1).optional().meta({
|
|
326
373
|
description:
|
|
327
374
|
"How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. Omit to use the default (600000, ten minutes).",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline permission dialog's key bindings, and the defaults they start from.
|
|
3
|
+
*
|
|
4
|
+
* The action ids are the `permissionDialogKeys` config keys, so the config
|
|
5
|
+
* surface and the decision model speak one vocabulary rather than two that a
|
|
6
|
+
* translation layer has to keep in step.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The dialog's five decisions, named by what they do.
|
|
11
|
+
*
|
|
12
|
+
* Distinct from the character that selects one: a binding is configurable and
|
|
13
|
+
* an identity is not, so a rebound dialog still decides the same five things.
|
|
14
|
+
*/
|
|
15
|
+
export type PromptAction =
|
|
16
|
+
| "approve"
|
|
17
|
+
| "approveSession"
|
|
18
|
+
| "approveSessionBoth"
|
|
19
|
+
| "deny"
|
|
20
|
+
| "denyWithReason";
|
|
21
|
+
|
|
22
|
+
/** Every action's bound character, complete. */
|
|
23
|
+
export type DialogKeyBindings = Readonly<Record<PromptAction, string>>;
|
|
24
|
+
|
|
25
|
+
/** What a config file may say: any subset of the actions. */
|
|
26
|
+
export type DialogKeyOverrides = Partial<Record<PromptAction, string>>;
|
|
27
|
+
|
|
28
|
+
/** The config slice the resolver reads. */
|
|
29
|
+
export interface DialogKeysConfig {
|
|
30
|
+
readonly permissionDialogKeys?: DialogKeyOverrides;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bindings to use, and what had to be refused to arrive at them. */
|
|
34
|
+
export interface DialogKeyResolution {
|
|
35
|
+
readonly keys: DialogKeyBindings;
|
|
36
|
+
/** One sentence per refused override; empty when every one applied. */
|
|
37
|
+
readonly issues: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The decisions in the order a config's overrides are considered. */
|
|
41
|
+
const ACTION_ORDER: readonly PromptAction[] = [
|
|
42
|
+
"approve",
|
|
43
|
+
"approveSession",
|
|
44
|
+
"approveSessionBoth",
|
|
45
|
+
"deny",
|
|
46
|
+
"denyWithReason",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** The shipped bindings, unchanged since the dialog was introduced. */
|
|
50
|
+
export const DEFAULT_DIALOG_KEYS: DialogKeyBindings = {
|
|
51
|
+
approve: "y",
|
|
52
|
+
approveSession: "s",
|
|
53
|
+
approveSessionBoth: "b",
|
|
54
|
+
deny: "n",
|
|
55
|
+
denyWithReason: "r",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const LOWERCASE_LETTERS = "abcdefghijklmnopqrstuvwxyz";
|
|
59
|
+
const DIGITS = "0123456789";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* pi-tui's symbol keys, minus `+`.
|
|
63
|
+
*
|
|
64
|
+
* `+` separates a modifier from its key in a key identifier, so pi-tui's
|
|
65
|
+
* parser splits `"+"` into two empty halves and the binding matches nothing.
|
|
66
|
+
* A parity test in `test/config/dialog-keys.test.ts` derives this roster from
|
|
67
|
+
* pi-tui's exported `Key` constant and fails if the two drift; keeping the
|
|
68
|
+
* literal here is what lets this module stay free of SDK imports.
|
|
69
|
+
*/
|
|
70
|
+
const SYMBOLS = "`-=[]\\;',./!@#$%^&*()_|~{}:<>?";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every character a decision may be bound to.
|
|
74
|
+
*
|
|
75
|
+
* Uppercase is excluded deliberately rather than normalized: pi-tui lowercases
|
|
76
|
+
* a key identifier, so a `"Y"` binding would answer to a lowercase `y` and
|
|
77
|
+
* never to the keystroke the user asked for.
|
|
78
|
+
*/
|
|
79
|
+
export const BINDABLE_DIALOG_KEY_CHARACTERS: ReadonlySet<string> = new Set(
|
|
80
|
+
Array.from(LOWERCASE_LETTERS + DIGITS + SYMBOLS),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
/** Whether `value` is a single character the dialog can bind a decision to. */
|
|
84
|
+
export function isBindableDialogKey(value: string): boolean {
|
|
85
|
+
return BINDABLE_DIALOG_KEY_CHARACTERS.has(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The characters the dialog answers to before any decision does.
|
|
90
|
+
*
|
|
91
|
+
* `handleInput` maps these to navigation ahead of the decision table, so a
|
|
92
|
+
* decision bound to one would simply never fire.
|
|
93
|
+
*/
|
|
94
|
+
export const RESERVED_DIALOG_KEYS: readonly string[] = ["j", "k"];
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The bindings a config asks for, reconciled against the defaults.
|
|
98
|
+
*
|
|
99
|
+
* Tolerant by design: an unusable, reserved, or colliding override is refused
|
|
100
|
+
* and its decision keeps the shipped letter, so a mistyped hotkey costs the
|
|
101
|
+
* user their remap and nothing else. The refusals come back as sentences for
|
|
102
|
+
* the caller to surface; the permission policy never sees them.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveDialogKeys(
|
|
105
|
+
config: DialogKeysConfig,
|
|
106
|
+
): DialogKeyResolution {
|
|
107
|
+
const overrides = config.permissionDialogKeys;
|
|
108
|
+
const issues: string[] = [];
|
|
109
|
+
const accepted = new Map<PromptAction, string>();
|
|
110
|
+
|
|
111
|
+
for (const action of ACTION_ORDER) {
|
|
112
|
+
const value = overrides?.[action];
|
|
113
|
+
if (value === undefined) continue;
|
|
114
|
+
if (!isBindableDialogKey(value)) {
|
|
115
|
+
issues.push(
|
|
116
|
+
refusal(
|
|
117
|
+
action,
|
|
118
|
+
value,
|
|
119
|
+
"is not a bindable key. Use one lowercase letter, digit, or symbol",
|
|
120
|
+
),
|
|
121
|
+
);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (RESERVED_DIALOG_KEYS.includes(value)) {
|
|
125
|
+
issues.push(
|
|
126
|
+
refusal(action, value, "is reserved for moving the dialog's highlight"),
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
accepted.set(action, value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Dropping an override restores its default, and that default can collide
|
|
134
|
+
// with an override that survived the previous look — so this settles rather
|
|
135
|
+
// than checks once. Each round drops at least one override and there are at
|
|
136
|
+
// most five, and the all-defaults state it terminates at is collision-free.
|
|
137
|
+
for (let round = ACTION_ORDER.length; round > 0; round--) {
|
|
138
|
+
const contested = contestedCharacters(accepted);
|
|
139
|
+
if (contested.size === 0) break;
|
|
140
|
+
for (const [action, value] of accepted) {
|
|
141
|
+
if (!contested.has(value)) continue;
|
|
142
|
+
accepted.delete(action);
|
|
143
|
+
issues.push(
|
|
144
|
+
refusal(action, value, "is already bound to another decision"),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const keys: Record<PromptAction, string> = { ...DEFAULT_DIALOG_KEYS };
|
|
150
|
+
for (const [action, value] of accepted) {
|
|
151
|
+
keys[action] = value;
|
|
152
|
+
}
|
|
153
|
+
return { keys, issues };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The characters two decisions would answer to, given the accepted overrides. */
|
|
157
|
+
function contestedCharacters(
|
|
158
|
+
accepted: ReadonlyMap<PromptAction, string>,
|
|
159
|
+
): ReadonlySet<string> {
|
|
160
|
+
const seen = new Set<string>();
|
|
161
|
+
const contested = new Set<string>();
|
|
162
|
+
for (const action of ACTION_ORDER) {
|
|
163
|
+
const value = accepted.get(action) ?? DEFAULT_DIALOG_KEYS[action];
|
|
164
|
+
if (seen.has(value)) contested.add(value);
|
|
165
|
+
seen.add(value);
|
|
166
|
+
}
|
|
167
|
+
return contested;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function refusal(action: PromptAction, value: string, why: string): string {
|
|
171
|
+
return `permissionDialogKeys.${action}: "${value}" ${why}; keeping the default "${DEFAULT_DIALOG_KEYS[action]}".`;
|
|
172
|
+
}
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
ShellToolsConfig,
|
|
10
10
|
UnifiedPermissionConfig,
|
|
11
11
|
} from "./config-loader";
|
|
12
|
+
import type { DialogKeyOverrides } from "./dialog-keys";
|
|
12
13
|
|
|
13
14
|
export const EXTENSION_ID = "pi-permission-system";
|
|
14
15
|
|
|
@@ -28,6 +29,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
28
29
|
promptFieldMaxWidth?: number;
|
|
29
30
|
/** Max characters of any one value written to the permission review log. Defaults to 1000. */
|
|
30
31
|
reviewLogFieldMaxWidth?: number;
|
|
32
|
+
/** The inline dialog's hotkey bindings, as configured; resolved at prompt time. */
|
|
33
|
+
permissionDialogKeys?: DialogKeyOverrides;
|
|
31
34
|
/** Non-bash tools that carry shell semantics, keyed by tool name. */
|
|
32
35
|
shellTools?: ShellToolsConfig;
|
|
33
36
|
/** Ordered names of registered live-authority chain links to consult before the terminal authorizer. */
|
|
@@ -91,6 +94,9 @@ export function normalizePermissionSystemConfig(
|
|
|
91
94
|
// absent: the schema and the merge still accept them so the deprecation
|
|
92
95
|
// detector can see an operator's setting, but no runtime consumer may read
|
|
93
96
|
// one (ADR 0011 §5, #745).
|
|
97
|
+
if (raw.permissionDialogKeys !== undefined) {
|
|
98
|
+
result.permissionDialogKeys = raw.permissionDialogKeys;
|
|
99
|
+
}
|
|
94
100
|
if (raw.shellTools !== undefined) {
|
|
95
101
|
result.shellTools = raw.shellTools;
|
|
96
102
|
}
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
|
36
36
|
import { registerPermissionSystemCommand } from "#src/config/config-modal";
|
|
37
37
|
import { getGlobalConfigPath } from "#src/config/config-paths";
|
|
38
38
|
import { ConfigStore } from "#src/config/config-store";
|
|
39
|
+
import { resolveDialogKeys } from "#src/config/dialog-keys";
|
|
39
40
|
import { isYoloModeEnabled } from "#src/config/extension-config";
|
|
40
41
|
import { computeExtensionPaths } from "#src/config/extension-paths";
|
|
41
42
|
import { GateRunner } from "#src/handlers/gates/runner";
|
|
@@ -150,6 +151,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
150
151
|
getPromptPreferences: () => ({
|
|
151
152
|
doublePressToConfirm: configStore.current().doublePressToConfirm,
|
|
152
153
|
budget: resolveRenderBudget(configStore.current()),
|
|
154
|
+
dialogKeys: resolveDialogKeys(configStore.current()).keys,
|
|
153
155
|
}),
|
|
154
156
|
requestPermissionDecision,
|
|
155
157
|
forwardingDir: paths.forwardingDir,
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ResolvedAccessIntent } from "#src/access-intent/access-intent";
|
|
3
3
|
import { normalizeInput } from "#src/access-intent/input-normalizer";
|
|
4
|
-
import {
|
|
5
|
-
PATH_SURFACES,
|
|
6
|
-
surfaceFamilyOf,
|
|
7
|
-
} from "#src/access-intent/path-surfaces";
|
|
4
|
+
import { surfaceFamilyOf } from "#src/access-intent/path-surfaces";
|
|
8
5
|
import { classifyToolKind } from "#src/access-intent/tool-kind";
|
|
9
6
|
import {
|
|
10
7
|
getGlobalConfigPath,
|
|
@@ -29,7 +26,6 @@ import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
|
29
26
|
import {
|
|
30
27
|
evaluate,
|
|
31
28
|
evaluateAnyValue,
|
|
32
|
-
evaluateFirst,
|
|
33
29
|
floorAllowsToAsk,
|
|
34
30
|
isSurfaceFullyDenied,
|
|
35
31
|
rewriteAsksToYolo,
|
|
@@ -356,9 +352,10 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
356
352
|
/**
|
|
357
353
|
* Evaluate a normalized surface/values triple and shape the result.
|
|
358
354
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
* `"tool"` and `"path-values"`
|
|
355
|
+
* Every surface resolves through {@link evaluateAnyValue}, so a rule's position
|
|
356
|
+
* in the config decides and the candidate list only determines which name the
|
|
357
|
+
* decision is reported under. Shared by the `"tool"` and `"path-values"`
|
|
358
|
+
* branches of {@link PermissionManager.check}.
|
|
362
359
|
*/
|
|
363
360
|
function buildCheckResult(
|
|
364
361
|
surface: string,
|
|
@@ -369,9 +366,7 @@ function buildCheckResult(
|
|
|
369
366
|
fullRules: Ruleset,
|
|
370
367
|
flavor: PathFlavor,
|
|
371
368
|
): PermissionCheckResult {
|
|
372
|
-
const { rule, value } =
|
|
373
|
-
? evaluateAnyValue(surface, values, fullRules, flavor)
|
|
374
|
-
: evaluateFirst(surface, values, fullRules, flavor);
|
|
369
|
+
const { rule, value } = evaluateAnyValue(surface, values, fullRules, flavor);
|
|
375
370
|
|
|
376
371
|
// For MCP, replace the normalizer's fallback target with the actual
|
|
377
372
|
// matched candidate value so PermissionCheckResult.target is accurate.
|
package/src/policy/rule.ts
CHANGED
|
@@ -216,45 +216,15 @@ export function evaluateMostRestrictive(
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
/**
|
|
219
|
-
* Evaluate a
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* evaluating the first candidate so the caller always receives a concrete
|
|
229
|
-
* result.
|
|
230
|
-
*/
|
|
231
|
-
export function evaluateFirst(
|
|
232
|
-
surface: string,
|
|
233
|
-
values: string[],
|
|
234
|
-
rules: Ruleset,
|
|
235
|
-
flavor: PathFlavor,
|
|
236
|
-
): { rule: Rule; value: string } {
|
|
237
|
-
for (const value of values) {
|
|
238
|
-
const rule = evaluate(surface, value, rules, flavor);
|
|
239
|
-
if (rule.layer !== "default") {
|
|
240
|
-
return { rule, value };
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
// All candidates matched only the synthesized default — use the first.
|
|
244
|
-
const fallbackValue = values[0] ?? "*";
|
|
245
|
-
return {
|
|
246
|
-
rule: evaluate(surface, fallbackValue, rules, flavor),
|
|
247
|
-
value: fallbackValue,
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Evaluate equivalent lookup values as aliases of the same path.
|
|
253
|
-
*
|
|
254
|
-
* Unlike `evaluateFirst()`, this preserves rule ordering across aliases: the
|
|
255
|
-
* last rule that matches any alias wins. This lets absolute allowlists and
|
|
256
|
-
* legacy relative rules coexist without a catch-all match on the first alias
|
|
257
|
-
* masking a later, more specific rule on another alias.
|
|
219
|
+
* Evaluate a set of lookup values as alternative names for one access.
|
|
220
|
+
*
|
|
221
|
+
* Preserves rule ordering across the whole set: the last rule matching **any**
|
|
222
|
+
* value wins, and the reported value is the first one that rule matches. This
|
|
223
|
+
* is last-match-wins (see `evaluate()`) lifted from a single value to a
|
|
224
|
+
* candidate list, so a catch-all matched by one value cannot mask a later, more
|
|
225
|
+
* specific rule matched by another — whether the values are spellings of one
|
|
226
|
+
* path (`/proj/src/a.ts` and `src/a.ts`) or names for one MCP call
|
|
227
|
+
* (`github_search_code`, `github`, `mcp_call`).
|
|
258
228
|
*/
|
|
259
229
|
export function evaluateAnyValue(
|
|
260
230
|
surface: string,
|