@gotgenes/pi-permission-system 25.2.2 → 25.4.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/config/config.example.json +3 -0
  4. package/dist/public.d.ts +162 -41
  5. package/docs/configuration.md +17 -2
  6. package/docs/cross-extension-api.md +14 -9
  7. package/package.json +1 -1
  8. package/schemas/permissions.schema.json +16 -0
  9. package/src/access-intent/bash/command-enumeration.ts +45 -117
  10. package/src/access-intent/bash/wrapper-analysis.ts +335 -0
  11. package/src/authority/approval-escalator.ts +26 -1
  12. package/src/authority/forwarded-request-server.ts +5 -14
  13. package/src/authority/local-user-authorizer.ts +2 -3
  14. package/src/authority/permission-prompt-component.ts +87 -47
  15. package/src/authority/permission-prompter.ts +9 -0
  16. package/src/config-loader.ts +2 -0
  17. package/src/config-schema.ts +14 -0
  18. package/src/extension-config.ts +10 -0
  19. package/src/handlers/gates/bash-command.ts +7 -2
  20. package/src/handlers/gates/bash-external-directory.ts +11 -6
  21. package/src/handlers/gates/bash-path.ts +10 -6
  22. package/src/handlers/gates/descriptor.ts +10 -1
  23. package/src/handlers/gates/external-directory.ts +13 -8
  24. package/src/handlers/gates/helpers.ts +6 -7
  25. package/src/handlers/gates/path.ts +11 -14
  26. package/src/handlers/gates/runner.ts +40 -19
  27. package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
  28. package/src/handlers/gates/skill-input.ts +5 -2
  29. package/src/handlers/gates/skill-read.ts +6 -6
  30. package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
  31. package/src/handlers/gates/tool.ts +10 -5
  32. package/src/handlers/tool-call-boundary.ts +30 -7
  33. package/src/index.ts +2 -0
  34. package/src/permission-events.ts +6 -0
  35. package/src/permission-prompts.ts +4 -72
  36. package/src/permission-request-id.ts +17 -0
  37. package/src/presentation/dialog-renderer.ts +404 -0
  38. package/src/presentation/forwarded-ask-payload.ts +45 -0
  39. package/src/presentation/legacy-message.ts +117 -0
  40. package/src/presentation/line-fitting.ts +27 -0
  41. package/src/presentation/path-ask-payload.ts +128 -0
  42. package/src/presentation/prompt-payload.ts +137 -0
  43. package/src/presentation/skill-ask-payload.ts +50 -0
  44. package/src/presentation/tool-ask-payload.ts +104 -0
  45. package/src/tool-preview-formatter.ts +1 -1
  46. package/src/types.ts +6 -0
  47. package/src/handlers/gates/external-directory-messages.ts +0 -28
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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
+ ## [25.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.3.0...pi-permission-system-v25.4.0) (2026-08-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * **pi-permission-system:** adopt the requester's request id as the forwarded request id ([3f8d3fd](https://github.com/gotgenes/pi-packages/commit/3f8d3fd69940211c6a92b99f8eae0396936afedf)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
14
+ * **pi-permission-system:** carry the request id on permissions:decision ([95c001c](https://github.com/gotgenes/pi-packages/commit/95c001cf6b35c68991143826d8621b1e94fc0b32)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
15
+ * **pi-permission-system:** give the gate-error review entry a request id ([172bc68](https://github.com/gotgenes/pi-packages/commit/172bc689ea0f18c88672a22c0b5e84c0f3576fd1)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
16
+ * **pi-permission-system:** mint a permission request id at request creation ([336842d](https://github.com/gotgenes/pi-packages/commit/336842de6c34e96bd90ed411eaca9649c4b9fac2)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
17
+
18
+
19
+ ### Documentation
20
+
21
+ * **pi-permission-system:** record the minted request id in the API and architecture docs ([84f5856](https://github.com/gotgenes/pi-packages/commit/84f5856868a548c9454ef3e23ca75c608ce3f308)), closes [#752](https://github.com/gotgenes/pi-packages/issues/752)
22
+
23
+ ## [25.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.2...pi-permission-system-v25.3.0) (2026-08-15)
24
+
25
+
26
+ ### Features
27
+
28
+ * **pi-permission-system:** add promptMaxRows and promptFieldMaxWidth ([8dbca30](https://github.com/gotgenes/pi-packages/commit/8dbca303f55769222f6a829d3fc7a1d7461cd334)), closes [#710](https://github.com/gotgenes/pi-packages/issues/710)
29
+ * **pi-permission-system:** expand the permission dialog to the complete request on Ctrl+O ([b741513](https://github.com/gotgenes/pi-packages/commit/b7415137236d6245a41f95255d17425127739a97)), closes [#710](https://github.com/gotgenes/pi-packages/issues/710)
30
+ * **pi-permission-system:** render permission prompts as bounded, aligned fact lines ([2bd18c5](https://github.com/gotgenes/pi-packages/commit/2bd18c57003bdfbd0f95ad2c309e5387031164a0))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * **pi-permission-system:** distinguish the redundancy rule from elision ([5b26a46](https://github.com/gotgenes/pi-packages/commit/5b26a466191e5cc4395c2cebba22732c3d867bff)), closes [#710](https://github.com/gotgenes/pi-packages/issues/710)
36
+ * **pi-permission-system:** document the bounded permission dialog ([68fcf58](https://github.com/gotgenes/pi-packages/commit/68fcf583f740cbcd8c5224db96fff85a74a2b0f2)), closes [#710](https://github.com/gotgenes/pi-packages/issues/710)
37
+
8
38
  ## [25.2.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v25.2.1...pi-permission-system-v25.2.2) (2026-08-15)
9
39
 
10
40
 
package/README.md CHANGED
@@ -66,7 +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
- Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) keeps working while the dialog is open, so you can expand a truncated tool preview before deciding.
69
+ 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
70
  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
71
 
72
72
  The `path` surface is a cross-cutting gate that applies to **all** file access — Pi tools, bash commands, MCP calls, and extension tools alike.
@@ -8,6 +8,9 @@
8
8
 
9
9
  "forwardingTimeoutMs": 600000,
10
10
 
11
+ "promptMaxRows": 24,
12
+ "promptFieldMaxWidth": 400,
13
+
11
14
  "toolInputPreviewMaxLength": 400,
12
15
  "toolTextSummaryMaxLength": 120,
13
16
 
package/dist/public.d.ts CHANGED
@@ -1,5 +1,153 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
4
+ /** A permission decision. */
5
+ type PermissionState = z.infer<typeof permissionStateSchema>;
6
+
7
+ /**
8
+ * Provenance of a rule — which source contributed it.
9
+ *
10
+ * Config scopes: "global", "project", "agent", "project-agent".
11
+ * Synthesized: "builtin" (universal default / evaluate() fallback),
12
+ * "baseline" (conditional MCP metadata auto-allow).
13
+ * Runtime: "session" (session approvals).
14
+ * Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
15
+ * "fail-closed" (composition-stage allow→ask floor when an
16
+ * invalid non-global config scope is detected).
17
+ */
18
+ type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo" | "fail-closed";
19
+
20
+ /**
21
+ * Execution context of a bash command nested inside a substitution or subshell.
22
+ * Absent for current-shell (top-level) commands.
23
+ */
24
+ type BashCommandContext = "command_substitution" | "process_substitution" | "subshell";
25
+ interface PermissionCheckResult {
26
+ toolName: string;
27
+ state: PermissionState;
28
+ /** Custom denial reason from a deny-with-reason pattern, when present. */
29
+ reason?: string;
30
+ matchedPattern?: string;
31
+ command?: string;
32
+ target?: string;
33
+ source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
34
+ /** Which source contributed the winning rule. */
35
+ origin: RuleOrigin;
36
+ /**
37
+ * Execution context of the offending nested command, when the winning bash
38
+ * unit came from a substitution or subshell. Absent for current-shell
39
+ * (top-level) commands.
40
+ */
41
+ commandContext?: BashCommandContext;
42
+ /**
43
+ * The command the winning bash unit actually runs, when it is a wrapper whose
44
+ * inner command differs from the unit text (#713). Display-only: the gate
45
+ * still decides on `command`, so this never widens or narrows a decision.
46
+ */
47
+ executedUnit?: string;
48
+ }
49
+
50
+ /**
51
+ * The complete, structured description of a permission ask (ADR 0011 §2).
52
+ *
53
+ * A gate emits one of these instead of a sentence. It is complete by contract:
54
+ * it never truncates and never decides what a human will see. Every consumer is
55
+ * a renderer over it, eliding under its own budget — so elision is a property
56
+ * of a render, never of the payload.
57
+ */
58
+ interface PromptPayload {
59
+ readonly kind: PromptPayloadKind;
60
+ readonly request: PromptRequestFacts;
61
+ /** Complete; each renderer elides to fit its own budget. */
62
+ readonly evidence: readonly PromptEvidence[];
63
+ /** Supplied by registered annotators; always marked as model-generated. */
64
+ readonly annotations: readonly PromptAnnotation[];
65
+ }
66
+ /**
67
+ * Which ask this payload describes — the renderers' dispatch discriminant.
68
+ *
69
+ * Present because the ask shapes are not separable by surface alone: a tool
70
+ * external-directory ask and a bash one share the `external_directory` surface,
71
+ * and the `path` gate and the per-tool gate differ only in wording. It mirrors
72
+ * `DenialContext`'s discriminated union, the shape ADR 0011 §7 names as already
73
+ * correct, and gives every renderer an exhaustive switch rather than a set of
74
+ * string comparisons a new variant sails past.
75
+ */
76
+ type PromptPayloadKind = "bash" | "mcp" | "tool" | "path" | "external_directory" | "bash_external_directory" | "skill" | "skill_read" | "forwarded";
77
+ /**
78
+ * The invariant core (ADR 0011 §3): the facts visible in every render, that no
79
+ * renderer's budget may elide.
80
+ *
81
+ * Named for what it holds — the permission request's own facts, matching the
82
+ * package's `PermissionRequest` / `ForwardedPermissionRequest` vocabulary —
83
+ * rather than for its contract, which this comment states instead.
84
+ */
85
+ interface PromptRequestFacts {
86
+ /** Who is asking, and whether the ask arrived from a subagent. */
87
+ readonly requester: PromptRequester;
88
+ /** The gate surface the rule fired on. */
89
+ readonly surface: string;
90
+ /** The gated tool name; `null` when the ask is not tool-shaped. */
91
+ readonly toolName: string | null;
92
+ /**
93
+ * The invoked tool name when a shell alias re-exposes bash under another
94
+ * name (#574) — "gated as bash, invoked as exec_command" is two facts.
95
+ * `null` when it adds nothing.
96
+ */
97
+ readonly invokedToolName: string | null;
98
+ /** The decision-relevant value: the command, path, MCP target, or skill name. */
99
+ readonly value: string;
100
+ /** The matched rule, including a sentinel such as `<indirection-bash-wrapper>`. */
101
+ readonly matchedPattern: string | null;
102
+ /**
103
+ * Where the offending bash unit runs, when it came from a substitution or a
104
+ * subshell. A fact rather than a rendered clause: it is what makes the
105
+ * matched rule intelligible, and how it reads is the renderer's choice.
106
+ */
107
+ readonly commandContext: BashCommandContext | null;
108
+ /**
109
+ * For bash, the unit that will actually run — including inside an unstrippable
110
+ * wrapper (#713). `null` when it adds nothing over {@link value}.
111
+ */
112
+ readonly executedUnit: string | null;
113
+ }
114
+ /** Who is asking, one hop below when the ask was forwarded. */
115
+ interface PromptRequester {
116
+ readonly agentName: string | null;
117
+ readonly forwarded: boolean;
118
+ /** The requesting session, for a forwarded ask; `null` for a local one. */
119
+ readonly sessionId: string | null;
120
+ }
121
+ /**
122
+ * One piece of decision evidence.
123
+ *
124
+ * Complete on the payload; each renderer elides entries and orders them under
125
+ * its own budget (ADR 0011 §4).
126
+ */
127
+ interface PromptEvidence {
128
+ readonly label: string;
129
+ readonly text: string;
130
+ /**
131
+ * A secondary fact bound to this entry that a renderer may show alongside
132
+ * {@link text} or elide independently — a path's symlink-resolved alias, for
133
+ * instance. Bound to the entry rather than listed as a second one so an
134
+ * elision cannot separate the two.
135
+ */
136
+ readonly detail: string | null;
137
+ }
138
+ /**
139
+ * A model-generated advisory (ADR 0011 §8).
140
+ *
141
+ * The slot owns the attribution and the model-generated marking, so marking is
142
+ * a property of the payload rather than a discipline each annotator must
143
+ * remember. Structurally separate from any verdict: an annotation cannot allow,
144
+ * deny, defer, or suppress.
145
+ */
146
+ interface PromptAnnotation {
147
+ readonly source: string;
148
+ readonly text: string;
149
+ }
150
+
3
151
  /** Emitted at `session_start`, after the service is published. */
4
152
  declare const PERMISSIONS_READY_CHANNEL = "permissions:ready";
5
153
  /** Emitted when a permission request is committed to the active UI prompt path. */
@@ -59,6 +207,12 @@ interface PermissionUiPromptEvent {
59
207
  type PermissionDecisionResolution = "policy_allow" | "policy_deny" | "session_approved" | "infrastructure_auto_allowed" | "user_approved" | "user_approved_for_session" | "user_denied" | "auto_approved" | "confirmation_unavailable";
60
208
  /** Payload emitted on `permissions:decision`. */
61
209
  interface PermissionDecisionEvent {
210
+ /**
211
+ * Identifies the permission request this decision resolves, minted when the
212
+ * request was created. Distinct from the host's tool-call id: one tool call
213
+ * runs several gates and so raises several requests.
214
+ */
215
+ requestId: string;
62
216
  /** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
63
217
  surface: string;
64
218
  /** The value that was evaluated (command, tool name, skill name, path). */
@@ -75,47 +229,6 @@ interface PermissionDecisionEvent {
75
229
  matchedPattern: string | null;
76
230
  }
77
231
 
78
- declare const permissionStateSchema: z.ZodUnion<readonly [z.ZodLiteral<"allow">, z.ZodLiteral<"deny">, z.ZodLiteral<"ask">]>;
79
- /** A permission decision. */
80
- type PermissionState = z.infer<typeof permissionStateSchema>;
81
-
82
- /**
83
- * Provenance of a rule — which source contributed it.
84
- *
85
- * Config scopes: "global", "project", "agent", "project-agent".
86
- * Synthesized: "builtin" (universal default / evaluate() fallback),
87
- * "baseline" (conditional MCP metadata auto-allow).
88
- * Runtime: "session" (session approvals).
89
- * Rewrite: "yolo" (composition-stage ask→allow rewrite under yolo mode),
90
- * "fail-closed" (composition-stage allow→ask floor when an
91
- * invalid non-global config scope is detected).
92
- */
93
- type RuleOrigin = "global" | "project" | "agent" | "project-agent" | "builtin" | "baseline" | "session" | "yolo" | "fail-closed";
94
-
95
- /**
96
- * Execution context of a bash command nested inside a substitution or subshell.
97
- * Absent for current-shell (top-level) commands.
98
- */
99
- type BashCommandContext = "command_substitution" | "process_substitution" | "subshell";
100
- interface PermissionCheckResult {
101
- toolName: string;
102
- state: PermissionState;
103
- /** Custom denial reason from a deny-with-reason pattern, when present. */
104
- reason?: string;
105
- matchedPattern?: string;
106
- command?: string;
107
- target?: string;
108
- source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
109
- /** Which source contributed the winning rule. */
110
- origin: RuleOrigin;
111
- /**
112
- * Execution context of the offending nested command, when the winning bash
113
- * unit came from a substitution or subshell. Absent for current-shell
114
- * (top-level) commands.
115
- */
116
- commandContext?: BashCommandContext;
117
- }
118
-
119
232
  /**
120
233
  * The child's session-approval suggestion, relayed to the serving node so a
121
234
  * human who grants "the whole session" records the same pattern the child
@@ -169,6 +282,14 @@ interface PromptPermissionDetails {
169
282
  source: PermissionReviewSource;
170
283
  agentName: string | null;
171
284
  message: string;
285
+ /**
286
+ * The complete structured description of this ask (ADR 0011 §2).
287
+ *
288
+ * Required: every ask carries one, and the type is what guarantees it rather
289
+ * than a convention each gate has to remember. `message` is a render over it
290
+ * for the duration of the transition, so the two cannot disagree.
291
+ */
292
+ payload: PromptPayload;
172
293
  toolCallId?: string;
173
294
  toolName?: string;
174
295
  skillName?: string;
@@ -36,7 +36,7 @@ See [migration/0644-project-trust-gating.md](migration/0644-project-trust-gating
36
36
  4. Project agent frontmatter
37
37
 
38
38
  The `permission` object uses deep-shallow merge: string-vs-string replaces; both-object shallow-merges pattern maps; string-vs-object the override wins entirely.
39
- Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`) use simple replacement.
39
+ Scalar fields (`debugLog`, `permissionReviewLog`, `yoloMode`, `doublePressToConfirm`, `forwardingTimeoutMs`, `promptMaxRows`, `promptFieldMaxWidth`) use simple replacement.
40
40
 
41
41
  **Invalid higher-precedence scope fails closed.**
42
42
  If a non-global scope (project config, global agent frontmatter, or project agent frontmatter) is present but fails to load or validate, it no longer contributes an empty scope that silently inherits the lower scope's rules.
@@ -106,6 +106,8 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
106
106
  | `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
107
107
  | `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
108
108
  | `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose in-process parent is not draining its inbox gives up in ~2 s regardless. |
109
+ | `promptMaxRows` | `24` | Max rows a permission prompt renders before eliding its evidence. The request's own facts are never elided by this budget; `Ctrl+O` expands the prompt to the complete request. |
110
+ | `promptFieldMaxWidth` | `400` | Max characters of any one field shown in a permission prompt. This is what bounds a single long field (a here-string command, say) that would otherwise fill the prompt through wrapping. |
109
111
  | `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
110
112
  | `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
111
113
  | `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME`/`${HOME}` expansion and wildcard patterns (`*`, `?`). |
@@ -129,10 +131,23 @@ Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option
129
131
  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.
130
132
  Set `doublePressToConfirm` to `false` to commit on the first press.
131
133
 
132
- Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) stays live while the dialog is open, so you can expand a truncated tool preview before deciding.
134
+ Pi's tool-expansion binding (`app.tools.expand`, `Ctrl+O` by default) stays live while the dialog is open.
135
+ It expands both the prompt itself — to the complete request, unbounded by `promptMaxRows` and `promptFieldMaxWidth` — and the host's pending tool call, so one keystroke shows you everything before you decide.
133
136
  It only toggles the display — it never resolves, commits, or arms the pending decision.
134
137
  While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
135
138
 
139
+ ### What a prompt shows
140
+
141
+ The prompt renders one fact per line, with the requesting agent (and, for a forwarded subagent ask, its session), the tool, the gate surface, the matched rule, the decision-relevant value, and — for a wrapper such as `xargs` — the command that will actually run.
142
+ Those facts are always present: a budget may shorten a long one, never drop it.
143
+ A fact the line above already states is not repeated — a bash ask shows `tool : bash` without a second `surface : bash` line, and a path ask's `path :` line names the surface itself.
144
+
145
+ Everything else is evidence — the full command a gated sub-command came from, the working directory a path escaped, the tool-input preview — and it is what gives way when the render does not fit.
146
+ A shortened field or a dropped entry is marked with an ellipsis, and `Ctrl+O` shows the complete request.
147
+ Raise `promptMaxRows` to see more evidence inline; raise `promptFieldMaxWidth` to see more of a long command.
148
+
149
+ Non-TUI contexts render the same facts under the same budget, without the colour or the expansion.
150
+
136
151
  Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
137
152
 
138
153
  ### `piInfrastructureReadPaths` patterns
@@ -336,15 +336,15 @@ pi.events.on("permissions:ui_prompt", (raw) => {
336
336
 
337
337
  ### Payload Fields
338
338
 
339
- | Field | Type | Description |
340
- | ------------ | -------------------------------- | ---------------------------------------------------------------------- |
341
- | `requestId` | `string` | Unique ID for the permission request being prompted |
342
- | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
343
- | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
344
- | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
345
- | `agentName` | `string \| null` | Active/requesting agent name, when known |
346
- | `message` | `string` | Message displayed in the permission prompt |
347
- | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
339
+ | Field | Type | Description |
340
+ | ------------ | -------------------------------- | ----------------------------------------------------------------------- |
341
+ | `requestId` | `string` | Id of the permission request being prompted, minted when it was created |
342
+ | `source` | `PermissionUiPromptSource` | Prompt origin: `"tool_call"`, `"skill_input"`, or `"skill_read"` |
343
+ | `surface` | `string \| null` | Normalized display surface (e.g. `"bash"`, `"skill"`), when known |
344
+ | `value` | `string \| null` | Normalized display value (command, path, skill name, etc.), when known |
345
+ | `agentName` | `string \| null` | Active/requesting agent name, when known |
346
+ | `message` | `string` | Message displayed in the permission prompt |
347
+ | `forwarding` | `ForwardedPromptContext \| null` | Forwarding context, or `null` for a direct prompt |
348
348
 
349
349
  Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its original `source` and is identified by a non-null `forwarding` field, not by a dedicated source value.
350
350
 
@@ -367,6 +367,10 @@ The stability guarantee is additive, so any can be reintroduced in a later minor
367
367
  Every permission gate resolution emits a `permissions:decision` event, regardless of outcome.
368
368
  This is useful for dashboards, telemetry, or audit overlays.
369
369
 
370
+ The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
371
+ It identifies a permission *request*, not a tool call: one tool call runs several gates and so raises several requests, each with its own id.
372
+ Use the review log's `toolCallId` to join back to the Pi transcript.
373
+
370
374
  ```typescript
371
375
  pi.events.on("permissions:decision", (raw) => {
372
376
  const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
@@ -379,6 +383,7 @@ pi.events.on("permissions:decision", (raw) => {
379
383
 
380
384
  | Field | Type | Description |
381
385
  | ---------------- | ------------------- | ----------------------------------------------------------------------------------------- |
386
+ | `requestId` | `string` | Id of the permission request this decision resolves |
382
387
  | `surface` | `string` | Permission surface (`"bash"`, `"read"`, `"mcp"`, `"skill"`, `"external_directory"`, etc.) |
383
388
  | `value` | `string` | Value evaluated (command, tool name, skill name, path) |
384
389
  | `result` | `"allow" \| "deny"` | Final outcome |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "25.2.2",
3
+ "version": "25.4.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,6 +39,22 @@
39
39
  "minimum": 1,
40
40
  "maximum": 9007199254740991
41
41
  },
42
+ "promptMaxRows": {
43
+ "description": "Maximum rows a permission prompt renders before eliding its evidence. Omit to use the default (24).",
44
+ "markdownDescription": "Maximum rows a permission prompt renders before eliding its evidence.\n\nOmit to use the default (24). The request's own facts — the requesting agent, the tool, the matched rule, the decision-relevant value — are never elided by this budget; what gives way is the supporting evidence, and `Ctrl+O` expands the prompt to the complete request.",
45
+ "default": 24,
46
+ "type": "integer",
47
+ "minimum": 1,
48
+ "maximum": 9007199254740991
49
+ },
50
+ "promptFieldMaxWidth": {
51
+ "description": "Maximum characters of any one field shown in a permission prompt. Omit to use the default (400).",
52
+ "markdownDescription": "Maximum characters of any one field shown in a permission prompt.\n\nOmit to use the default (400). This is what bounds a single pathological field — a long here-string command, say — that would otherwise fill the prompt through wrapping. A shortened field is marked with an ellipsis, and `Ctrl+O` shows it in full.",
53
+ "default": 400,
54
+ "type": "integer",
55
+ "minimum": 1,
56
+ "maximum": 9007199254740991
57
+ },
42
58
  "toolInputPreviewMaxLength": {
43
59
  "description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
44
60
  "markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
@@ -3,8 +3,16 @@ import {
3
3
  forEachNestedExecution,
4
4
  } from "#src/access-intent/bash/nested-execution";
5
5
  import type { TSNode } from "#src/access-intent/bash/parser";
6
+ import {
7
+ type CommandWord,
8
+ classifyWrapperWords,
9
+ executedUnitOf,
10
+ type WrapperKind,
11
+ } from "#src/access-intent/bash/wrapper-analysis";
6
12
  import type { BashCommandContext } from "#src/types";
7
13
 
14
+ export type { WrapperKind } from "#src/access-intent/bash/wrapper-analysis";
15
+
8
16
  // ── Command type ─────────────────────────────────────────────────────────────
9
17
 
10
18
  /**
@@ -15,16 +23,6 @@ import type { BashCommandContext } from "#src/types";
15
23
  * The type is the stable extension point: #306 adds an execution `context`,
16
24
  * #307 adds per-command path candidates and an effective working directory.
17
25
  */
18
- /**
19
- * Why a command unit's decision is floored to at least `ask`.
20
- * `"opaque-payload"` — an inline-shell payload (`bash -c`/`eval`) whose inner
21
- * program is not re-parsed (#481).
22
- * `"indirection"` — a prefix/exec wrapper (`sudo`/`env`/`xargs`/`find -exec`/…)
23
- * whose inner command is a visible argument but is not gated on its own (#490).
24
- * The kind selects the audit sentinel; both floor identically.
25
- */
26
- export type WrapperKind = "opaque-payload" | "indirection";
27
-
28
26
  export interface BashCommand {
29
27
  readonly text: string;
30
28
  /**
@@ -38,6 +36,13 @@ export interface BashCommand {
38
36
  * Absent for an ordinary command.
39
37
  */
40
38
  readonly wrapperKind?: WrapperKind;
39
+ /**
40
+ * The command this wrapper unit actually runs (#713). Display-only — it is
41
+ * never gated on its own, so the wrapper floor still applies. Absent for an
42
+ * ordinary command, and for a wrapper whose inner command cannot be
43
+ * established.
44
+ */
45
+ readonly executedUnit?: string;
41
46
  }
42
47
 
43
48
  // ── Command enumeration ──────────────────────────────────────────────────────
@@ -105,9 +110,7 @@ function collectCommandsInto(
105
110
  if (COMMAND_ENUM_SKIP.has(node.type)) return;
106
111
 
107
112
  if (node.type === "command") {
108
- out.push(
109
- makeUnit(commandUnitText(node), context, classifyWrapperCommand(node)),
110
- );
113
+ out.push(makeCommandUnit(node, context));
111
114
  // A command's text already contains any substitution; descend its subtree
112
115
  // to ALSO emit the inner commands of command/process substitutions.
113
116
  collectHostedCommands(node, out);
@@ -141,125 +144,50 @@ function makeUnit(
141
144
  text: string,
142
145
  context: BashCommandContext | undefined,
143
146
  wrapperKind?: WrapperKind,
147
+ executedUnit?: string,
144
148
  ): BashCommand {
145
149
  const unit: BashCommand = context ? { text, context } : { text };
146
- return wrapperKind ? { ...unit, wrapperKind } : unit;
150
+ const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
151
+ return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
147
152
  }
148
153
 
149
154
  /**
150
- * Shell command names whose `-c` flag introduces an opaque inline program.
151
- */
152
- const SHELL_WRAPPER_NAMES = new Set(["bash", "sh", "dash", "zsh", "ksh"]);
153
-
154
- /**
155
- * Indirection wrappers that always invoke a following command, so the wrapper
156
- * (not the inner command) is what a bash rule matches. Floored by command-name
157
- * basename alone. Extend this set to cover another always-invoking wrapper.
158
- */
159
- const INDIRECTION_WRAPPER_NAMES = new Set([
160
- "sudo",
161
- "env",
162
- "xargs",
163
- "time",
164
- "nohup",
165
- "timeout",
166
- "nice",
167
- // Exec-capable rewrites and prefix wrappers surveyed in #575: parallelizers
168
- // (parallel/rust-parallel/rush), a sudo rewrite (doas), and prefix wrappers
169
- // (setsid/stdbuf/watch/flock) that all always invoke a following command.
170
- "parallel",
171
- "rust-parallel",
172
- "rush",
173
- "doas",
174
- "setsid",
175
- "stdbuf",
176
- "watch",
177
- "flock",
178
- ]);
179
-
180
- /**
181
- * Search tools that invoke a command per result only when an exec flag is
182
- * present; a bare search runs no subcommand. Floored only when an argument
183
- * exactly matches one of the tool's exec flags. Extend by adding a tool with
184
- * its exec-flag set.
185
- */
186
- const EXEC_CONDITIONAL_WRAPPERS = new Map<string, ReadonlySet<string>>([
187
- ["find", new Set(["-exec", "-execdir", "-ok", "-okdir"])],
188
- ["fd", new Set(["-x", "--exec", "-X", "--exec-batch"])],
189
- ]);
190
-
191
- /**
192
- * Classify a `command` node as a floored wrapper, or `undefined` for an
193
- * ordinary command. Reads only the node's own named children (a shallow walk),
194
- * skipping any leading `variable_assignment` prefix, and matches the command
195
- * name on its basename (so `/bin/bash -c …` counts).
196
- *
197
- * `"opaque-payload"`: `eval`, or a shell (`bash`/`sh`/`dash`/`zsh`/`ksh`) with a
198
- * `-c` short-flag cluster (`-c`, `-ec`, `-xc`) — the inner program is a quoted
199
- * argument the enumerator does not re-parse (#481).
200
- *
201
- * `"indirection"`: an always-invoking prefix/exec wrapper
202
- * (`INDIRECTION_WRAPPER_NAMES`), or a search tool (`EXEC_CONDITIONAL_WRAPPERS`,
203
- * `find`/`fd`) carrying a per-result exec flag — the inner command is a visible
204
- * argument that a `<cmd> *` rule would otherwise never match (#490). A bare
205
- * `find`/`fd` search runs no subcommand and is not flagged.
155
+ * Build the unit for a `command` node, reading its words once to answer both
156
+ * wrapper questions: whether the unit is floored, and what it actually runs.
206
157
  */
207
- function classifyWrapperCommand(node: TSNode): WrapperKind | undefined {
208
- const { commandName, args } = readWrapperCommand(node);
209
- if (commandName === undefined) return undefined;
210
- if (commandName === "eval") return "opaque-payload";
211
- if (SHELL_WRAPPER_NAMES.has(commandName) && hasShortFlagC(args)) {
212
- return "opaque-payload";
213
- }
214
- if (INDIRECTION_WRAPPER_NAMES.has(commandName)) return "indirection";
215
- const execFlags = EXEC_CONDITIONAL_WRAPPERS.get(commandName);
216
- if (execFlags && args.some((arg) => execFlags.has(arg))) return "indirection";
217
- return undefined;
158
+ function makeCommandUnit(
159
+ node: TSNode,
160
+ context: BashCommandContext | undefined,
161
+ ): BashCommand {
162
+ const text = commandUnitText(node);
163
+ const words = readCommandWords(node);
164
+ return makeUnit(
165
+ text,
166
+ context,
167
+ classifyWrapperWords(words),
168
+ executedUnitOf(text, words) ?? undefined,
169
+ );
218
170
  }
219
171
 
220
172
  /**
221
- * A `command` node's name basename and its argument texts, skipping any leading
222
- * `variable_assignment` prefix (matching `commandUnitText`). `commandName` is
223
- * `undefined` for a pure assignment with no `command_name`.
173
+ * A `command` node's words its `command_name` followed by its arguments — each
174
+ * carrying its offset into the unit text `commandUnitText` produces.
175
+ *
176
+ * A leading `variable_assignment` prefix is skipped (matching
177
+ * `commandUnitText`), so offsets are relative to the `command_name`. An empty
178
+ * list means a pure assignment with no `command_name`.
224
179
  */
225
- function readWrapperCommand(node: TSNode): {
226
- commandName: string | undefined;
227
- args: string[];
228
- } {
229
- let commandName: string | undefined;
230
- const args: string[] = [];
180
+ function readCommandWords(node: TSNode): CommandWord[] {
181
+ const words: CommandWord[] = [];
182
+ let unitStart: number | undefined;
231
183
  for (let i = 0; i < node.childCount; i++) {
232
184
  const child = node.child(i);
233
185
  if (!child?.isNamed) continue;
234
186
  if (child.type === "variable_assignment") continue;
235
- if (commandName === undefined) {
236
- commandName = basename(child.text);
237
- continue;
238
- }
239
- args.push(child.text);
187
+ unitStart ??= child.startIndex;
188
+ words.push({ text: child.text, offset: child.startIndex - unitStart });
240
189
  }
241
- return { commandName, args };
242
- }
243
-
244
- /**
245
- * True when an argument list has a short-flag cluster containing `c` before any
246
- * `--` end-of-options marker (`-c`, `-ec`, `-xc`) — the inline-shell payload
247
- * flag for `bash`/`sh`/`dash`/`zsh`/`ksh`.
248
- */
249
- function hasShortFlagC(args: string[]): boolean {
250
- for (const arg of args) {
251
- if (arg === "--") return false;
252
- if (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("c")) {
253
- return true;
254
- }
255
- }
256
- return false;
257
- }
258
-
259
- /** The final path segment of a command name (`/bin/bash` → `bash`). */
260
- function basename(name: string): string {
261
- const slash = name.lastIndexOf("/");
262
- return slash === -1 ? name : name.slice(slash + 1);
190
+ return words;
263
191
  }
264
192
 
265
193
  /**