@gotgenes/pi-permission-system 20.1.0 → 20.3.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 +25 -0
- package/docs/cross-extension-api.md +2 -1
- package/docs/subagent-integration.md +2 -0
- package/package.json +1 -1
- package/src/authority/approval-escalator.ts +17 -7
- package/src/authority/authorizer-selection.ts +26 -27
- package/src/authority/denying-authorizer.ts +9 -1
- package/src/authority/forwarded-request-server.ts +209 -87
- package/src/authority/forwarding-io.ts +27 -0
- package/src/authority/local-user-authorizer.ts +35 -5
- package/src/authority/permission-prompter.ts +30 -1
- package/src/handlers/gates/helpers.ts +4 -3
- package/src/handlers/gates/runner.ts +11 -7
- package/src/index.ts +38 -15
- package/src/pattern-suggest.ts +29 -0
- package/src/permission-dialog.ts +34 -0
- package/src/permission-forwarding.ts +23 -1
- package/src/permission-gate.ts +15 -21
- package/src/permission-ui-prompt.ts +39 -54
- package/src/session-approval.ts +11 -0
- package/src/gate-prompter.ts +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
## [20.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.2.0...pi-permission-system-v20.3.0) (2026-07-09)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** forward the child's session-approval suggestion ([fa04d8c](https://github.com/gotgenes/pi-packages/commit/fa04d8ca3485ca3f6e29bbe8d53146f5eeb6672c))
|
|
14
|
+
* **pi-permission-system:** offer whole-session scope on forwarded approvals ([bd2be07](https://github.com/gotgenes/pi-packages/commit/bd2be0799923ed809cd0939047486fdc1b2a477f))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** record forwarded grant-scope selection (Phase 9 Step 4) ([a3d1fca](https://github.com/gotgenes/pi-packages/commit/a3d1fca183dc5e4d6fb15ec6ec78199f5d092bc3))
|
|
20
|
+
|
|
21
|
+
## [20.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.1.0...pi-permission-system-v20.2.0) (2026-07-09)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** serve forwarded permissions by resolution and Authorizer escalation ([#557](https://github.com/gotgenes/pi-packages/issues/557)) ([c5d3bcb](https://github.com/gotgenes/pi-packages/commit/c5d3bcbf4e69a568f750d6a7c0619d4b88d5b377))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** mark Phase 9 Step 3 complete ([a5a348f](https://github.com/gotgenes/pi-packages/commit/a5a348f3d3dc1835e64ecd7b6b0c1b6164085a97))
|
|
32
|
+
|
|
8
33
|
## [20.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v20.0.0...pi-permission-system-v20.1.0) (2026-07-08)
|
|
9
34
|
|
|
10
35
|
|
|
@@ -307,7 +307,8 @@ This event is for integrations such as notification extensions that should alert
|
|
|
307
307
|
It is not a generic "permission request entered waiting state" event, and it does not imply the prompt will be approved.
|
|
308
308
|
Policy decisions that resolve without an active UI prompt, such as `policy_allow`, `policy_deny`, `session_approved`, `infrastructure_auto_allowed`, or `auto_approved`, do not emit this event.
|
|
309
309
|
Non-UI child sessions also do not emit this event when they create a forwarded permission request; the parent UI session emits it immediately before showing the forwarded permission dialog.
|
|
310
|
-
|
|
310
|
+
A forwarded request the parent's own recorded policy decides (a matching `allow` or `deny`) is answered without a prompt and emits no event; the event fires only when the parent is actually about to ask the human.
|
|
311
|
+
Forwarded prompts that do reach the human are not degraded: the parent emits the child's original `source` and the same `surface`/`value` display projection, plus a populated `forwarding` context identifying the requesting subagent.
|
|
311
312
|
|
|
312
313
|
The payload is lean by design — `surface`/`value` are the normalized display projection a notification consumer reads, not a mirror of the internal review log.
|
|
313
314
|
Read defensively rather than version-gating: broadcast payloads carry no `protocolVersion`.
|
|
@@ -17,6 +17,7 @@ The integration enables:
|
|
|
17
17
|
2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
|
|
18
18
|
3. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
|
|
19
19
|
The parent approves or denies, and the child resumes.
|
|
20
|
+
When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
|
|
20
21
|
|
|
21
22
|
No configuration is required - the integration is automatic when both extensions are installed.
|
|
22
23
|
When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents` emits its lifecycle events with no subscriber - a harmless no-op.
|
|
@@ -25,6 +26,7 @@ When `@gotgenes/pi-permission-system` is not installed, `@gotgenes/pi-subagents`
|
|
|
25
26
|
|
|
26
27
|
When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories.
|
|
27
28
|
The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
29
|
+
A parent `allow`/`deny` rule governs a child's escalation directly (the serving node resolves it as recorded authority before prompting), and a "whole session" grant recorded on the parent auto-approves later forwards of the same pattern.
|
|
28
30
|
|
|
29
31
|
This keeps `ask` policies usable even when the original permission check happens inside a non-UI execution context.
|
|
30
32
|
|
package/package.json
CHANGED
|
@@ -22,13 +22,14 @@ import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
|
22
22
|
import {
|
|
23
23
|
type ForwardedPermissionRequest,
|
|
24
24
|
type ForwardedPromptDisplay,
|
|
25
|
+
type ForwardedSessionApproval,
|
|
25
26
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
26
27
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
27
28
|
type PermissionForwardingLocation,
|
|
28
29
|
resolvePermissionForwardingTargetSessionId,
|
|
29
30
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
30
31
|
} from "#src/permission-forwarding";
|
|
31
|
-
import {
|
|
32
|
+
import { buildUiPrompt } from "#src/permission-ui-prompt";
|
|
32
33
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
33
34
|
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
34
35
|
import { toRecord } from "#src/value-guards";
|
|
@@ -97,12 +98,17 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
97
98
|
authorize(
|
|
98
99
|
details: PromptPermissionDetails,
|
|
99
100
|
): Promise<PermissionPromptDecision> {
|
|
100
|
-
const uiPrompt =
|
|
101
|
-
return this.waitForForwardedApproval(
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
const uiPrompt = buildUiPrompt(details);
|
|
102
|
+
return this.waitForForwardedApproval(
|
|
103
|
+
this.ctx,
|
|
104
|
+
details.message,
|
|
105
|
+
{
|
|
106
|
+
source: uiPrompt.source,
|
|
107
|
+
surface: uiPrompt.surface,
|
|
108
|
+
value: uiPrompt.value,
|
|
109
|
+
},
|
|
110
|
+
details.sessionApproval,
|
|
111
|
+
);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
// ── Private methods ────────────────────────────────────────────────────
|
|
@@ -111,6 +117,7 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
111
117
|
ctx: ForwarderContext,
|
|
112
118
|
message: string,
|
|
113
119
|
forwarded?: ForwardedPromptDisplay,
|
|
120
|
+
sessionApproval?: ForwardedSessionApproval,
|
|
114
121
|
): Promise<PermissionPromptDecision> {
|
|
115
122
|
const requesterSessionId = getSessionId(ctx);
|
|
116
123
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
@@ -156,6 +163,7 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
156
163
|
requesterSessionId,
|
|
157
164
|
targetSessionId,
|
|
158
165
|
forwarded,
|
|
166
|
+
sessionApproval,
|
|
159
167
|
);
|
|
160
168
|
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
161
169
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
@@ -194,6 +202,7 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
194
202
|
requesterSessionId: string,
|
|
195
203
|
targetSessionId: string,
|
|
196
204
|
forwarded?: ForwardedPromptDisplay,
|
|
205
|
+
sessionApproval?: ForwardedSessionApproval,
|
|
197
206
|
): ForwardedPermissionRequest {
|
|
198
207
|
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
199
208
|
const requesterAgentName =
|
|
@@ -214,6 +223,7 @@ export class ParentAuthorizer implements Authorizer {
|
|
|
214
223
|
value: forwarded.value,
|
|
215
224
|
}
|
|
216
225
|
: {}),
|
|
226
|
+
...(sessionApproval ? { sessionApproval } : {}),
|
|
217
227
|
};
|
|
218
228
|
}
|
|
219
229
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { GatePrompter } from "#src/gate-prompter";
|
|
3
2
|
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
4
3
|
import {
|
|
5
4
|
type Authorizer,
|
|
@@ -23,23 +22,34 @@ export interface AuthorizerSelectionLifecycle {
|
|
|
23
22
|
deactivate(): void;
|
|
24
23
|
}
|
|
25
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The ask-escalation seam `GateRunner` depends on: escalate a single ask to
|
|
27
|
+
* the session's selected `Authorizer` and return its decision.
|
|
28
|
+
*
|
|
29
|
+
* Replaces the two-method `GatePrompter` role (#556). There is no
|
|
30
|
+
* "can anyone answer" pre-check: absent authority is the `DenyingAuthorizer`,
|
|
31
|
+
* which answers by denying with a `confirmationUnavailable` marker.
|
|
32
|
+
*/
|
|
33
|
+
export interface AskEscalator {
|
|
34
|
+
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
/**
|
|
27
38
|
* Context-owning selection root for the Authorizer spine.
|
|
28
39
|
*
|
|
29
40
|
* The rewrite of `PromptingGateway`: owns the stored `ExtensionContext`, runs
|
|
30
|
-
* `selectAuthorizer` once per activation, and implements `
|
|
41
|
+
* `selectAuthorizer` once per activation, and implements `AskEscalator` by
|
|
31
42
|
* delegating to the selected `Authorizer` via `PermissionPrompter`.
|
|
32
43
|
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
44
|
+
* `selectAuthorizer` encodes the liveness decision in *which* `Authorizer` it
|
|
45
|
+
* returns (`LocalUserAuthorizer` / `ParentAuthorizer` when authority is
|
|
46
|
+
* reachable, `DenyingAuthorizer` otherwise), so no separate confirmability
|
|
47
|
+
* predicate survives (#556 dissolved `canConfirm()`).
|
|
37
48
|
*/
|
|
38
49
|
export class AuthorizerSelection
|
|
39
|
-
implements
|
|
50
|
+
implements AskEscalator, AuthorizerSelectionLifecycle
|
|
40
51
|
{
|
|
41
52
|
private selected: Authorizer | null = null;
|
|
42
|
-
private confirmable = false;
|
|
43
53
|
|
|
44
54
|
constructor(
|
|
45
55
|
private readonly deps: AuthorizerSelectionDeps & {
|
|
@@ -47,39 +57,28 @@ export class AuthorizerSelection
|
|
|
47
57
|
},
|
|
48
58
|
) {}
|
|
49
59
|
|
|
50
|
-
/** Select the Authorizer for `ctx` and store
|
|
60
|
+
/** Select the Authorizer for `ctx` and store it. */
|
|
51
61
|
activate(ctx: ExtensionContext): void {
|
|
52
62
|
this.selected = selectAuthorizer(ctx, this.deps);
|
|
53
|
-
this.confirmable = ctx.hasUI || this.deps.detection.isSubagent(ctx);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
/** Clear the stored selection. */
|
|
57
66
|
deactivate(): void {
|
|
58
67
|
this.selected = null;
|
|
59
|
-
this.confirmable = false;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Whether an interactive permission prompt can be shown.
|
|
64
|
-
*
|
|
65
|
-
* Returns false when no authorizer has been selected. Otherwise true when
|
|
66
|
-
* the context had UI or was a forwarding subagent at selection time — the
|
|
67
|
-
* two Authorizer-selection predicates, evaluated once at `activate`.
|
|
68
|
-
*/
|
|
69
|
-
canConfirm(): boolean {
|
|
70
|
-
return this.selected !== null && this.confirmable;
|
|
71
68
|
}
|
|
72
69
|
|
|
73
70
|
/**
|
|
74
|
-
*
|
|
71
|
+
* Escalate an ask to the selected authorizer and return its decision.
|
|
75
72
|
*
|
|
76
|
-
* Rejects if no authorizer has been selected —
|
|
77
|
-
*
|
|
73
|
+
* Rejects if no authorizer has been selected — i.e. before the session was
|
|
74
|
+
* activated. Implements {@link AskEscalator}.
|
|
78
75
|
*/
|
|
79
|
-
|
|
76
|
+
escalate(
|
|
77
|
+
details: PromptPermissionDetails,
|
|
78
|
+
): Promise<PermissionPromptDecision> {
|
|
80
79
|
if (this.selected === null) {
|
|
81
80
|
return Promise.reject(
|
|
82
|
-
new Error("
|
|
81
|
+
new Error("escalate called before the session was activated"),
|
|
83
82
|
);
|
|
84
83
|
}
|
|
85
84
|
return this.deps.prompter.prompt(this.selected, details);
|
|
@@ -4,9 +4,17 @@ import type { Authorizer } from "./authorizer";
|
|
|
4
4
|
/**
|
|
5
5
|
* Least-privilege Authorizer: no authority is reachable for this session
|
|
6
6
|
* (no UI, not a subagent), so every ask is denied.
|
|
7
|
+
*
|
|
8
|
+
* The denial carries the `confirmationUnavailable` marker so the ask path can
|
|
9
|
+
* distinguish "nobody could answer" from an interactive user denial when it
|
|
10
|
+
* derives the review-entry and decision-event resolution.
|
|
7
11
|
*/
|
|
8
12
|
export class DenyingAuthorizer implements Authorizer {
|
|
9
13
|
authorize(): Promise<PermissionPromptDecision> {
|
|
10
|
-
return Promise.resolve({
|
|
14
|
+
return Promise.resolve({
|
|
15
|
+
approved: false,
|
|
16
|
+
state: "denied",
|
|
17
|
+
confirmationUnavailable: true,
|
|
18
|
+
});
|
|
11
19
|
}
|
|
12
20
|
}
|
|
@@ -3,26 +3,19 @@ import {
|
|
|
3
3
|
type ForwarderContext,
|
|
4
4
|
getSessionId,
|
|
5
5
|
} from "#src/authority/forwarder-context";
|
|
6
|
-
import type {
|
|
7
|
-
import { isYoloModeEnabled } from "#src/extension-config";
|
|
8
|
-
import type {
|
|
9
|
-
PermissionDecisionUi,
|
|
10
|
-
PermissionPromptDecision,
|
|
11
|
-
RequestPermissionOptions,
|
|
12
|
-
} from "#src/permission-dialog";
|
|
13
|
-
import {
|
|
14
|
-
emitUiPromptEvent,
|
|
15
|
-
type PermissionEventBus,
|
|
16
|
-
} from "#src/permission-events";
|
|
6
|
+
import type { PermissionPromptDecision } from "#src/permission-dialog";
|
|
17
7
|
import {
|
|
18
8
|
type ForwardedPermissionRequest,
|
|
19
9
|
type ForwardedPermissionResponse,
|
|
20
10
|
isForwardedPermissionRequestForSession,
|
|
21
11
|
type PermissionForwardingLocation,
|
|
22
12
|
} from "#src/permission-forwarding";
|
|
23
|
-
import {
|
|
13
|
+
import { SessionApproval } from "#src/session-approval";
|
|
14
|
+
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
24
15
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
25
|
-
|
|
16
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
17
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
18
|
+
import type { AskEscalator } from "./authorizer-selection";
|
|
26
19
|
import {
|
|
27
20
|
cleanupPermissionForwardingLocationIfEmpty,
|
|
28
21
|
ensureDirectoryExists,
|
|
@@ -34,6 +27,7 @@ import {
|
|
|
34
27
|
safeDeleteFile,
|
|
35
28
|
writeJsonFileAtomic,
|
|
36
29
|
} from "./forwarding-io";
|
|
30
|
+
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
37
31
|
|
|
38
32
|
/**
|
|
39
33
|
* Narrow seam describing what `ForwardingManager` needs from the server: a
|
|
@@ -47,20 +41,35 @@ export interface InboxProcessor {
|
|
|
47
41
|
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
48
42
|
}
|
|
49
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Recorded-authority view the serving node resolves a forwarded request
|
|
46
|
+
* against: answer one `(surface, value)` query on the serving session's
|
|
47
|
+
* composed base ruleset (agent-neutral — the child already applied its own
|
|
48
|
+
* per-agent overrides before forwarding).
|
|
49
|
+
*
|
|
50
|
+
* Narrow by design (ISP): the server needs one decision, not the whole
|
|
51
|
+
* resolver. The composition root satisfies it with an access-intent build plus
|
|
52
|
+
* `resolver.resolve`, the same primitives `LocalPermissionsService` composes.
|
|
53
|
+
*/
|
|
54
|
+
export interface ServingPolicy {
|
|
55
|
+
check(surface: string, value: string | null): PermissionCheckResult;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
/** Constructor config for `ForwardedRequestServer`. */
|
|
51
59
|
export interface ForwardedRequestServerDeps {
|
|
52
60
|
forwardingDir: string;
|
|
53
61
|
logger: DebugReviewLogger;
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
/** Recorded-authority resolution for `(surface, value)` requests. */
|
|
63
|
+
policy: ServingPolicy;
|
|
64
|
+
/** Escalation seam to the serving session's selected `Authorizer` on `ask`. */
|
|
65
|
+
escalator: AskEscalator;
|
|
66
|
+
/**
|
|
67
|
+
* The serving session's `SessionRules`. Records a whole-session grant when a
|
|
68
|
+
* human approves a forwarded request for the entire serving session.
|
|
69
|
+
*/
|
|
70
|
+
recorder: SessionApprovalRecorder;
|
|
71
|
+
/** In-process subagent registry, read only by the one-hop canary. */
|
|
72
|
+
registry?: SubagentSessionRegistry;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
// ── Module-private helpers ────────────────────────────────────────────────
|
|
@@ -78,39 +87,77 @@ function formatForwardedPermissionPrompt(
|
|
|
78
87
|
].join("\n");
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A request is resolvable against the ruleset only when it carries a concrete
|
|
92
|
+
* `(surface, value)` display projection. A legacy/version-skew request without
|
|
93
|
+
* them floors to `ask` (escalate), never a silent grant.
|
|
94
|
+
*/
|
|
95
|
+
function hasDisplayFields(
|
|
96
|
+
request: ForwardedPermissionRequest,
|
|
97
|
+
): request is ForwardedPermissionRequest & { surface: string; value: string } {
|
|
98
|
+
return (
|
|
99
|
+
typeof request.surface === "string" &&
|
|
100
|
+
request.surface.length > 0 &&
|
|
101
|
+
typeof request.value === "string" &&
|
|
102
|
+
request.value.length > 0
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Map a forwarded request onto the escalated ask's details, carrying the
|
|
108
|
+
* forwarded provenance (requester agent/session + the child's original display
|
|
109
|
+
* projection) so `LocalUserAuthorizer` emits a non-degraded broadcast (#292).
|
|
110
|
+
*/
|
|
111
|
+
function buildForwardedAskDetails(
|
|
112
|
+
request: ForwardedPermissionRequest,
|
|
113
|
+
): PromptPermissionDetails {
|
|
114
|
+
return {
|
|
115
|
+
requestId: request.id,
|
|
116
|
+
source: request.source ?? "tool_call",
|
|
117
|
+
agentName: request.requesterAgentName || null,
|
|
118
|
+
message: formatForwardedPermissionPrompt(request),
|
|
119
|
+
surface: request.surface ?? null,
|
|
120
|
+
value: request.value ?? null,
|
|
121
|
+
forwarding: {
|
|
122
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
123
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
124
|
+
},
|
|
125
|
+
// Carries the child's suggestion so LocalUserAuthorizer can offer the
|
|
126
|
+
// whole-session grant scope; absent for a legacy/version-skew request.
|
|
127
|
+
...(request.sessionApproval
|
|
128
|
+
? { sessionApproval: request.sessionApproval }
|
|
129
|
+
: {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
81
133
|
// ── ForwardedRequestServer ────────────────────────────────────────────────
|
|
82
134
|
|
|
83
135
|
/**
|
|
84
136
|
* Owner of the serving-down role of the forwarded-permission behavior:
|
|
85
137
|
* draining this session's forwarded-permission inbox and answering each
|
|
86
|
-
* request
|
|
138
|
+
* request the same way the session resolves a local action — `evaluate()`
|
|
139
|
+
* against its recorded authority (`ServingPolicy`), then escalation to its
|
|
140
|
+
* selected `Authorizer` (`AskEscalator`) on `ask`.
|
|
87
141
|
*/
|
|
88
142
|
export class ForwardedRequestServer implements InboxProcessor {
|
|
89
143
|
private readonly forwardingDir: string;
|
|
90
|
-
private readonly events: PermissionEventBus | undefined;
|
|
91
144
|
private readonly logger: DebugReviewLogger;
|
|
92
|
-
private readonly
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
options?: RequestPermissionOptions,
|
|
97
|
-
) => Promise<PermissionPromptDecision>;
|
|
98
|
-
private readonly config: ConfigReader;
|
|
145
|
+
private readonly policy: ServingPolicy;
|
|
146
|
+
private readonly escalator: AskEscalator;
|
|
147
|
+
private readonly recorder: SessionApprovalRecorder;
|
|
148
|
+
private readonly registry: SubagentSessionRegistry | undefined;
|
|
99
149
|
|
|
100
150
|
constructor(deps: ForwardedRequestServerDeps) {
|
|
101
151
|
this.forwardingDir = deps.forwardingDir;
|
|
102
|
-
this.events = deps.events;
|
|
103
152
|
this.logger = deps.logger;
|
|
104
|
-
this.
|
|
105
|
-
this.
|
|
153
|
+
this.policy = deps.policy;
|
|
154
|
+
this.escalator = deps.escalator;
|
|
155
|
+
this.recorder = deps.recorder;
|
|
156
|
+
this.registry = deps.registry;
|
|
106
157
|
}
|
|
107
158
|
|
|
108
159
|
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
109
160
|
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
110
|
-
if (!ctx.hasUI) {
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
161
|
const currentSessionId = getSessionId(ctx);
|
|
115
162
|
const location = getExistingPermissionForwardingLocation(
|
|
116
163
|
this.forwardingDir,
|
|
@@ -152,7 +199,6 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
152
199
|
}
|
|
153
200
|
|
|
154
201
|
await this.processSingleForwardedRequest(
|
|
155
|
-
ctx,
|
|
156
202
|
request,
|
|
157
203
|
location,
|
|
158
204
|
requestPath,
|
|
@@ -166,7 +212,6 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
166
212
|
// ── Private methods ────────────────────────────────────────────────────
|
|
167
213
|
|
|
168
214
|
private async processSingleForwardedRequest(
|
|
169
|
-
ctx: ForwarderContext,
|
|
170
215
|
request: ForwardedPermissionRequest,
|
|
171
216
|
location: PermissionForwardingLocation,
|
|
172
217
|
requestPath: string,
|
|
@@ -185,6 +230,8 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
185
230
|
return;
|
|
186
231
|
}
|
|
187
232
|
|
|
233
|
+
this.warnOnMultiHop(request, currentSessionId);
|
|
234
|
+
|
|
188
235
|
const forwardedPermissionLogDetails = {
|
|
189
236
|
requestId: request.id,
|
|
190
237
|
source: location.label,
|
|
@@ -194,55 +241,66 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
194
241
|
requestPath,
|
|
195
242
|
};
|
|
196
243
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
244
|
+
const decision = await this.resolveDecision(
|
|
245
|
+
request,
|
|
246
|
+
forwardedPermissionLogDetails,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
this.recordForwardedDecision(
|
|
250
|
+
request,
|
|
251
|
+
location,
|
|
252
|
+
requestPath,
|
|
253
|
+
currentSessionId,
|
|
254
|
+
this.applyGrantScope(request, decision, forwardedPermissionLogDetails),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Apply the human's grant-scope choice on a forwarded approval.
|
|
260
|
+
*
|
|
261
|
+
* A whole-session grant (`approved_for_serving_session`) records the child's
|
|
262
|
+
* suggested pattern into this serving node's `SessionRules` — the single
|
|
263
|
+
* source of truth for the scope — and is then translated to a plain
|
|
264
|
+
* `approved` so the child records nothing (its next identical action
|
|
265
|
+
* re-forwards and resolves as recorded authority). Every other decision
|
|
266
|
+
* passes through unchanged (`approved_for_session` → the child records).
|
|
267
|
+
*/
|
|
268
|
+
private applyGrantScope(
|
|
269
|
+
request: ForwardedPermissionRequest,
|
|
270
|
+
decision: PermissionPromptDecision,
|
|
271
|
+
logDetails: Record<string, unknown>,
|
|
272
|
+
): PermissionPromptDecision {
|
|
273
|
+
if (decision.state !== "approved_for_serving_session") {
|
|
274
|
+
return decision;
|
|
275
|
+
}
|
|
276
|
+
if (request.sessionApproval) {
|
|
277
|
+
this.recorder.recordSessionApproval(
|
|
278
|
+
SessionApproval.multiple(
|
|
279
|
+
request.sessionApproval.surface,
|
|
280
|
+
request.sessionApproval.patterns,
|
|
281
|
+
),
|
|
214
282
|
);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
buildForwardedUiPrompt({
|
|
221
|
-
requestId: request.id,
|
|
222
|
-
message: forwardedMessage,
|
|
223
|
-
requesterAgentName: request.requesterAgentName || null,
|
|
224
|
-
requesterSessionId: request.requesterSessionId || null,
|
|
225
|
-
source: request.source ?? null,
|
|
226
|
-
surface: request.surface ?? null,
|
|
227
|
-
value: request.value ?? null,
|
|
228
|
-
}),
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
decision = await this.requestPermissionDecisionFromUi(
|
|
232
|
-
ctx.ui,
|
|
233
|
-
"Permission Required (Subagent)",
|
|
234
|
-
forwardedMessage,
|
|
235
|
-
);
|
|
236
|
-
} catch (error) {
|
|
237
|
-
logPermissionForwardingError(
|
|
238
|
-
this.logger,
|
|
239
|
-
"Failed to show forwarded permission confirmation dialog",
|
|
240
|
-
error,
|
|
241
|
-
);
|
|
242
|
-
decision = { approved: false, state: "denied" };
|
|
243
|
-
}
|
|
283
|
+
this.logger.review("forwarded_permission.session_recorded", {
|
|
284
|
+
...logDetails,
|
|
285
|
+
surface: request.sessionApproval.surface,
|
|
286
|
+
patterns: request.sessionApproval.patterns,
|
|
287
|
+
});
|
|
244
288
|
}
|
|
289
|
+
return { approved: true, state: "approved" };
|
|
290
|
+
}
|
|
245
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Persist the served decision: write the response file the child polls for,
|
|
294
|
+
* log the outcome, and delete the drained request. The symmetric "respond"
|
|
295
|
+
* half to {@link resolveDecision}'s "decide" half.
|
|
296
|
+
*/
|
|
297
|
+
private recordForwardedDecision(
|
|
298
|
+
request: ForwardedPermissionRequest,
|
|
299
|
+
location: PermissionForwardingLocation,
|
|
300
|
+
requestPath: string,
|
|
301
|
+
currentSessionId: string,
|
|
302
|
+
decision: PermissionPromptDecision,
|
|
303
|
+
): void {
|
|
246
304
|
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
247
305
|
this.logger.review(
|
|
248
306
|
decision.approved
|
|
@@ -282,4 +340,68 @@ export class ForwardedRequestServer implements InboxProcessor {
|
|
|
282
340
|
`${location.label} forwarded permission request`,
|
|
283
341
|
);
|
|
284
342
|
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolve the request the same way the session resolves a local action:
|
|
346
|
+
* recorded authority first (a request carrying `(surface, value)` resolves
|
|
347
|
+
* against the serving node's composed ruleset — `allow`, including
|
|
348
|
+
* yolo-rewritten, auto-approves; `deny` auto-denies), then escalate `ask`
|
|
349
|
+
* (or a request without display fields) to the selected `Authorizer`.
|
|
350
|
+
*/
|
|
351
|
+
private async resolveDecision(
|
|
352
|
+
request: ForwardedPermissionRequest,
|
|
353
|
+
logDetails: Record<string, unknown>,
|
|
354
|
+
): Promise<PermissionPromptDecision> {
|
|
355
|
+
const state = hasDisplayFields(request)
|
|
356
|
+
? this.policy.check(request.surface, request.value).state
|
|
357
|
+
: "ask";
|
|
358
|
+
|
|
359
|
+
if (state === "allow") {
|
|
360
|
+
this.logger.review("forwarded_permission.auto_approved", logDetails);
|
|
361
|
+
return { approved: true, state: "approved" };
|
|
362
|
+
}
|
|
363
|
+
if (state === "deny") {
|
|
364
|
+
this.logger.review("forwarded_permission.auto_denied", logDetails);
|
|
365
|
+
return { approved: false, state: "denied" };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.logger.review("forwarded_permission.prompted", logDetails);
|
|
369
|
+
try {
|
|
370
|
+
return await this.escalator.escalate(buildForwardedAskDetails(request));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
logPermissionForwardingError(
|
|
373
|
+
this.logger,
|
|
374
|
+
`Failed to escalate forwarded permission request '${request.id}'`,
|
|
375
|
+
error,
|
|
376
|
+
);
|
|
377
|
+
return { approved: false, state: "denied" };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* One-hop canary: forwarding is depth-1 (child → root). If the requester is
|
|
383
|
+
* itself a registered subagent whose parent is not this serving session, the
|
|
384
|
+
* request came through more than one hop (or was misrouted) — resolution is
|
|
385
|
+
* still well-defined, so keep serving, but warn loudly so a future
|
|
386
|
+
* recursion-guard break is visible rather than silent. Unregistered
|
|
387
|
+
* (external file-based) requesters have no recorded parent and are silent.
|
|
388
|
+
*/
|
|
389
|
+
private warnOnMultiHop(
|
|
390
|
+
request: ForwardedPermissionRequest,
|
|
391
|
+
currentSessionId: string,
|
|
392
|
+
): void {
|
|
393
|
+
const requesterInfo = this.registry?.get(request.requesterSessionId);
|
|
394
|
+
if (
|
|
395
|
+
requesterInfo?.parentSessionId &&
|
|
396
|
+
requesterInfo.parentSessionId !== currentSessionId
|
|
397
|
+
) {
|
|
398
|
+
logPermissionForwardingWarning(
|
|
399
|
+
this.logger,
|
|
400
|
+
`Forwarded permission request '${request.id}' violates the one-hop ` +
|
|
401
|
+
`invariant: requester '${request.requesterSessionId}' is a registered ` +
|
|
402
|
+
`subagent whose parent '${requesterInfo.parentSessionId}' is not this ` +
|
|
403
|
+
`serving session '${currentSessionId}' (multi-hop or misrouted).`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
285
407
|
}
|