@gotgenes/pi-permission-system 30.1.0 → 31.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 +29 -0
- package/README.md +1 -0
- package/dist/public.d.ts +1 -0
- package/docs/configuration.md +6 -1
- package/docs/session-approvals.md +36 -1
- package/package.json +2 -1
- package/src/access-intent/bash/token-collection.ts +73 -2
- 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/gates/runner.ts +9 -7
- package/src/pattern-suggest.ts +73 -5
- package/src/permission-gate.ts +16 -3
- package/src/session-approval.ts +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ 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
|
+
## [31.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v30.2.0...pi-permission-system-v31.0.0) (2026-09-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** **breaking:** gate a path named as a for or select loop operand ([6189d81](https://github.com/gotgenes/pi-packages/commit/6189d81dc48a26d572bacb91a1b2e7c062634ad9)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
|
|
14
|
+
* **pi-permission-system:** **breaking:** gate a path named as a case subject ([dfd2f8d](https://github.com/gotgenes/pi-packages/commit/dfd2f8d682c39dcfe6a903cdeadde18b08a7cc60)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** commit the instrument behind the statement-operand measurement ([6a708b7](https://github.com/gotgenes/pi-packages/commit/6a708b716f038bf192875a0166ae6a6ab012c3d8)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
|
|
19
|
+
* **pi-permission-system:** register the statement-operand measurement script ([e3e8799](https://github.com/gotgenes/pi-packages/commit/e3e879935c6f395d4b2387a6e30e0791bb8fb765)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
|
|
20
|
+
* **pi-permission-system:** correct the drift note in the operand measurement ([e300934](https://github.com/gotgenes/pi-packages/commit/e300934c0aad34b3bfd127695219250ca6f4613f)), closes [#839](https://github.com/gotgenes/pi-packages/issues/839)
|
|
21
|
+
|
|
22
|
+
## [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)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Features
|
|
26
|
+
|
|
27
|
+
* **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)
|
|
28
|
+
|
|
29
|
+
### Bug Fixes
|
|
30
|
+
|
|
31
|
+
* **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)
|
|
32
|
+
|
|
33
|
+
### Documentation
|
|
34
|
+
|
|
35
|
+
* **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)
|
|
36
|
+
|
|
8
37
|
## [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)
|
|
9
38
|
|
|
10
39
|
|
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
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.
|
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "31.0.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"verify:public-types": "bash scripts/verify-public-types.sh",
|
|
92
92
|
"measure:core-coverage": "node scripts/measure-core-coverage.mjs",
|
|
93
93
|
"measure:statement-descent": "node scripts/measure-statement-descent.mjs",
|
|
94
|
+
"measure:statement-operands": "node scripts/measure-statement-operands.mjs",
|
|
94
95
|
"measure:wrapper-transparency": "node scripts/measure-wrapper-transparency.mjs",
|
|
95
96
|
"lint:md": "rumdl check *.md docs/**/*.md",
|
|
96
97
|
"lint": "biome check . && eslint . && pnpm run lint:md"
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "#src/access-intent/bash/node-text";
|
|
12
12
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
13
13
|
import { redirectEffectForDestination } from "#src/access-intent/bash/redirect-analysis";
|
|
14
|
-
import type
|
|
14
|
+
import { type TokenEffect, UNPROVEN_EFFECT } from "#src/access-intent/effect";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* A collected path-candidate token paired with the effect its position proved.
|
|
@@ -29,7 +29,8 @@ export interface PathToken {
|
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
31
|
* Recursively visit the AST and collect resolved text of nodes that
|
|
32
|
-
* represent command arguments or
|
|
32
|
+
* represent command arguments, redirect destinations, or a statement's own
|
|
33
|
+
* path operands.
|
|
33
34
|
*
|
|
34
35
|
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
|
|
35
36
|
* still descends an execution host for the commands it hosts — an interpolating
|
|
@@ -46,6 +47,12 @@ export interface PathToken {
|
|
|
46
47
|
export function collectPathCandidateTokens(node: TSNode): PathToken[] {
|
|
47
48
|
if (node.type === "command") return collectCommandTokens(node);
|
|
48
49
|
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
50
|
+
if (node.type === "for_statement") {
|
|
51
|
+
return collectStatementOperandTokens(node, "after-in");
|
|
52
|
+
}
|
|
53
|
+
if (node.type === "case_statement") {
|
|
54
|
+
return collectStatementOperandTokens(node, "before-in");
|
|
55
|
+
}
|
|
49
56
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
50
57
|
return collectHostedExecutionTokens(node);
|
|
51
58
|
}
|
|
@@ -139,6 +146,70 @@ function collectHostedExecutionTokens(node: TSNode): PathToken[] {
|
|
|
139
146
|
return tokens;
|
|
140
147
|
}
|
|
141
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Which side of a statement's `in` keyword carries its path operands.
|
|
151
|
+
*
|
|
152
|
+
* A `for`/`select` word list follows `in`; a `case` subject precedes it.
|
|
153
|
+
*/
|
|
154
|
+
type OperandSide = "before-in" | "after-in";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Collect the tokens of a statement that names its own path operands, rather
|
|
158
|
+
* than reaching them through a command.
|
|
159
|
+
*
|
|
160
|
+
* A path in a `for`/`select` word list or a `case` subject is a child of the
|
|
161
|
+
* statement node, so the command and redirect collectors never see it and the
|
|
162
|
+
* loop body cannot recover it — `for f in /etc/shadow; do cat $f; done` carries
|
|
163
|
+
* the literal only here, and ADR 0009 declines to resolve the body's `$f`
|
|
164
|
+
* (#839).
|
|
165
|
+
*
|
|
166
|
+
* The two statements ask one question with one parameter — which side of the
|
|
167
|
+
* anonymous `in` keyword is the operand side — so the walk is named here once
|
|
168
|
+
* rather than spelled twice, as `COMMAND_PREFIX_TYPES` is for the two command
|
|
169
|
+
* walkers.
|
|
170
|
+
*
|
|
171
|
+
* Three properties carry the design:
|
|
172
|
+
*
|
|
173
|
+
* 1. A non-operand child falls through to the ordinary recursion, not to
|
|
174
|
+
* nothing. That is what keeps the `do_group` reaching the loop body's
|
|
175
|
+
* commands; searching it for hosted executions alone would silently drop
|
|
176
|
+
* every ordinary body command.
|
|
177
|
+
* 2. An operand-side child outside {@link ARG_NODE_TYPES} falls through the
|
|
178
|
+
* same way, so a bare substitution in the word list is descended for its
|
|
179
|
+
* command as before and its operands keep that command's own attribution
|
|
180
|
+
* (#807) instead of the statement's.
|
|
181
|
+
* 3. An operand-side argument node is read *and* searched for hosted
|
|
182
|
+
* executions, since a `concatenation` can be both — the pairing
|
|
183
|
+
* {@link collectRedirectTokens} already performs on a destination.
|
|
184
|
+
*
|
|
185
|
+
* The token carries {@link UNPROVEN_EFFECT}: no command word owns it and no
|
|
186
|
+
* redirect operator names it, so neither proof source can speak and the gates
|
|
187
|
+
* consult both directional surfaces.
|
|
188
|
+
*/
|
|
189
|
+
function collectStatementOperandTokens(
|
|
190
|
+
node: TSNode,
|
|
191
|
+
operandSide: OperandSide,
|
|
192
|
+
): PathToken[] {
|
|
193
|
+
const tokens: PathToken[] = [];
|
|
194
|
+
let seenIn = false;
|
|
195
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
196
|
+
const child = node.child(i);
|
|
197
|
+
if (!child) continue;
|
|
198
|
+
if (!child.isNamed) {
|
|
199
|
+
if (child.type === "in") seenIn = true;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const side: OperandSide = seenIn ? "after-in" : "before-in";
|
|
203
|
+
if (side !== operandSide || !ARG_NODE_TYPES.has(child.type)) {
|
|
204
|
+
tokens.push(...collectPathCandidateTokens(child));
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
tokens.push({ token: resolveNodeText(child), effect: UNPROVEN_EFFECT });
|
|
208
|
+
tokens.push(...collectHostedExecutionTokens(child));
|
|
209
|
+
}
|
|
210
|
+
return tokens;
|
|
211
|
+
}
|
|
212
|
+
|
|
142
213
|
/**
|
|
143
214
|
* Extract the command name from a `command` node.
|
|
144
215
|
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
@@ -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 = {
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type PromptModelConfig,
|
|
23
23
|
type PromptViewState,
|
|
24
24
|
reducePrompt,
|
|
25
|
+
visibleOptionKeys,
|
|
25
26
|
} from "#src/authority/permission-prompt-decision";
|
|
26
27
|
import {
|
|
27
28
|
completeViewBudget,
|
|
@@ -127,12 +128,11 @@ const DEFAULT_SESSION_LABEL = "Yes, for this session";
|
|
|
127
128
|
const OPTION_LABELS: Record<PromptKey, string> = {
|
|
128
129
|
y: "Yes",
|
|
129
130
|
s: DEFAULT_SESSION_LABEL,
|
|
131
|
+
b: "Yes, for this session in both directions",
|
|
130
132
|
n: "No",
|
|
131
133
|
r: "No, provide reason",
|
|
132
134
|
};
|
|
133
135
|
|
|
134
|
-
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
135
|
-
|
|
136
136
|
export function presentInlinePermissionPrompt(
|
|
137
137
|
view: PermissionPromptView,
|
|
138
138
|
title: string,
|
|
@@ -142,6 +142,7 @@ export function presentInlinePermissionPrompt(
|
|
|
142
142
|
const config: PromptModelConfig = {
|
|
143
143
|
doublePressToConfirm: view.doublePressToConfirm,
|
|
144
144
|
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
|
|
145
|
+
widthLabel: options?.sessionWidth?.label,
|
|
145
146
|
sessionScope: options?.sessionScope,
|
|
146
147
|
};
|
|
147
148
|
return view.ui.custom<UnattributedDecision>(
|
|
@@ -330,7 +331,9 @@ class PermissionPromptComponent implements Component {
|
|
|
330
331
|
return { type: "cancel" };
|
|
331
332
|
}
|
|
332
333
|
if (this.state.step === "decision") {
|
|
333
|
-
const key =
|
|
334
|
+
const key = visibleOptionKeys(this.config).find((option) =>
|
|
335
|
+
matchesKey(data, option),
|
|
336
|
+
);
|
|
334
337
|
if (key) {
|
|
335
338
|
return { type: "hotkey", key };
|
|
336
339
|
}
|
|
@@ -354,8 +357,8 @@ class PermissionPromptComponent implements Component {
|
|
|
354
357
|
private renderDecision(width: number): string[] {
|
|
355
358
|
const ask = this.renderAsk(width);
|
|
356
359
|
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
|
|
357
|
-
for (const key of
|
|
358
|
-
const label =
|
|
360
|
+
for (const key of visibleOptionKeys(this.config)) {
|
|
361
|
+
const label = this.labelFor(key);
|
|
359
362
|
const selected = this.state.highlightedKey === key;
|
|
360
363
|
const marker = selected ? "▶" : " ";
|
|
361
364
|
const row = `${marker} (${key}) ${label}`;
|
|
@@ -366,6 +369,16 @@ class PermissionPromptComponent implements Component {
|
|
|
366
369
|
return lines;
|
|
367
370
|
}
|
|
368
371
|
|
|
372
|
+
/**
|
|
373
|
+
* The row label for a key: the two session options carry ask-supplied text
|
|
374
|
+
* naming what they grant, and the rest are fixed.
|
|
375
|
+
*/
|
|
376
|
+
private labelFor(key: PromptKey): string {
|
|
377
|
+
if (key === "s") return this.config.sessionLabel;
|
|
378
|
+
if (key === "b") return this.config.widthLabel ?? OPTION_LABELS.b;
|
|
379
|
+
return OPTION_LABELS[key];
|
|
380
|
+
}
|
|
381
|
+
|
|
369
382
|
private renderReason(width: number): string[] {
|
|
370
383
|
const lines = [
|
|
371
384
|
this.theme.fg("accent", this.title),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import {
|
|
2
3
|
createDeniedPermissionDecision,
|
|
3
4
|
normalizePermissionDenialReason,
|
|
@@ -15,17 +16,45 @@ import {
|
|
|
15
16
|
* forwards keystrokes to {@link reducePrompt} and renders the returned state.
|
|
16
17
|
*/
|
|
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";
|
|
20
27
|
|
|
21
28
|
/** Which sub-view the dialog is showing. */
|
|
22
29
|
export type PromptStep = "decision" | "reason" | "scope";
|
|
23
30
|
|
|
24
|
-
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
|
|
31
|
+
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "b", "n", "r"];
|
|
32
|
+
|
|
33
|
+
const NARROW_OPTION_ORDER: readonly PromptKey[] = OPTION_ORDER.filter(
|
|
34
|
+
(key) => key !== "b",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The decision step's option keys, in display order.
|
|
39
|
+
*
|
|
40
|
+
* A function of the config rather than an exported constant, so which options
|
|
41
|
+
* an ask offers is decided in the model and the component renders whatever it
|
|
42
|
+
* is handed — two copies of the roster would be two places to teach about a
|
|
43
|
+
* conditional option.
|
|
44
|
+
*
|
|
45
|
+
* The width option is offered iff the ask supplied a label for it, so an ask
|
|
46
|
+
* that proves no single direction is rendered and navigated exactly as before.
|
|
47
|
+
*/
|
|
48
|
+
export function visibleOptionKeys(
|
|
49
|
+
config: PromptModelConfig,
|
|
50
|
+
): readonly PromptKey[] {
|
|
51
|
+
return config.widthLabel ? OPTION_ORDER : NARROW_OPTION_ORDER;
|
|
52
|
+
}
|
|
25
53
|
|
|
26
54
|
const OPTION_VERBS: Record<PromptKey, string> = {
|
|
27
55
|
y: "approve",
|
|
28
56
|
s: "approve for this session",
|
|
57
|
+
b: "approve both directions for this session",
|
|
29
58
|
n: "deny",
|
|
30
59
|
r: "deny with a reason",
|
|
31
60
|
};
|
|
@@ -36,6 +65,13 @@ export interface PromptModelConfig {
|
|
|
36
65
|
doublePressToConfirm: boolean;
|
|
37
66
|
/** Label shown beside the approve-for-session option. */
|
|
38
67
|
sessionLabel: string;
|
|
68
|
+
/**
|
|
69
|
+
* Label for the both-directions session option (#813).
|
|
70
|
+
*
|
|
71
|
+
* Its presence is what offers the option: an ask whose grants prove no
|
|
72
|
+
* single direction supplies none, and the roster stays four keys.
|
|
73
|
+
*/
|
|
74
|
+
widthLabel?: string;
|
|
39
75
|
/**
|
|
40
76
|
* Forwarded asks only: when set, confirming `s` opens a second step choosing
|
|
41
77
|
* whether the grant applies to the requesting subagent only (least-privilege
|
|
@@ -56,6 +92,15 @@ export interface PromptViewState {
|
|
|
56
92
|
reasonError?: string;
|
|
57
93
|
/** Scope step: false = subagent-only (default), true = whole serving session. */
|
|
58
94
|
scopeServing: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* The width the session option chosen so far would grant.
|
|
97
|
+
*
|
|
98
|
+
* Held on the state rather than passed to the scope step, because a
|
|
99
|
+
* forwarded ask commits the two choices in different steps. Reset to
|
|
100
|
+
* `"proven"` on every return to the decision step, so a width the user
|
|
101
|
+
* backed out of cannot ride along with a later narrow choice.
|
|
102
|
+
*/
|
|
103
|
+
grantWidth: SessionGrantWidth;
|
|
59
104
|
}
|
|
60
105
|
|
|
61
106
|
/** An input event the reducer understands. */
|
|
@@ -81,6 +126,7 @@ export function initialPromptState(
|
|
|
81
126
|
hint: "",
|
|
82
127
|
reasonError: undefined,
|
|
83
128
|
scopeServing: false,
|
|
129
|
+
grantWidth: "proven",
|
|
84
130
|
};
|
|
85
131
|
}
|
|
86
132
|
|
|
@@ -116,12 +162,14 @@ function reduceDecisionStep(
|
|
|
116
162
|
case "nav":
|
|
117
163
|
return render({
|
|
118
164
|
...state,
|
|
119
|
-
highlightedKey: shiftKey(state.highlightedKey, event.direction),
|
|
165
|
+
highlightedKey: shiftKey(config, state.highlightedKey, event.direction),
|
|
120
166
|
armedKey: undefined,
|
|
121
167
|
hint: "",
|
|
122
168
|
});
|
|
123
169
|
case "hotkey":
|
|
124
|
-
return
|
|
170
|
+
return visibleOptionKeys(config).includes(event.key)
|
|
171
|
+
? pressHotkey(config, state, event.key)
|
|
172
|
+
: render(state);
|
|
125
173
|
case "confirm":
|
|
126
174
|
return commit(config, state, state.highlightedKey);
|
|
127
175
|
case "cancel":
|
|
@@ -170,23 +218,48 @@ function commit(
|
|
|
170
218
|
reasonError: undefined,
|
|
171
219
|
});
|
|
172
220
|
case "s":
|
|
221
|
+
case "b": {
|
|
222
|
+
// The two session options differ only in the width they grant; which
|
|
223
|
+
// scope they land on is the forwarded scope step's separate question.
|
|
224
|
+
const grantWidth: SessionGrantWidth = key === "b" ? "family" : "proven";
|
|
173
225
|
if (config.sessionScope) {
|
|
174
226
|
return render({
|
|
175
227
|
...state,
|
|
176
228
|
step: "scope",
|
|
177
|
-
highlightedKey:
|
|
229
|
+
highlightedKey: key,
|
|
178
230
|
armedKey: undefined,
|
|
179
231
|
hint: "",
|
|
180
232
|
scopeServing: false,
|
|
233
|
+
grantWidth,
|
|
181
234
|
});
|
|
182
235
|
}
|
|
183
236
|
return {
|
|
184
237
|
kind: "decision",
|
|
185
|
-
decision:
|
|
238
|
+
decision: sessionDecision("approved_for_session", grantWidth),
|
|
186
239
|
};
|
|
240
|
+
}
|
|
187
241
|
}
|
|
188
242
|
}
|
|
189
243
|
|
|
244
|
+
/**
|
|
245
|
+
* A session-granting decision, naming its width only when it is not the
|
|
246
|
+
* default.
|
|
247
|
+
*
|
|
248
|
+
* Absent means `"proven"` everywhere this value travels — the decision, the
|
|
249
|
+
* gate result, and the forwarded wire — so the narrow grant serializes
|
|
250
|
+
* exactly as it did before the option existed.
|
|
251
|
+
*/
|
|
252
|
+
function sessionDecision(
|
|
253
|
+
state: "approved_for_session" | "approved_for_serving_session",
|
|
254
|
+
width: SessionGrantWidth,
|
|
255
|
+
): UnattributedDecision {
|
|
256
|
+
return {
|
|
257
|
+
approved: true,
|
|
258
|
+
state,
|
|
259
|
+
...(width === "family" ? { sessionGrantWidth: width } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
190
263
|
function reduceReasonStep(
|
|
191
264
|
state: PromptViewState,
|
|
192
265
|
event: PromptEvent,
|
|
@@ -198,6 +271,7 @@ function reduceReasonStep(
|
|
|
198
271
|
armedKey: undefined,
|
|
199
272
|
hint: "",
|
|
200
273
|
reasonError: undefined,
|
|
274
|
+
grantWidth: "proven",
|
|
201
275
|
});
|
|
202
276
|
}
|
|
203
277
|
if (event.type === "submitReason") {
|
|
@@ -226,12 +300,12 @@ function reduceScopeStep(
|
|
|
226
300
|
case "confirm":
|
|
227
301
|
return {
|
|
228
302
|
kind: "decision",
|
|
229
|
-
decision:
|
|
230
|
-
|
|
231
|
-
state: state.scopeServing
|
|
303
|
+
decision: sessionDecision(
|
|
304
|
+
state.scopeServing
|
|
232
305
|
? "approved_for_serving_session"
|
|
233
306
|
: "approved_for_session",
|
|
234
|
-
|
|
307
|
+
state.grantWidth,
|
|
308
|
+
),
|
|
235
309
|
};
|
|
236
310
|
case "cancel":
|
|
237
311
|
return render({
|
|
@@ -239,17 +313,23 @@ function reduceScopeStep(
|
|
|
239
313
|
step: "decision",
|
|
240
314
|
armedKey: undefined,
|
|
241
315
|
hint: "",
|
|
316
|
+
grantWidth: "proven",
|
|
242
317
|
});
|
|
243
318
|
default:
|
|
244
319
|
return render(state);
|
|
245
320
|
}
|
|
246
321
|
}
|
|
247
322
|
|
|
248
|
-
function shiftKey(
|
|
249
|
-
|
|
323
|
+
function shiftKey(
|
|
324
|
+
config: PromptModelConfig,
|
|
325
|
+
current: PromptKey,
|
|
326
|
+
direction: "up" | "down",
|
|
327
|
+
): PromptKey {
|
|
328
|
+
const keys = visibleOptionKeys(config);
|
|
329
|
+
const index = keys.indexOf(current);
|
|
250
330
|
const delta = direction === "down" ? 1 : -1;
|
|
251
|
-
const next = (index + delta +
|
|
252
|
-
return
|
|
331
|
+
const next = (index + delta + keys.length) % keys.length;
|
|
332
|
+
return keys[next] ?? current;
|
|
253
333
|
}
|
|
254
334
|
|
|
255
335
|
function render(state: PromptViewState): PromptOutcome {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
import type {
|
|
@@ -11,6 +12,25 @@ import type { TerminalAuthorizer } from "./authorizer";
|
|
|
11
12
|
|
|
12
13
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
13
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The width a decision's session grant was recorded at, or `undefined` when it
|
|
17
|
+
* granted nothing for the session.
|
|
18
|
+
*
|
|
19
|
+
* Absent means "proven" everywhere else this value travels, but the review log
|
|
20
|
+
* is read rather than consumed, so a session-granting entry states its width
|
|
21
|
+
* explicitly instead of leaving the reader to know the default (#813).
|
|
22
|
+
*/
|
|
23
|
+
function recordedGrantWidth(
|
|
24
|
+
decision: PermissionPromptDecision,
|
|
25
|
+
): SessionGrantWidth | undefined {
|
|
26
|
+
const grantsForSession =
|
|
27
|
+
decision.state === "approved_for_session" ||
|
|
28
|
+
decision.state === "approved_for_serving_session";
|
|
29
|
+
return grantsForSession
|
|
30
|
+
? (decision.sessionGrantWidth ?? "proven")
|
|
31
|
+
: undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
14
34
|
/**
|
|
15
35
|
* Provenance of a forwarded ask: who is really asking, one hop below.
|
|
16
36
|
*
|
|
@@ -133,6 +153,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
133
153
|
: decision.state,
|
|
134
154
|
denialReason: decision.denialReason,
|
|
135
155
|
decidedBy: decision.decidedBy,
|
|
156
|
+
sessionGrantWidth: recordedGrantWidth(decision),
|
|
136
157
|
},
|
|
137
158
|
);
|
|
138
159
|
|
|
@@ -151,10 +172,14 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
151
172
|
resolution?: string;
|
|
152
173
|
denialReason?: string;
|
|
153
174
|
decidedBy?: DecisionSource;
|
|
175
|
+
sessionGrantWidth?: SessionGrantWidth;
|
|
154
176
|
},
|
|
155
177
|
): void {
|
|
156
178
|
this.deps.logger.review(event, {
|
|
157
179
|
...(details.decidedBy ? { decidedBy: details.decidedBy } : {}),
|
|
180
|
+
...(details.sessionGrantWidth
|
|
181
|
+
? { sessionGrantWidth: details.sessionGrantWidth }
|
|
182
|
+
: {}),
|
|
158
183
|
requestId: details.requestId,
|
|
159
184
|
source: details.source,
|
|
160
185
|
agentName: details.agentName,
|
|
@@ -228,9 +228,9 @@ export class GateRunner {
|
|
|
228
228
|
messages,
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
// 4. Determine whether session approval was granted
|
|
232
|
-
const
|
|
233
|
-
gateResult.action === "allow"
|
|
231
|
+
// 4. Determine whether session approval was granted, and at what width
|
|
232
|
+
const sessionGrant =
|
|
233
|
+
gateResult.action === "allow" ? gateResult.sessionGrant : undefined;
|
|
234
234
|
|
|
235
235
|
// 5. Emit decision event
|
|
236
236
|
this.emitDecision(
|
|
@@ -242,15 +242,17 @@ export class GateRunner {
|
|
|
242
242
|
gateResult.action === "allow" ? "allow" : "deny",
|
|
243
243
|
resolutionFor(gateResult.decidedBy, {
|
|
244
244
|
approved: gateResult.action === "allow",
|
|
245
|
-
forSession:
|
|
245
|
+
forSession: sessionGrant !== undefined,
|
|
246
246
|
}),
|
|
247
247
|
),
|
|
248
248
|
);
|
|
249
249
|
|
|
250
250
|
// 6. Record session approval — tell the store; it owns the per-pattern loop
|
|
251
|
-
//
|
|
252
|
-
if (
|
|
253
|
-
this.recorder.recordSessionApproval(
|
|
251
|
+
// A present grant already implies gateResult.action === "allow".
|
|
252
|
+
if (sessionGrant && descriptor.sessionApproval) {
|
|
253
|
+
this.recorder.recordSessionApproval(
|
|
254
|
+
descriptor.sessionApproval.atWidth(sessionGrant.width),
|
|
255
|
+
);
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
if (gateResult.action === "block") {
|
package/src/pattern-suggest.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type CapabilityDirection,
|
|
3
|
+
PATH_BEARING_TOOLS,
|
|
4
|
+
surfaceFamilyOf,
|
|
5
|
+
} from "./access-intent/path-surfaces";
|
|
6
|
+
import type { ApprovalGrant } from "./approval-grant";
|
|
2
7
|
import { prefix, stripBashCommentLines } from "./bash-arity";
|
|
3
8
|
|
|
4
9
|
/** The suggestion returned for a "Yes, for this session" dialog option. */
|
|
@@ -69,27 +74,90 @@ export interface ForwardedScopeLabels {
|
|
|
69
74
|
servingSessionLabel: string;
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
/**
|
|
78
|
+
* What an approval's grants cover, as one phrase.
|
|
79
|
+
*
|
|
80
|
+
* A single grant names its pattern; several name their count, because only the
|
|
81
|
+
* external-directory gate aggregates an ask over many paths and there is no
|
|
82
|
+
* pattern that describes them all. Requires at least one grant — an approval
|
|
83
|
+
* with none is never offered as a session option.
|
|
84
|
+
*/
|
|
85
|
+
export function describeGrantTarget(grants: readonly ApprovalGrant[]): string {
|
|
86
|
+
return grants.length === 1
|
|
87
|
+
? `"${grants[0].pattern}"`
|
|
88
|
+
: `${grants.length} paths`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The two session-option labels for an ask whose grants prove one direction. */
|
|
92
|
+
export interface DirectionalSessionLabels {
|
|
93
|
+
/** The proven-direction grant — the least-privilege default. */
|
|
94
|
+
sessionLabel: string;
|
|
95
|
+
/** The both-directions grant, offered beside it (#813). */
|
|
96
|
+
widenedLabel: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const DIRECTION_NOUNS: Record<CapabilityDirection, string> = {
|
|
100
|
+
read: "reads",
|
|
101
|
+
write: "writes",
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Label the two widths a directional ask's session grant can take.
|
|
106
|
+
*
|
|
107
|
+
* Both rows name the direction and the target, so the choice between them
|
|
108
|
+
* contrasts on a stated axis rather than on "wider" (#813).
|
|
109
|
+
*/
|
|
110
|
+
export function buildDirectionalSessionLabels(
|
|
111
|
+
direction: CapabilityDirection,
|
|
112
|
+
target: string,
|
|
113
|
+
): DirectionalSessionLabels {
|
|
114
|
+
return {
|
|
115
|
+
sessionLabel: `Yes, allow ${DIRECTION_NOUNS[direction]} to ${target} for this session`,
|
|
116
|
+
widenedLabel: `Yes, allow ${DIRECTION_NOUNS.read} and ${DIRECTION_NOUNS.write} to ${target} for this session`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
72
120
|
/**
|
|
73
121
|
* Build the two scope labels shown when a human grants a forwarded request
|
|
74
122
|
* "for this session."
|
|
75
123
|
*
|
|
76
124
|
* The subagent option names the requester (least privilege); the whole-session
|
|
77
|
-
* option restates
|
|
125
|
+
* option restates what is being granted session-wide — every grant, not the
|
|
126
|
+
* first of them (the residual #810 deferred here).
|
|
127
|
+
*
|
|
128
|
+
* The surface is named as the grants' shared **family**, never a directional
|
|
129
|
+
* member: these labels are built before the dialog runs, so a direction here
|
|
130
|
+
* could contradict a width the human chooses inside it. Grants that share no
|
|
131
|
+
* family name no surface at all.
|
|
78
132
|
*/
|
|
79
133
|
export function buildForwardedScopeLabels(
|
|
80
134
|
agentName: string | null,
|
|
81
|
-
|
|
82
|
-
pattern: string,
|
|
135
|
+
grants: readonly ApprovalGrant[],
|
|
83
136
|
): ForwardedScopeLabels {
|
|
84
137
|
const subagentLabel = agentName
|
|
85
138
|
? `This subagent ('${agentName}') only`
|
|
86
139
|
: "This subagent only";
|
|
140
|
+
const family = sharedSurfaceFamilyOf(grants);
|
|
141
|
+
const granted = family
|
|
142
|
+
? `${family} ${describeGrantTarget(grants)}`
|
|
143
|
+
: describeGrantTarget(grants);
|
|
87
144
|
return {
|
|
88
145
|
subagentLabel,
|
|
89
|
-
servingSessionLabel: `The whole session — allow ${
|
|
146
|
+
servingSessionLabel: `The whole session — allow ${granted} for parent and all subagents`,
|
|
90
147
|
};
|
|
91
148
|
}
|
|
92
149
|
|
|
150
|
+
/** The family every grant belongs to, or `null` when they disagree. */
|
|
151
|
+
function sharedSurfaceFamilyOf(
|
|
152
|
+
grants: readonly ApprovalGrant[],
|
|
153
|
+
): string | null {
|
|
154
|
+
if (grants.length === 0) return null;
|
|
155
|
+
const family = surfaceFamilyOf(grants[0].surface);
|
|
156
|
+
return grants.every((grant) => surfaceFamilyOf(grant.surface) === family)
|
|
157
|
+
? family
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
93
161
|
/** Surface-aware human-readable labels for the session-approval option. */
|
|
94
162
|
function buildLabel(pattern: string, surface: string): string {
|
|
95
163
|
switch (surface) {
|
package/src/permission-gate.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionGrantWidth } from "#src/approval-grant";
|
|
1
2
|
import type { DecisionSource } from "#src/authority/decision-source";
|
|
2
3
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
3
4
|
|
|
@@ -13,8 +14,15 @@ export type PermissionGateResult =
|
|
|
13
14
|
| {
|
|
14
15
|
action: "allow";
|
|
15
16
|
decidedBy: DecisionSource;
|
|
16
|
-
/**
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Set when the human granted the ask for the whole session, carrying the
|
|
19
|
+
* width to record it at.
|
|
20
|
+
*
|
|
21
|
+
* One field rather than a `forSession` flag beside a width: the width is
|
|
22
|
+
* meaningless without the grant, and two optional fields could represent
|
|
23
|
+
* a width for a grant that never happened.
|
|
24
|
+
*/
|
|
25
|
+
sessionGrant?: { width: SessionGrantWidth };
|
|
18
26
|
}
|
|
19
27
|
| { action: "block"; decidedBy: DecisionSource; reason: string };
|
|
20
28
|
|
|
@@ -112,7 +120,12 @@ export async function applyPermissionGate(
|
|
|
112
120
|
decision.state === "approved_for_session" &&
|
|
113
121
|
params.canGrantForSession
|
|
114
122
|
) {
|
|
115
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
action: "allow",
|
|
125
|
+
decidedBy,
|
|
126
|
+
// Absent means the width every producer chose before #813.
|
|
127
|
+
sessionGrant: { width: decision.sessionGrantWidth ?? "proven" },
|
|
128
|
+
};
|
|
116
129
|
}
|
|
117
130
|
return { action: "allow", decidedBy };
|
|
118
131
|
}
|
package/src/session-approval.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import {
|
|
2
|
+
type ApprovalGrant,
|
|
3
|
+
type SessionGrantWidth,
|
|
4
|
+
widenGrant,
|
|
5
|
+
} from "#src/approval-grant";
|
|
2
6
|
import type { ForwardedSessionApproval } from "#src/authority/permission-forwarding";
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -33,6 +37,20 @@ export class SessionApproval {
|
|
|
33
37
|
return this.grants.length > 0;
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* This approval as recorded at `width`.
|
|
42
|
+
*
|
|
43
|
+
* The runner holds a width and an approval and tells the approval to produce
|
|
44
|
+
* itself — it never inspects a grant's surface. Each grant is folded
|
|
45
|
+
* individually, so an approval whose patterns proved different directions
|
|
46
|
+
* keeps one grant per pattern (#810) at either width.
|
|
47
|
+
*/
|
|
48
|
+
atWidth(width: SessionGrantWidth): SessionApproval {
|
|
49
|
+
return width === "proven"
|
|
50
|
+
? this
|
|
51
|
+
: new SessionApproval(this.grants.map(widenGrant));
|
|
52
|
+
}
|
|
53
|
+
|
|
36
54
|
/**
|
|
37
55
|
* Plain data shape for relaying this approval on a forwarded request, so the
|
|
38
56
|
* serving node can record the same grants as a whole-session grant.
|