@gotgenes/pi-permission-system 30.0.0 → 30.2.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 +31 -0
- package/README.md +1 -0
- package/dist/public.d.ts +24 -2
- package/docs/configuration.md +14 -1
- package/docs/cross-extension-api.md +21 -5
- package/docs/session-approvals.md +36 -1
- package/package.json +1 -1
- package/src/access-intent/path-surfaces.ts +35 -1
- package/src/approval-grant.ts +64 -0
- package/src/authority/forwarded-request-server.ts +8 -2
- package/src/authority/forwarding-io.ts +6 -1
- package/src/authority/local-user-authorizer.ts +37 -17
- package/src/authority/permission-dialog.ts +49 -16
- package/src/authority/permission-forwarding.ts +11 -1
- package/src/authority/permission-prompt-component.ts +18 -5
- package/src/authority/permission-prompt-decision.ts +95 -15
- package/src/authority/permission-prompter.ts +25 -0
- package/src/handlers/before-agent-start.ts +8 -8
- package/src/handlers/gates/runner.ts +9 -7
- package/src/pattern-suggest.ts +73 -5
- package/src/permission-gate.ts +16 -3
- package/src/permission-manager.ts +20 -0
- package/src/permission-resolver.ts +9 -0
- package/src/permissions-service.ts +8 -0
- package/src/rule.ts +49 -0
- package/src/service.ts +24 -2
- package/src/session-approval.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,37 @@ 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
|
+
## [30.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.1.0...pi-permission-system-v30.2.0) (2026-09-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** offer a both-directions session grant at the ask prompt ([c4c6736](https://github.com/gotgenes/pi-packages/commit/c4c67364b88e74ad439adcc6c442de46a4f90c04)), closes [#813](https://github.com/gotgenes/pi-packages/issues/813)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** name every path in a forwarded whole-session scope label ([b61c19d](https://github.com/gotgenes/pi-packages/commit/b61c19d77c74e2d8ff233ca556af04f6aacd2c09)), closes [#813](https://github.com/gotgenes/pi-packages/issues/813)
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** document the session-grant direction width ([dae4737](https://github.com/gotgenes/pi-packages/commit/dae473727b4b514761545d8fcc0a5e4824fe35b0)), closes [#813](https://github.com/gotgenes/pi-packages/issues/813)
|
|
22
|
+
|
|
23
|
+
## [30.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.0.0...pi-permission-system-v30.1.0) (2026-09-02)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* **pi-permission-system:** publish isToolFullyDenied for cross-extension tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([4345b19](https://github.com/gotgenes/pi-packages/commit/4345b199e5a33427e47bb80f9c16cb78fb1e7fbf)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
|
|
29
|
+
|
|
30
|
+
### Bug Fixes
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** stop hiding a tool whose surface has a reachable non-deny rule ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([ce46b6c](https://github.com/gotgenes/pi-packages/commit/ce46b6c7b9f4c901d6a035f3be6e334f77a76e95)), closes [#815](https://github.com/gotgenes/pi-packages/issues/815)
|
|
33
|
+
|
|
34
|
+
### Documentation
|
|
35
|
+
|
|
36
|
+
* **pi-permission-system:** describe tool exposure as surface reachability ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([71d9696](https://github.com/gotgenes/pi-packages/commit/71d96969e5c33ecc0f38b0a43b0bdaf267622000))
|
|
37
|
+
* **pi-permission-system:** stop recommending getToolPermission for tool pre-filtering ([#815](https://github.com/gotgenes/pi-packages/issues/815)) ([fdf0a38](https://github.com/gotgenes/pi-packages/commit/fdf0a383d5419eaded4b6277ead8264e23aa281d))
|
|
38
|
+
|
|
8
39
|
## [30.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v29.3.0...pi-permission-system-v30.0.0) (2026-09-02)
|
|
9
40
|
|
|
10
41
|
|
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
|
+
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.
|
|
69
70
|
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.
|
|
70
71
|
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.
|
|
71
72
|
|
package/dist/public.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
|
|
9
9
|
* value object and the forwarded wire type name it, and those two already
|
|
10
10
|
* import in one direction.
|
|
11
11
|
*/
|
|
12
|
+
|
|
12
13
|
interface ApprovalGrant {
|
|
13
14
|
readonly surface: string;
|
|
14
15
|
readonly pattern: string;
|
|
@@ -531,13 +532,17 @@ interface PermissionQuery {
|
|
|
531
532
|
*/
|
|
532
533
|
checkPermission(surface: string, value?: string, agentName?: string): PermissionCheckResult;
|
|
533
534
|
/**
|
|
534
|
-
* Query
|
|
535
|
-
* creating a child session.
|
|
535
|
+
* Query a surface's catch-all permission state — its blanket policy.
|
|
536
536
|
*
|
|
537
537
|
* Returns `"deny"` | `"allow"` | `"ask"` based on the composed policy.
|
|
538
538
|
* Does not consider command-level rules (e.g. per-bash-command patterns) —
|
|
539
539
|
* use `checkPermission` for runtime invocation gates.
|
|
540
540
|
*
|
|
541
|
+
* This is **not** the question to ask when pre-filtering a tool list: a
|
|
542
|
+
* partially permissive surface such as `bash: {"*": "deny", "git *": "ask"}`
|
|
543
|
+
* answers `"deny"` here while `git status` would still be asked about. Use
|
|
544
|
+
* {@link PermissionsService.isToolFullyDenied} for that.
|
|
545
|
+
*
|
|
541
546
|
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
542
547
|
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
543
548
|
*/
|
|
@@ -557,6 +562,23 @@ interface PermissionQuery {
|
|
|
557
562
|
* rules internally.
|
|
558
563
|
*/
|
|
559
564
|
interface PermissionsService extends PermissionQuery {
|
|
565
|
+
/**
|
|
566
|
+
* Whether every value under a tool's surface resolves to `deny`.
|
|
567
|
+
*
|
|
568
|
+
* This is the question to ask before withholding a tool from a child
|
|
569
|
+
* session's tool list, and it is not `getToolPermission`: that reports the
|
|
570
|
+
* surface's own catch-all, so a partially permissive surface such as
|
|
571
|
+
* `bash: {"*": "deny", "git *": "ask"}` reads as `"deny"` while `git status`
|
|
572
|
+
* would still be asked about.
|
|
573
|
+
*
|
|
574
|
+
* Rule ordering is honored (last-match-wins), so an exception written *after*
|
|
575
|
+
* a `deny` catch-all keeps the tool reachable and one written *before* it
|
|
576
|
+
* does not.
|
|
577
|
+
*
|
|
578
|
+
* @param toolName - Tool name (e.g. `"bash"`, `"read"`, `"my-extension:tool"`).
|
|
579
|
+
* @param agentName - Optional agent name for per-agent policy resolution.
|
|
580
|
+
*/
|
|
581
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
560
582
|
/**
|
|
561
583
|
* Register a custom preview formatter for a specific tool name.
|
|
562
584
|
*
|
package/docs/configuration.md
CHANGED
|
@@ -122,10 +122,15 @@ In an interactive **TUI** session, an `ask` decision opens an inline keybind dia
|
|
|
122
122
|
| Key | Action |
|
|
123
123
|
| --- | ----------------------------------------------------------------- |
|
|
124
124
|
| `y` | Approve once |
|
|
125
|
-
| `s` | Approve for this session
|
|
125
|
+
| `s` | Approve for this session, in the direction the gate proved |
|
|
126
|
+
| `b` | Approve for this session in **both** directions (see below) |
|
|
126
127
|
| `n` | Deny |
|
|
127
128
|
| `r` | Deny with a reason (opens an inline editor; a reason is required) |
|
|
128
129
|
|
|
130
|
+
`b` appears only for an ask whose paths all prove the same direction — a read or a write, but not both.
|
|
131
|
+
Every other ask shows the four options above without it.
|
|
132
|
+
See [session-approvals.md](session-approvals.md#grant-direction) for what the two widths grant.
|
|
133
|
+
|
|
129
134
|
Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option, and `esc` denies.
|
|
130
135
|
With `doublePressToConfirm` enabled (the default), a letter hotkey **arms** its action and shows a `Press y again to approve.` hint; press the same key again to commit.
|
|
131
136
|
Set `doublePressToConfirm` to `false` to commit on the first press.
|
|
@@ -295,6 +300,10 @@ A string value is a catch-all for that surface.
|
|
|
295
300
|
Unknown or absent tools are not required in the config.
|
|
296
301
|
If a tool is not registered at runtime, this extension blocks it before permission checks run.
|
|
297
302
|
|
|
303
|
+
A tool is withheld from the model entirely only when **every** pattern configured under its surface resolves to `deny`.
|
|
304
|
+
So `"bash": "deny"` hides the tool, while `"bash": { "*": "deny", "git *": "ask" }` keeps it visible — the agent can attempt a `git` command and be prompted, and everything else is denied at the gate.
|
|
305
|
+
Ordering follows the same last-match-wins rule as every other lookup: an exception written *after* the `deny` catch-all is reachable, while one written *before* it is shadowed and the tool is hidden.
|
|
306
|
+
|
|
298
307
|
#### Path Patterns for File Tools
|
|
299
308
|
|
|
300
309
|
For path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`), an object value maps file-path patterns to actions.
|
|
@@ -993,6 +1002,9 @@ Avoid arrays, multi-line scalars, and YAML anchors.
|
|
|
993
1002
|
}
|
|
994
1003
|
```
|
|
995
1004
|
|
|
1005
|
+
The Bash tool stays visible to the agent here: the three `git` patterns are written after the `deny` catch-all, so they are reachable.
|
|
1006
|
+
Every other command is denied at the gate.
|
|
1007
|
+
|
|
996
1008
|
### Read-Only Bash Command Allowlist
|
|
997
1009
|
|
|
998
1010
|
The [Read-Only Mode](#read-only-mode) recipe above gates *tools*; this one gates the *bash* surface.
|
|
@@ -1142,6 +1154,7 @@ Additional behaviors:
|
|
|
1142
1154
|
|
|
1143
1155
|
- Unknown/unregistered tools are blocked before permission checks (prevents bypass attempts)
|
|
1144
1156
|
- Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
|
|
1157
|
+
- A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
|
|
1145
1158
|
- The `Available tools:` system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed
|
|
1146
1159
|
- The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
|
|
1147
1160
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
|
|
@@ -69,9 +69,12 @@ interface PermissionsService {
|
|
|
69
69
|
agentName?: string,
|
|
70
70
|
): PermissionCheckResult;
|
|
71
71
|
|
|
72
|
-
/** Query
|
|
72
|
+
/** Query a surface's catch-all permission state — its blanket policy. */
|
|
73
73
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
74
74
|
|
|
75
|
+
/** Whether every value under a tool's surface resolves to deny; use this to pre-filter a tool list. */
|
|
76
|
+
isToolFullyDenied(toolName: string, agentName?: string): boolean;
|
|
77
|
+
|
|
75
78
|
/**
|
|
76
79
|
* Register a custom preview formatter for a specific tool name.
|
|
77
80
|
* Returns a disposer that unregisters the formatter.
|
|
@@ -130,14 +133,27 @@ Decomposition needs the tree-sitter parser, which is warmed at `before_agent_sta
|
|
|
130
133
|
#### `getToolPermission`
|
|
131
134
|
|
|
132
135
|
Returns `"allow"` | `"deny"` | `"ask"` for a tool name without considering command-level rules.
|
|
133
|
-
|
|
136
|
+
It reports the surface's own catch-all, so it answers what a surface's blanket policy is.
|
|
134
137
|
|
|
135
138
|
```typescript
|
|
136
|
-
const
|
|
137
|
-
(t) => permissions.getToolPermission(t, agentName) === "deny",
|
|
138
|
-
);
|
|
139
|
+
const blanketPolicy = permissions.getToolPermission("bash", agentName);
|
|
139
140
|
```
|
|
140
141
|
|
|
142
|
+
This is not the question to ask when pre-filtering a tool list — use `isToolFullyDenied` for that.
|
|
143
|
+
A surface written as `bash: {"*": "deny", "git *": "ask"}` reports `"deny"` here while `git status` would still be asked about.
|
|
144
|
+
|
|
145
|
+
#### `isToolFullyDenied`
|
|
146
|
+
|
|
147
|
+
Returns `true` when every value under the tool's surface resolves to `deny`, and `false` when anything at all could get through.
|
|
148
|
+
Use this to pre-filter a tool list before creating a child session — it avoids calling `checkPermission` per tool and interpreting the full result, and unlike `getToolPermission` it does not withhold a tool that is only partially restricted.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
const usable = tools.filter((t) => !permissions.isToolFullyDenied(t, agentName));
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Rule ordering is honored (last-match-wins), so an exception written after a `deny` catch-all keeps the tool reachable while one written before it does not.
|
|
155
|
+
It considers config-layer rules only; a runtime session approval does not change the answer.
|
|
156
|
+
|
|
141
157
|
#### `registerToolInputFormatter`
|
|
142
158
|
|
|
143
159
|
Register a custom preview formatter for a specific tool name.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Session-Scoped Approvals
|
|
2
2
|
|
|
3
|
-
When any permission resolves to `ask`, the permission dialog offers four options:
|
|
3
|
+
When any permission resolves to `ask`, the permission dialog offers at least these four options:
|
|
4
4
|
|
|
5
5
|
```text
|
|
6
6
|
Yes | Yes, allow "<pattern>" for this session | No | No, provide reason
|
|
@@ -9,8 +9,42 @@ Yes | Yes, allow "<pattern>" for this session | No | No, provide reason
|
|
|
9
9
|
Selecting **Yes, allow "\<pattern\>" for this session** approves the current request and records the suggested wildcard pattern as a session rule.
|
|
10
10
|
Subsequent requests that match the pattern skip the prompt for the remainder of the session.
|
|
11
11
|
|
|
12
|
+
A file-access ask adds a fifth option — see [Grant direction](#grant-direction).
|
|
13
|
+
|
|
12
14
|
Session approvals are ephemeral — they are never persisted to disk and are cleared on `session_shutdown`.
|
|
13
15
|
|
|
16
|
+
## Grant Direction
|
|
17
|
+
|
|
18
|
+
The `path` and `external_directory` surfaces carry a read/write axis, and the two directions are independent permissions rather than tiers: a write grant does not imply a read.
|
|
19
|
+
When the gate can prove which direction a request needs — a `>` redirect proves a write, `cat` proves a read, the `read` tool proves a read — the session grant is recorded on that direction alone, which is what the prompt named.
|
|
20
|
+
|
|
21
|
+
That is least privilege, and it costs a second prompt in the read-after-write flow: approving `echo hello > /tmp/out.txt` grants a write, so a following `cat /tmp/out.txt` asks again.
|
|
22
|
+
So an ask whose paths all prove the **same** direction offers both widths:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
(y) Yes
|
|
26
|
+
▶ (s) Yes, allow writes to "/tmp/*" for this session
|
|
27
|
+
(b) Yes, allow reads and writes to "/tmp/*" for this session
|
|
28
|
+
(n) No
|
|
29
|
+
(r) No, provide reason
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- **`s`** records the proven direction only (`external_directory_write` here).
|
|
33
|
+
This is the default; a later read of the same path asks again.
|
|
34
|
+
- **`b`** records the bare family key (`external_directory`), which expands onto both directions — exactly the width a config key like `"external_directory": {"/tmp/*": "allow"}` has.
|
|
35
|
+
A later read of the same path is covered.
|
|
36
|
+
|
|
37
|
+
The choice is opt-in per ask and never persisted.
|
|
38
|
+
Ignoring `b` grants no more than the prompt named.
|
|
39
|
+
|
|
40
|
+
`b` is offered only when one direction phrase describes the whole ask.
|
|
41
|
+
A command that proves a read of one path and a write of another (`cat /outside/a.ts > /elsewhere/b.ts`) records each path on its own direction and offers no width choice, because no single label would be true of both.
|
|
42
|
+
Surfaces with no capability axis — `bash`, `mcp`, `skill`, and the per-tool surfaces — never offer it.
|
|
43
|
+
|
|
44
|
+
Two paths in the **same** directory derive the same glob, so approving them at either width grants that directory what the prompt showed.
|
|
45
|
+
|
|
46
|
+
The review log records the width every session grant was taken at, as `sessionGrantWidth: "proven"` or `"family"`.
|
|
47
|
+
|
|
14
48
|
## Suggested Patterns
|
|
15
49
|
|
|
16
50
|
The suggested pattern is surface-specific:
|
|
@@ -50,6 +84,7 @@ Put the most specific multi-word prefix first (e.g. `"npm run": 3`) before the s
|
|
|
50
84
|
The review log records session approval decisions:
|
|
51
85
|
|
|
52
86
|
- `resolution: "approved_for_session"` — when the user approves with the session pattern
|
|
87
|
+
- `sessionGrantWidth: "proven" | "family"` — beside it, the direction width the grant was recorded at (see [Grant direction](#grant-direction))
|
|
53
88
|
- `resolution: "session_approved"` — when a later request is matched by an existing session rule
|
|
54
89
|
|
|
55
90
|
## Permission Prompt Summaries
|
package/package.json
CHANGED
|
@@ -32,11 +32,26 @@ const DIRECTIONAL_FAMILIES: ReadonlySet<string> = new Set([
|
|
|
32
32
|
"external_directory",
|
|
33
33
|
]);
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The capability directions, in ADR 0013 §4's normative order.
|
|
37
|
+
*
|
|
38
|
+
* The suffixes below derive from these, so a direction word and its surface
|
|
39
|
+
* suffix cannot drift — the same discipline that keeps the four surface names
|
|
40
|
+
* spelled exactly once.
|
|
41
|
+
*/
|
|
42
|
+
const CAPABILITY_DIRECTIONS = ["read", "write"] as const;
|
|
43
|
+
|
|
44
|
+
/** One end of the read/write axis (ADR 0013 §3). */
|
|
45
|
+
export type CapabilityDirection = (typeof CAPABILITY_DIRECTIONS)[number];
|
|
46
|
+
|
|
35
47
|
/**
|
|
36
48
|
* The capability suffixes, in ADR 0013 §4's normative order: a family's
|
|
37
49
|
* sugar-expanded and folded members are always read-then-write.
|
|
38
50
|
*/
|
|
39
|
-
const CAPABILITY_SUFFIXES = [
|
|
51
|
+
const CAPABILITY_SUFFIXES = [
|
|
52
|
+
`_${CAPABILITY_DIRECTIONS[0]}`,
|
|
53
|
+
`_${CAPABILITY_DIRECTIONS[1]}`,
|
|
54
|
+
] as const;
|
|
40
55
|
|
|
41
56
|
/**
|
|
42
57
|
* Surfaces whose patterns are matched against filesystem paths and therefore
|
|
@@ -67,6 +82,25 @@ export function surfaceFamilyOf(surface: string): string {
|
|
|
67
82
|
return surface;
|
|
68
83
|
}
|
|
69
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The direction a surface proves, or `null` when it proves neither — a family
|
|
87
|
+
* name, a non-path surface, or a suffixed name outside the directional
|
|
88
|
+
* families (`my_tool_read`).
|
|
89
|
+
*
|
|
90
|
+
* The inverse of {@link capabilitySurfaceForEffect} over a proven effect, and
|
|
91
|
+
* the question that decides whether an ask's session grant can be widened.
|
|
92
|
+
*/
|
|
93
|
+
export function capabilityDirectionOf(
|
|
94
|
+
surface: string,
|
|
95
|
+
): CapabilityDirection | null {
|
|
96
|
+
if (surfaceFamilyOf(surface) === surface) return null;
|
|
97
|
+
return (
|
|
98
|
+
CAPABILITY_DIRECTIONS.find((direction) =>
|
|
99
|
+
surface.endsWith(`_${direction}`),
|
|
100
|
+
) ?? null
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
/**
|
|
71
105
|
* A family name's directional members, or `null` when `surface` is not a
|
|
72
106
|
* family name (a directional member itself, or any non-path surface).
|
package/src/approval-grant.ts
CHANGED
|
@@ -7,7 +7,71 @@
|
|
|
7
7
|
* value object and the forwarded wire type name it, and those two already
|
|
8
8
|
* import in one direction.
|
|
9
9
|
*/
|
|
10
|
+
import {
|
|
11
|
+
type CapabilityDirection,
|
|
12
|
+
capabilityDirectionOf,
|
|
13
|
+
surfaceFamilyOf,
|
|
14
|
+
} from "#src/access-intent/path-surfaces";
|
|
15
|
+
|
|
10
16
|
export interface ApprovalGrant {
|
|
11
17
|
readonly surface: string;
|
|
12
18
|
readonly pattern: string;
|
|
13
19
|
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* How wide a session grant is recorded, relative to what the gate proved.
|
|
23
|
+
*
|
|
24
|
+
* `"proven"` records each grant on the surface the gate named — the
|
|
25
|
+
* least-privilege default, and the only width anything produces today.
|
|
26
|
+
* `"family"` folds a directional surface to its bare family, which
|
|
27
|
+
* `SessionRules.approve` sugar-expands onto both members (ADR 0013 §4).
|
|
28
|
+
*/
|
|
29
|
+
export type SessionGrantWidth = "proven" | "family";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether an off-disk value names a width this node understands.
|
|
33
|
+
*
|
|
34
|
+
* The forwarded response is another process's file, so an unrecognized value
|
|
35
|
+
* is dropped rather than trusted — which lands the grant at `"proven"`, the
|
|
36
|
+
* least-privilege width, in the skew direction.
|
|
37
|
+
*/
|
|
38
|
+
export function isSessionGrantWidth(
|
|
39
|
+
value: unknown,
|
|
40
|
+
): value is SessionGrantWidth {
|
|
41
|
+
return value === "proven" || value === "family";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The same grant on its bare family surface, or the grant itself when it
|
|
46
|
+
* already names one (or names no directional family at all).
|
|
47
|
+
*
|
|
48
|
+
* Widening is a change of surface and never of pattern: the user is saying
|
|
49
|
+
* "the other direction too", not "more paths".
|
|
50
|
+
*/
|
|
51
|
+
export function widenGrant(grant: ApprovalGrant): ApprovalGrant {
|
|
52
|
+
const family = surfaceFamilyOf(grant.surface);
|
|
53
|
+
return family === grant.surface
|
|
54
|
+
? grant
|
|
55
|
+
: { surface: family, pattern: grant.pattern };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The one direction every grant proves, or `null` when they disagree, any of
|
|
60
|
+
* them proves none, or there are no grants.
|
|
61
|
+
*
|
|
62
|
+
* This is the precise reading of "the gate proved a single direction": a
|
|
63
|
+
* widened grant is offered only when one direction phrase describes the whole
|
|
64
|
+
* approval, so the prompt can name what it is widening from.
|
|
65
|
+
*/
|
|
66
|
+
export function provenDirectionOf(
|
|
67
|
+
grants: readonly ApprovalGrant[],
|
|
68
|
+
): CapabilityDirection | null {
|
|
69
|
+
if (grants.length === 0) return null;
|
|
70
|
+
const direction = capabilityDirectionOf(grants[0].surface);
|
|
71
|
+
if (direction === null) return null;
|
|
72
|
+
return grants.every(
|
|
73
|
+
(grant) => capabilityDirectionOf(grant.surface) === direction,
|
|
74
|
+
)
|
|
75
|
+
? direction
|
|
76
|
+
: null;
|
|
77
|
+
}
|
|
@@ -351,10 +351,13 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
351
351
|
}
|
|
352
352
|
if (request.sessionApproval) {
|
|
353
353
|
const { grants } = request.sessionApproval;
|
|
354
|
-
|
|
354
|
+
const approval = SessionApproval.forGrants(grants).atWidth(
|
|
355
|
+
decision.sessionGrantWidth ?? "proven",
|
|
356
|
+
);
|
|
357
|
+
this.recorder.recordSessionApproval(approval);
|
|
355
358
|
this.logger.review("forwarded_permission.session_recorded", {
|
|
356
359
|
...logDetails,
|
|
357
|
-
grants,
|
|
360
|
+
grants: approval.grants,
|
|
358
361
|
});
|
|
359
362
|
}
|
|
360
363
|
return {
|
|
@@ -403,6 +406,9 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
403
406
|
// Carried onto the wire so the requester can name what decided inside
|
|
404
407
|
// this session, not merely that this session answered (#726).
|
|
405
408
|
decidedBy: decision.decidedBy,
|
|
409
|
+
// The child records a subagent-scoped grant itself, so the width the
|
|
410
|
+
// human chose has to reach it (#813).
|
|
411
|
+
sessionGrantWidth: decision.sessionGrantWidth,
|
|
406
412
|
} satisfies ForwardedPermissionResponse);
|
|
407
413
|
} catch (error) {
|
|
408
414
|
logPermissionForwardingError(
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
writeFileSync,
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
|
-
import type
|
|
12
|
+
import { type ApprovalGrant, isSessionGrantWidth } from "#src/approval-grant";
|
|
13
13
|
import { asDecisionSource } from "#src/authority/decision-source";
|
|
14
14
|
import { isPermissionDecisionState } from "#src/authority/permission-dialog";
|
|
15
15
|
import {
|
|
@@ -495,6 +495,11 @@ export function readForwardedPermissionResponse(
|
|
|
495
495
|
// record is dropped, but the decision itself still has to reach the
|
|
496
496
|
// requester, so it never rejects the response.
|
|
497
497
|
decidedBy: asDecisionSource(parsed.decidedBy),
|
|
498
|
+
// Tolerant for the same reason, and least-privilege when it fires: a
|
|
499
|
+
// dropped width records the grant on the surface the gate proved.
|
|
500
|
+
sessionGrantWidth: isSessionGrantWidth(parsed.sessionGrantWidth)
|
|
501
|
+
? parsed.sessionGrantWidth
|
|
502
|
+
: undefined,
|
|
498
503
|
};
|
|
499
504
|
} catch (error) {
|
|
500
505
|
logPermissionForwardingWarning(
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { provenDirectionOf } from "#src/approval-grant";
|
|
2
3
|
import type {
|
|
3
4
|
PermissionPromptDecision,
|
|
4
5
|
RequestPermissionOptions,
|
|
@@ -8,7 +9,11 @@ import type {
|
|
|
8
9
|
PromptPreferences,
|
|
9
10
|
requestPermissionDecision,
|
|
10
11
|
} from "#src/authority/permission-prompt-component";
|
|
11
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
buildDirectionalSessionLabels,
|
|
14
|
+
buildForwardedScopeLabels,
|
|
15
|
+
describeGrantTarget,
|
|
16
|
+
} from "#src/pattern-suggest";
|
|
12
17
|
import {
|
|
13
18
|
emitUiPromptEvent,
|
|
14
19
|
type PermissionEventBus,
|
|
@@ -65,24 +70,39 @@ export class LocalUserAuthorizer implements TerminalAuthorizer {
|
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
73
|
+
* The dialog options this ask offers, composed from three independent groups.
|
|
74
|
+
*
|
|
75
|
+
* The label names what the session grant covers (a gate-supplied one, or one
|
|
76
|
+
* derived from the grants themselves for a path ask). An ask whose grants all
|
|
77
|
+
* prove the same direction additionally offers the both-directions width
|
|
78
|
+
* (#813). A forwarded ask additionally offers the scope choice (subagent vs
|
|
79
|
+
* whole session).
|
|
80
|
+
*
|
|
81
|
+
* They compose rather than exclude: a forwarded path ask offers all three, and
|
|
82
|
+
* an ask that qualifies for none passes `undefined` so the dialog keeps its
|
|
83
|
+
* defaults.
|
|
71
84
|
*/
|
|
72
85
|
function buildRequestOptions(
|
|
73
86
|
details: PromptPermissionDetails,
|
|
74
87
|
): RequestPermissionOptions | undefined {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
+
const grants = details.sessionApproval?.grants ?? [];
|
|
89
|
+
const direction = provenDirectionOf(grants);
|
|
90
|
+
const widths = direction
|
|
91
|
+
? buildDirectionalSessionLabels(direction, describeGrantTarget(grants))
|
|
92
|
+
: null;
|
|
93
|
+
const sessionLabel = widths?.sessionLabel ?? details.sessionLabel;
|
|
94
|
+
|
|
95
|
+
const options: RequestPermissionOptions = {
|
|
96
|
+
...(sessionLabel ? { sessionLabel } : {}),
|
|
97
|
+
...(widths ? { sessionWidth: { label: widths.widenedLabel } } : {}),
|
|
98
|
+
...(details.forwarding && grants.length > 0
|
|
99
|
+
? {
|
|
100
|
+
sessionScope: buildForwardedScopeLabels(
|
|
101
|
+
details.forwarding.requesterAgentName,
|
|
102
|
+
grants,
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
: {}),
|
|
106
|
+
};
|
|
107
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
88
108
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
|
|
3
4
|
export type PermissionDecisionState =
|
|
@@ -23,6 +24,16 @@ export type PermissionPromptDecision = {
|
|
|
23
24
|
* reads the `unavailable` decider below instead (#772).
|
|
24
25
|
*/
|
|
25
26
|
confirmationUnavailable?: true;
|
|
27
|
+
/**
|
|
28
|
+
* How wide a whole-session grant the human chose, when they chose one.
|
|
29
|
+
*
|
|
30
|
+
* Orthogonal to `state` rather than a value of it: the two directions and
|
|
31
|
+
* the subagent/serving scope vary independently, and an unrecognized `state`
|
|
32
|
+
* is rejected outright by the forwarded-response reader, where an
|
|
33
|
+
* unrecognized field is merely dropped. Absent means `"proven"` — the
|
|
34
|
+
* direction the gate named, which is what every producer chose before #813.
|
|
35
|
+
*/
|
|
36
|
+
sessionGrantWidth?: SessionGrantWidth;
|
|
26
37
|
/**
|
|
27
38
|
* What decided this request, stamped by the site that decided it.
|
|
28
39
|
*
|
|
@@ -54,6 +65,22 @@ const APPROVE_FOR_SESSION_OPTION = "Yes, for this session";
|
|
|
54
65
|
const DENY_OPTION = "No";
|
|
55
66
|
const DENY_WITH_REASON_OPTION = "No, provide reason";
|
|
56
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A session-granting decision, naming its width only when it is not the
|
|
70
|
+
* default — so a narrow grant serializes exactly as it did before the width
|
|
71
|
+
* option existed.
|
|
72
|
+
*/
|
|
73
|
+
function sessionApproval(
|
|
74
|
+
state: "approved_for_session" | "approved_for_serving_session",
|
|
75
|
+
width: SessionGrantWidth,
|
|
76
|
+
): UnattributedDecision {
|
|
77
|
+
return {
|
|
78
|
+
approved: true,
|
|
79
|
+
state,
|
|
80
|
+
...(width === "family" ? { sessionGrantWidth: width } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
57
84
|
export function normalizePermissionDenialReason(
|
|
58
85
|
value: unknown,
|
|
59
86
|
): string | undefined {
|
|
@@ -96,6 +123,12 @@ export function isPermissionDecisionState(
|
|
|
96
123
|
export interface RequestPermissionOptions {
|
|
97
124
|
/** Override the "for this session" option label (e.g. to show the suggested pattern). */
|
|
98
125
|
sessionLabel?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Present iff this ask's session grant can be widened to both directions:
|
|
128
|
+
* its label is the extra option shown beside the proven-direction one
|
|
129
|
+
* (#813). Absent leaves the prompt exactly four options.
|
|
130
|
+
*/
|
|
131
|
+
sessionWidth?: { label: string };
|
|
99
132
|
/**
|
|
100
133
|
* Forwarded asks only: when set, choosing the "for this session" option opens
|
|
101
134
|
* a second select asking whether the grant applies to the requesting subagent
|
|
@@ -114,16 +147,16 @@ export async function requestPermissionDecisionFromUi(
|
|
|
114
147
|
options?: RequestPermissionOptions,
|
|
115
148
|
): Promise<UnattributedDecision> {
|
|
116
149
|
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
|
|
150
|
+
const widthOption = options?.sessionWidth?.label;
|
|
117
151
|
const decisionOptions = [
|
|
118
152
|
APPROVE_OPTION,
|
|
119
153
|
sessionOption,
|
|
154
|
+
...(widthOption ? [widthOption] : []),
|
|
120
155
|
DENY_OPTION,
|
|
121
156
|
DENY_WITH_REASON_OPTION,
|
|
122
|
-
]
|
|
157
|
+
];
|
|
123
158
|
|
|
124
|
-
const selected = await ui.select(`${title}\n${message}`,
|
|
125
|
-
...decisionOptions,
|
|
126
|
-
]);
|
|
159
|
+
const selected = await ui.select(`${title}\n${message}`, decisionOptions);
|
|
127
160
|
|
|
128
161
|
if (selected === APPROVE_OPTION) {
|
|
129
162
|
return {
|
|
@@ -132,26 +165,26 @@ export async function requestPermissionDecisionFromUi(
|
|
|
132
165
|
};
|
|
133
166
|
}
|
|
134
167
|
|
|
135
|
-
if (selected === sessionOption) {
|
|
168
|
+
if (selected === sessionOption || (widthOption && selected === widthOption)) {
|
|
169
|
+
// The two session options differ only in the width they grant; the scope
|
|
170
|
+
// question below is the same for both.
|
|
171
|
+
const width: SessionGrantWidth =
|
|
172
|
+
selected === widthOption ? "family" : "proven";
|
|
136
173
|
if (options?.sessionScope) {
|
|
137
174
|
const scope = await ui.select(`${title}\nApply this session grant to:`, [
|
|
138
175
|
options.sessionScope.subagentLabel,
|
|
139
176
|
options.sessionScope.servingSessionLabel,
|
|
140
177
|
]);
|
|
141
|
-
return
|
|
142
|
-
approved: true,
|
|
178
|
+
return sessionApproval(
|
|
143
179
|
// A cancelled scope select (undefined) falls back to the
|
|
144
180
|
// least-privilege subagent scope.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
181
|
+
scope === options.sessionScope.servingSessionLabel
|
|
182
|
+
? "approved_for_serving_session"
|
|
183
|
+
: "approved_for_session",
|
|
184
|
+
width,
|
|
185
|
+
);
|
|
150
186
|
}
|
|
151
|
-
return
|
|
152
|
-
approved: true,
|
|
153
|
-
state: "approved_for_session",
|
|
154
|
-
};
|
|
187
|
+
return sessionApproval("approved_for_session", width);
|
|
155
188
|
}
|
|
156
189
|
|
|
157
190
|
if (selected === DENY_WITH_REASON_OPTION) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import type { ApprovalGrant } from "#src/approval-grant";
|
|
2
|
+
import type { ApprovalGrant, SessionGrantWidth } from "#src/approval-grant";
|
|
3
3
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
4
4
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
5
5
|
import type { PromptPayload } from "#src/presentation/prompt-payload";
|
|
@@ -198,6 +198,16 @@ export type ForwardedPermissionResponse = {
|
|
|
198
198
|
* rejecting the answer.
|
|
199
199
|
*/
|
|
200
200
|
decidedBy?: DecisionSource;
|
|
201
|
+
/**
|
|
202
|
+
* How wide a session grant the responder's human chose (#813).
|
|
203
|
+
*
|
|
204
|
+
* The child records a subagent-scoped grant itself, so the width has to
|
|
205
|
+
* survive the hop or the parent's choice is silently narrowed back.
|
|
206
|
+
* Optional for version-skew tolerance in both directions: an older
|
|
207
|
+
* responder omits it, and an older requester's allowlist rebuild drops it —
|
|
208
|
+
* both landing on `"proven"`, the least-privilege width.
|
|
209
|
+
*/
|
|
210
|
+
sessionGrantWidth?: SessionGrantWidth;
|
|
201
211
|
};
|
|
202
212
|
|
|
203
213
|
export type PermissionForwardingLocation = {
|