@gotgenes/pi-permission-system 23.0.2 → 24.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 +23 -0
- package/README.md +1 -0
- package/dist/public.d.ts +4 -2
- package/docs/configuration.md +7 -0
- package/docs/migration/0635-forwarded-ask-delegation-envelope.md +42 -0
- package/package.json +1 -1
- package/src/authority/forwarded-request-server.ts +36 -1
- package/src/authority/permission-prompt-component.ts +36 -2
- package/src/authority/permission-prompter.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ 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
|
+
## [24.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.3...pi-permission-system-v24.0.0) (2026-07-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** an authorizer chain link's `allow` on a forwarded subagent ask raised by the `path` or `external_directory` gate is now downgraded to `defer`, so the request falls through to an interactive prompt. This affects only an operator running an allow-capable third-party link named in `authorizerChain`; the first-party model judge is deny-first and is unaffected, as are forwarded `bash` asks and per-tool-gated asks. See packages/pi-permission-system/docs/migration/0635-forwarded-ask-delegation-envelope.md
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **pi-permission-system:** carry forwarded access facts to the Authorizer Chain ([#635](https://github.com/gotgenes/pi-packages/issues/635)) ([c0790ad](https://github.com/gotgenes/pi-packages/commit/c0790ad6d1d15defaba60097d803618b4d8c461c))
|
|
18
|
+
|
|
19
|
+
## [23.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.2...pi-permission-system-v23.0.3) (2026-07-26)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **pi-permission-system:** preserve tool expansion in inline permission prompts ([6a0d241](https://github.com/gotgenes/pi-packages/commit/6a0d241291f8eefa51493b683e12514a83f295bd))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** document tool expansion during permission prompts ([f4098d3](https://github.com/gotgenes/pi-packages/commit/f4098d331efc72538ee3836ffb4056beee8cbed0))
|
|
30
|
+
|
|
8
31
|
## [23.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.1...pi-permission-system-v23.0.2) (2026-07-26)
|
|
9
32
|
|
|
10
33
|
|
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
|
+
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
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.
|
|
70
71
|
|
|
71
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
|
@@ -195,8 +195,10 @@ interface PromptPermissionDetails {
|
|
|
195
195
|
* The child-fixed access facts the raising gate computed (surface + match
|
|
196
196
|
* set). Rides through the runner to the escalation edge, which completes
|
|
197
197
|
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
|
198
|
-
* `principal`.
|
|
199
|
-
* forwarded request
|
|
198
|
+
* `principal`. On a serving node these facts are projected back off the
|
|
199
|
+
* forwarded request, so a forwarded ask reaches the `Authorizer` chain with
|
|
200
|
+
* the same evidence as a local one; only a version-skew request that carried
|
|
201
|
+
* no intent leaves this absent.
|
|
200
202
|
*/
|
|
201
203
|
accessIntent?: ForwardedAccessFacts;
|
|
202
204
|
}
|
package/docs/configuration.md
CHANGED
|
@@ -127,6 +127,10 @@ Arrow keys / `j`/`k` move the highlight, `enter` confirms the highlighted option
|
|
|
127
127
|
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.
|
|
128
128
|
Set `doublePressToConfirm` to `false` to commit on the first press.
|
|
129
129
|
|
|
130
|
+
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.
|
|
131
|
+
It only toggles the display — it never resolves, commits, or arms the pending decision.
|
|
132
|
+
While you are typing a denial reason it is not intercepted, so a rebound printable key still reaches the reason editor.
|
|
133
|
+
|
|
130
134
|
Non-TUI contexts (RPC / frontend-driven sessions) keep the single-select prompt and are unaffected by `doublePressToConfirm`.
|
|
131
135
|
|
|
132
136
|
### `piInfrastructureReadPaths` patterns
|
|
@@ -205,6 +209,9 @@ Three invariants govern the chain:
|
|
|
205
209
|
|
|
206
210
|
The chain owner caps every link with a **bounded-delegation checkpoint**: a link's `allow` on an excluded surface (`external_directory` or the `path` surface) is downgraded to `defer`, so a buggy or over-eager judge can never approve access outside your policy.
|
|
207
211
|
Deny and defer are never capped.
|
|
212
|
+
The excluded surface is the **gate** surface the rule fired on, not the tool name displayed in the prompt — so a `write` blocked by a `path` rule is capped.
|
|
213
|
+
This holds for an ask forwarded up from a subagent exactly as it does for a local one.
|
|
214
|
+
See [migration/0635-forwarded-ask-delegation-envelope.md](migration/0635-forwarded-ask-delegation-envelope.md).
|
|
208
215
|
|
|
209
216
|
Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
|
|
210
217
|
Registration returns a disposer, and only one link may hold a given name.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Migration guide: forwarded asks and the bounded-delegation checkpoint
|
|
2
|
+
|
|
3
|
+
Starting with the release that closes #635, a permission request forwarded up from a subagent carries the **child-fixed access facts** through to the authorizer chain.
|
|
4
|
+
As a side effect, the bounded-delegation checkpoint now applies to forwarded asks the same way it already applied to local ones.
|
|
5
|
+
This is a **breaking change** for one narrow configuration.
|
|
6
|
+
|
|
7
|
+
## Who is affected
|
|
8
|
+
|
|
9
|
+
You are affected only if **all** of the following hold:
|
|
10
|
+
|
|
11
|
+
- You name an authorizer chain link in `authorizerChain`, and
|
|
12
|
+
- that link is **allow-capable** (it can return `allow`, not only `deny` / `defer`), and
|
|
13
|
+
- a subagent forwards an `ask` to your session that was raised by the `path` or `external_directory` gate.
|
|
14
|
+
|
|
15
|
+
If you run no chain link, or run only a deny-first link — including the first-party [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge), which only ever denies or defers — **nothing changes for you**.
|
|
16
|
+
|
|
17
|
+
## What changed
|
|
18
|
+
|
|
19
|
+
The checkpoint downgrades a link's `allow` to `defer` on the excluded surfaces (`path`, `external_directory`), so the request falls through to an interactive prompt instead.
|
|
20
|
+
It selects the surface from the **gate** surface — the surface the matching rule lives on — falling back to the displayed surface when no gate surface is available.
|
|
21
|
+
|
|
22
|
+
A forwarded request previously arrived with no gate surface, so the checkpoint fell back to the child's *display* surface, which is the tool name (`write`, `edit`, `read`).
|
|
23
|
+
A tool name is not an excluded surface, so a link's `allow` on a forwarded `path`-gated request was honored.
|
|
24
|
+
The identical request made directly in the same session was capped, because a local request always carries its gate surface.
|
|
25
|
+
|
|
26
|
+
Forwarding was therefore a way around your own delegation boundary: a link could approve `~/.ssh/config` for a subagent that it could not approve for the main session.
|
|
27
|
+
Now the gate surface crosses the forwarding hop, and both paths behave identically.
|
|
28
|
+
|
|
29
|
+
Unchanged: a forwarded `bash` ask, and a forwarded tool ask gated by a per-tool rule (`write: ask`), are both already on non-excluded surfaces and are not capped.
|
|
30
|
+
`deny` and `defer` verdicts were never capped and still are not.
|
|
31
|
+
|
|
32
|
+
## What you need to do
|
|
33
|
+
|
|
34
|
+
Nothing, unless you run an allow-capable link.
|
|
35
|
+
|
|
36
|
+
If you do, expect an interactive prompt where that link previously auto-approved a forwarded subagent request for a path.
|
|
37
|
+
To keep such a request from prompting, grant it in policy rather than through the link — the `path` and `external_directory` rules in your config are consulted before the chain runs, so an `allow` there resolves the request without reaching an authorizer at all.
|
|
38
|
+
|
|
39
|
+
## Related
|
|
40
|
+
|
|
41
|
+
Issue #620 will replace the whole-`path` exclusion with a narrower secret-shaped one, letting a link allow a non-secret path again while keeping secret-shaped paths capped.
|
|
42
|
+
That refinement applies to local and forwarded asks alike, so the two paths stay aligned.
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
} from "#src/authority/forwarder-context";
|
|
6
6
|
import type { PermissionPromptDecision } from "#src/authority/permission-dialog";
|
|
7
7
|
import {
|
|
8
|
+
type ForwardedAccessFacts,
|
|
8
9
|
type ForwardedAccessIntent,
|
|
9
10
|
type ForwardedPermissionRequest,
|
|
10
11
|
type ForwardedPermissionResponse,
|
|
@@ -93,7 +94,14 @@ function formatForwardedPermissionPrompt(
|
|
|
93
94
|
/**
|
|
94
95
|
* Map a forwarded request onto the escalated ask's details, carrying the
|
|
95
96
|
* forwarded provenance (requester agent/session + the child's original display
|
|
96
|
-
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292)
|
|
97
|
+
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292),
|
|
98
|
+
* plus the child-fixed access facts so the serving node's `Authorizer` chain
|
|
99
|
+
* judges a forwarded ask on the same evidence as a local one (ADR 0008; #635).
|
|
100
|
+
*
|
|
101
|
+
* The display `surface` and the fact `surface` are distinct and both belong
|
|
102
|
+
* here: the former is the child's tool name (what the UI shows), the latter the
|
|
103
|
+
* gate surface the rule fired on (what the bounded-delegation checkpoint
|
|
104
|
+
* excludes on).
|
|
97
105
|
*/
|
|
98
106
|
function buildForwardedAskDetails(
|
|
99
107
|
request: ForwardedPermissionRequest,
|
|
@@ -114,6 +122,33 @@ function buildForwardedAskDetails(
|
|
|
114
122
|
...(request.sessionApproval
|
|
115
123
|
? { sessionApproval: request.sessionApproval }
|
|
116
124
|
: {}),
|
|
125
|
+
// Absent for a version-skew request that carried no intent — which the
|
|
126
|
+
// delegation envelope reads as "surface undetermined" and fail-safes to
|
|
127
|
+
// excluded, so absence must stay absence rather than become `undefined`.
|
|
128
|
+
...(request.accessIntent
|
|
129
|
+
? { accessIntent: toAccessFacts(request.accessIntent) }
|
|
130
|
+
: {}),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Project the wire intent down to the child-fixed access facts an `Authorizer`
|
|
136
|
+
* may see.
|
|
137
|
+
*
|
|
138
|
+
* Field-by-field rather than a spread, because this is a disclosure boundary:
|
|
139
|
+
* `requesterCwd` and `principal` are requester identity for the serving node's
|
|
140
|
+
* own resolution (ADR 0008 §3) and stay off the ask details. A link that needs
|
|
141
|
+
* requester identity reads `details.forwarding`. `ForwardedAccessIntent`
|
|
142
|
+
* extends `ForwardedAccessFacts`, so a spread would type-check while widening
|
|
143
|
+
* disclosure at runtime; the explicit return type makes any future field on
|
|
144
|
+
* `ForwardedAccessFacts` a compile error here until it is deliberately
|
|
145
|
+
* projected or deliberately withheld.
|
|
146
|
+
*/
|
|
147
|
+
function toAccessFacts(intent: ForwardedAccessIntent): ForwardedAccessFacts {
|
|
148
|
+
return {
|
|
149
|
+
surface: intent.surface,
|
|
150
|
+
matchValues: intent.matchValues,
|
|
151
|
+
boundaryValue: intent.boundaryValue,
|
|
117
152
|
};
|
|
118
153
|
}
|
|
119
154
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ExtensionContext,
|
|
3
3
|
ExtensionUIContext,
|
|
4
|
+
KeybindingsManager,
|
|
4
5
|
} from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import {
|
|
6
7
|
type Component,
|
|
@@ -35,9 +36,12 @@ import {
|
|
|
35
36
|
/** The subset of the session UI surface the inline dialog needs. */
|
|
36
37
|
export type PermissionPromptUi = Pick<
|
|
37
38
|
ExtensionUIContext,
|
|
38
|
-
"select" | "input" | "custom"
|
|
39
|
+
"select" | "input" | "custom" | "getToolsExpanded" | "setToolsExpanded"
|
|
39
40
|
>;
|
|
40
41
|
|
|
42
|
+
/** The keybindings surface the dialog consults; only `matches` is read (ISP). */
|
|
43
|
+
type PromptKeybindings = Pick<KeybindingsManager, "matches">;
|
|
44
|
+
|
|
41
45
|
/** The resolved presentation context selected once per activation. */
|
|
42
46
|
export interface PermissionPromptView {
|
|
43
47
|
mode: ExtensionContext["mode"];
|
|
@@ -97,12 +101,13 @@ export function presentInlinePermissionPrompt(
|
|
|
97
101
|
sessionScope: options?.sessionScope,
|
|
98
102
|
};
|
|
99
103
|
return view.ui.custom<PermissionPromptDecision>(
|
|
100
|
-
(tui, theme,
|
|
104
|
+
(tui, theme, keybindings, done) =>
|
|
101
105
|
new PermissionPromptComponent(
|
|
102
106
|
theme,
|
|
103
107
|
config,
|
|
104
108
|
title,
|
|
105
109
|
message,
|
|
110
|
+
(data) => handleToolsExpandAction(data, keybindings, view.ui),
|
|
106
111
|
() => {
|
|
107
112
|
tui.requestRender();
|
|
108
113
|
},
|
|
@@ -112,6 +117,31 @@ export function presentInlinePermissionPrompt(
|
|
|
112
117
|
);
|
|
113
118
|
}
|
|
114
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Forward Pi's tool-expansion action while the dialog holds keyboard focus.
|
|
122
|
+
*
|
|
123
|
+
* A focused `ctx.ui.custom` component consumes every keystroke, so `Ctrl+O`
|
|
124
|
+
* would otherwise be dead for the duration of an ask — exactly when the user
|
|
125
|
+
* most needs to see the full pending tool invocation. Returns `true` when the
|
|
126
|
+
* keystroke was the action (and was handled), so the caller stops before
|
|
127
|
+
* mapping it to a {@link PromptEvent}; expansion is a display concern and must
|
|
128
|
+
* never reach the decision model.
|
|
129
|
+
*
|
|
130
|
+
* Deliberately does not request a render: `setToolsExpanded` re-renders the
|
|
131
|
+
* host itself, and the dialog's own lines are unaffected by tool expansion.
|
|
132
|
+
*/
|
|
133
|
+
function handleToolsExpandAction(
|
|
134
|
+
data: string,
|
|
135
|
+
keybindings: PromptKeybindings,
|
|
136
|
+
ui: PermissionPromptUi,
|
|
137
|
+
): boolean {
|
|
138
|
+
if (!keybindings.matches(data, "app.tools.expand")) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
ui.setToolsExpanded(!ui.getToolsExpanded());
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
115
145
|
class PermissionPromptComponent implements Component {
|
|
116
146
|
private state: PromptViewState;
|
|
117
147
|
private reasonBuffer = "";
|
|
@@ -121,6 +151,7 @@ class PermissionPromptComponent implements Component {
|
|
|
121
151
|
private readonly config: PromptModelConfig,
|
|
122
152
|
private readonly title: string,
|
|
123
153
|
private readonly message: string,
|
|
154
|
+
private readonly handleAppAction: (data: string) => boolean,
|
|
124
155
|
private readonly requestRender: () => void,
|
|
125
156
|
private readonly done: (decision: PermissionPromptDecision) => void,
|
|
126
157
|
) {
|
|
@@ -151,6 +182,9 @@ class PermissionPromptComponent implements Component {
|
|
|
151
182
|
this.handleReasonInput(data);
|
|
152
183
|
return;
|
|
153
184
|
}
|
|
185
|
+
if (this.handleAppAction(data)) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
154
188
|
const event = this.toEvent(data);
|
|
155
189
|
if (event) {
|
|
156
190
|
this.apply(event);
|
|
@@ -53,8 +53,10 @@ export interface PromptPermissionDetails {
|
|
|
53
53
|
* The child-fixed access facts the raising gate computed (surface + match
|
|
54
54
|
* set). Rides through the runner to the escalation edge, which completes
|
|
55
55
|
* them into a `ForwardedAccessIntent` by stamping `requesterCwd` and
|
|
56
|
-
* `principal`.
|
|
57
|
-
* forwarded request
|
|
56
|
+
* `principal`. On a serving node these facts are projected back off the
|
|
57
|
+
* forwarded request, so a forwarded ask reaches the `Authorizer` chain with
|
|
58
|
+
* the same evidence as a local one; only a version-skew request that carried
|
|
59
|
+
* no intent leaves this absent.
|
|
58
60
|
*/
|
|
59
61
|
accessIntent?: ForwardedAccessFacts;
|
|
60
62
|
}
|