@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.
- package/CHANGELOG.md +30 -0
- package/README.md +1 -1
- package/config/config.example.json +3 -0
- package/dist/public.d.ts +162 -41
- package/docs/configuration.md +17 -2
- package/docs/cross-extension-api.md +14 -9
- package/package.json +1 -1
- package/schemas/permissions.schema.json +16 -0
- package/src/access-intent/bash/command-enumeration.ts +45 -117
- package/src/access-intent/bash/wrapper-analysis.ts +335 -0
- package/src/authority/approval-escalator.ts +26 -1
- package/src/authority/forwarded-request-server.ts +5 -14
- package/src/authority/local-user-authorizer.ts +2 -3
- package/src/authority/permission-prompt-component.ts +87 -47
- package/src/authority/permission-prompter.ts +9 -0
- package/src/config-loader.ts +2 -0
- package/src/config-schema.ts +14 -0
- package/src/extension-config.ts +10 -0
- package/src/handlers/gates/bash-command.ts +7 -2
- package/src/handlers/gates/bash-external-directory.ts +11 -6
- package/src/handlers/gates/bash-path.ts +10 -6
- package/src/handlers/gates/descriptor.ts +10 -1
- package/src/handlers/gates/external-directory.ts +13 -8
- package/src/handlers/gates/helpers.ts +6 -7
- package/src/handlers/gates/path.ts +11 -14
- package/src/handlers/gates/runner.ts +40 -19
- package/src/handlers/gates/skill-input-gate-pipeline.ts +1 -12
- package/src/handlers/gates/skill-input.ts +5 -2
- package/src/handlers/gates/skill-read.ts +6 -6
- package/src/handlers/gates/tool-call-gate-pipeline.ts +1 -5
- package/src/handlers/gates/tool.ts +10 -5
- package/src/handlers/tool-call-boundary.ts +30 -7
- package/src/index.ts +2 -0
- package/src/permission-events.ts +6 -0
- package/src/permission-prompts.ts +4 -72
- package/src/permission-request-id.ts +17 -0
- package/src/presentation/dialog-renderer.ts +404 -0
- package/src/presentation/forwarded-ask-payload.ts +45 -0
- package/src/presentation/legacy-message.ts +117 -0
- package/src/presentation/line-fitting.ts +27 -0
- package/src/presentation/path-ask-payload.ts +128 -0
- package/src/presentation/prompt-payload.ts +137 -0
- package/src/presentation/skill-ask-payload.ts +50 -0
- package/src/presentation/tool-ask-payload.ts +104 -0
- package/src/tool-preview-formatter.ts +1 -1
- package/src/types.ts +6 -0
- 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
|
-
|
|
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.
|
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;
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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` |
|
|
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
|
@@ -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
|
-
|
|
150
|
+
const flagged = wrapperKind ? { ...unit, wrapperKind } : unit;
|
|
151
|
+
return executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
/**
|
|
150
|
-
*
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
222
|
-
*
|
|
223
|
-
*
|
|
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
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
|
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
|
/**
|