@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 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
@@ -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;
@@ -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": "30.1.0",
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 { TokenEffect } from "#src/access-intent/effect";
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 redirect destinations.
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 = ["_read", "_write"] as const;
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).
@@ -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
- this.recorder.recordSessionApproval(SessionApproval.forGrants(grants));
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 { ApprovalGrant } from "#src/approval-grant";
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 { buildForwardedScopeLabels } from "#src/pattern-suggest";
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
- * A forwarded ask carrying a session-approval suggestion offers the scope
69
- * choice (subagent vs whole session); any other ask keeps its single
70
- * "for this session" option (custom label when the gate supplied one).
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 grant = details.sessionApproval?.grants[0];
76
- if (details.forwarding && grant) {
77
- return {
78
- sessionScope: buildForwardedScopeLabels(
79
- details.forwarding.requesterAgentName,
80
- grant.surface,
81
- grant.pattern,
82
- ),
83
- };
84
- }
85
- return details.sessionLabel
86
- ? { sessionLabel: details.sessionLabel }
87
- : undefined;
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
- ] as const;
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
- state:
146
- scope === options.sessionScope.servingSessionLabel
147
- ? "approved_for_serving_session"
148
- : "approved_for_session",
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 = OPTION_ORDER.find((option) => matchesKey(data, option));
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 OPTION_ORDER) {
358
- const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
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
- /** The four decision hotkeys, in display order. */
19
- export type PromptKey = "y" | "s" | "n" | "r";
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 pressHotkey(config, state, event.key);
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: "s",
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: { approved: true, state: "approved_for_session" },
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
- approved: true,
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(current: PromptKey, direction: "up" | "down"): PromptKey {
249
- const index = OPTION_ORDER.indexOf(current);
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 + OPTION_ORDER.length) % OPTION_ORDER.length;
252
- return OPTION_ORDER[next] ?? current;
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 hasSessionApproval =
233
- gateResult.action === "allow" && gateResult.forSession === true;
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: hasSessionApproval,
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
- // hasSessionApproval already implies gateResult.action === "allow"
252
- if (hasSessionApproval && descriptor.sessionApproval) {
253
- this.recorder.recordSessionApproval(descriptor.sessionApproval);
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") {
@@ -1,4 +1,9 @@
1
- import { PATH_BEARING_TOOLS } from "./access-intent/path-surfaces";
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 the surface + pattern being granted session-wide.
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
- surface: string,
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 ${surface} "${pattern}" for parent and all subagents`,
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) {
@@ -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
- /** Set when the human granted the ask for the whole session. */
17
- forSession?: true;
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 { action: "allow", decidedBy, forSession: true };
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
  }
@@ -1,4 +1,8 @@
1
- import type { ApprovalGrant } from "#src/approval-grant";
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.