@gotgenes/pi-permission-system 9.2.0 → 10.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 +26 -0
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/forwarded-permissions/io.ts +29 -0
- package/src/forwarded-permissions/polling.ts +34 -2
- package/src/index.ts +2 -0
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +22 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/service.ts +17 -0
- package/test/composition-root.test.ts +5 -0
- package/test/permission-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarding.test.ts +282 -0
- package/test/permission-prompter.test.ts +120 -0
- package/test/permission-ui-prompt.test.ts +146 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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
|
+
## [10.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.2.0...pi-permission-system-v10.0.0) (2026-06-02)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** the permissions:ready event payload no longer includes protocolVersion. Consumers that read it must rely on package semver instead.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **pi-permission-manager:** broadcast permission prompts on permissions:prompt channel ([8540f3b](https://github.com/gotgenes/pi-packages/commit/8540f3b462b76a4789c4c17a75fadf254ae39feb))
|
|
18
|
+
* **pi-permission-system:** drop protocolVersion from permissions:ready ([6728a93](https://github.com/gotgenes/pi-packages/commit/6728a93af7edbc6953d20f448f1c3f54f9b7893f))
|
|
19
|
+
* **pi-permission-system:** harden prompt broadcasts ([067bafd](https://github.com/gotgenes/pi-packages/commit/067bafd80ef983fd8b9ab00914cf1cec9b6db915))
|
|
20
|
+
* **pi-permission-system:** make ready and decision broadcasts best-effort ([00a895f](https://github.com/gotgenes/pi-packages/commit/00a895f9377bcb7b598acbc6c95d7bc7cc83c515))
|
|
21
|
+
* **pi-permission-system:** preserve display fields for forwarded prompts ([9970912](https://github.com/gotgenes/pi-packages/commit/997091228736bbd4395d8bd16aeb9f4a4ae7e0b2))
|
|
22
|
+
* **pi-permission-system:** slim ui_prompt payload and centralize construction ([7a1ec56](https://github.com/gotgenes/pi-packages/commit/7a1ec56a827e90fe80a0f7de48e1222b5271700d))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** drop manual CHANGELOG Unreleased section ([f14e4f5](https://github.com/gotgenes/pi-packages/commit/f14e4f5d9b5ae1d6b207a913aefdd71980a46dd6))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* **pi-permission-system:** document the lean ui_prompt contract ([0b3c11c](https://github.com/gotgenes/pi-packages/commit/0b3c11c57a7b718d2f73a184802f8c5dcb95fbe7))
|
|
33
|
+
|
|
8
34
|
## [9.2.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v9.1.0...pi-permission-system-v9.2.0) (2026-06-02)
|
|
9
35
|
|
|
10
36
|
|
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
20
20
|
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
22
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
23
|
+
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI
|
|
23
24
|
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
|
24
25
|
|
|
25
26
|
## Install
|
|
@@ -89,16 +90,16 @@ For the full reference — all surfaces, runtime knobs, per-agent overrides, mer
|
|
|
89
90
|
|
|
90
91
|
## Documentation
|
|
91
92
|
|
|
92
|
-
| Document | Contents
|
|
93
|
-
| ------------------------------------------------------------------------------------------------------------------------------ |
|
|
94
|
-
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes
|
|
95
|
-
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table
|
|
96
|
-
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, decision broadcasts |
|
|
97
|
-
| [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions
|
|
98
|
-
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors
|
|
99
|
-
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide
|
|
100
|
-
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model
|
|
101
|
-
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout
|
|
93
|
+
| Document | Contents |
|
|
94
|
+
| ------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- |
|
|
95
|
+
| [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
|
|
96
|
+
| [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
|
|
97
|
+
| [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
|
|
98
|
+
| [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
|
|
99
|
+
| [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
|
|
100
|
+
| [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
|
|
101
|
+
| [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
|
|
102
|
+
| [docs/migration/legacy-to-flat.md](docs/migration/legacy-to-flat.md) | Migration from pre-v2 config layout |
|
|
102
103
|
|
|
103
104
|
## Development
|
|
104
105
|
|
package/package.json
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "node:fs";
|
|
11
11
|
|
|
12
12
|
import { isPermissionDecisionState } from "#src/permission-dialog";
|
|
13
|
+
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
13
14
|
import {
|
|
14
15
|
createPermissionForwardingLocation,
|
|
15
16
|
type ForwardedPermissionRequest,
|
|
@@ -17,6 +18,29 @@ import {
|
|
|
17
18
|
type PermissionForwardingLocation,
|
|
18
19
|
} from "#src/permission-forwarding";
|
|
19
20
|
|
|
21
|
+
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
22
|
+
const UI_PROMPT_SOURCES = [
|
|
23
|
+
"tool_call",
|
|
24
|
+
"skill_input",
|
|
25
|
+
"skill_read",
|
|
26
|
+
"rpc_prompt",
|
|
27
|
+
] as const satisfies readonly PermissionUiPromptSource[];
|
|
28
|
+
|
|
29
|
+
/** Narrow an unknown value to a valid prompt source, or `undefined`. */
|
|
30
|
+
function asUiPromptSource(
|
|
31
|
+
value: unknown,
|
|
32
|
+
): PermissionUiPromptSource | undefined {
|
|
33
|
+
return UI_PROMPT_SOURCES.find((source) => source === value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Narrow an unknown value to a nullable display string, or `undefined`. */
|
|
37
|
+
function asNullableDisplayString(value: unknown): string | null | undefined {
|
|
38
|
+
if (value === null || typeof value === "string") {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
20
44
|
type LogFn = (event: string, details: Record<string, unknown>) => void;
|
|
21
45
|
|
|
22
46
|
export interface ForwardedPermissionLogger {
|
|
@@ -285,6 +309,11 @@ export function readForwardedPermissionRequest(
|
|
|
285
309
|
targetSessionId: parsed.targetSessionId,
|
|
286
310
|
requesterAgentName: parsed.requesterAgentName,
|
|
287
311
|
message: parsed.message,
|
|
312
|
+
// Tolerant read: display fields are optional and may be absent (older
|
|
313
|
+
// child) or malformed; reconstruct only the well-formed ones.
|
|
314
|
+
source: asUiPromptSource(parsed.source),
|
|
315
|
+
surface: asNullableDisplayString(parsed.surface),
|
|
316
|
+
value: asNullableDisplayString(parsed.value),
|
|
288
317
|
};
|
|
289
318
|
} catch (error) {
|
|
290
319
|
logPermissionForwardingWarning(
|
|
@@ -11,15 +11,21 @@ import type {
|
|
|
11
11
|
PermissionPromptDecision,
|
|
12
12
|
RequestPermissionOptions,
|
|
13
13
|
} from "#src/permission-dialog";
|
|
14
|
+
import {
|
|
15
|
+
emitUiPromptEvent,
|
|
16
|
+
type PermissionEventBus,
|
|
17
|
+
} from "#src/permission-events";
|
|
14
18
|
import {
|
|
15
19
|
type ForwardedPermissionRequest,
|
|
16
20
|
type ForwardedPermissionResponse,
|
|
21
|
+
type ForwardedPromptDisplay,
|
|
17
22
|
isForwardedPermissionRequestForSession,
|
|
18
23
|
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
19
24
|
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
20
25
|
resolvePermissionForwardingTargetSessionId,
|
|
21
26
|
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
22
27
|
} from "#src/permission-forwarding";
|
|
28
|
+
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
23
29
|
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
24
30
|
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
25
31
|
|
|
@@ -43,6 +49,8 @@ export interface PermissionForwardingDeps {
|
|
|
43
49
|
subagentSessionsDir: string;
|
|
44
50
|
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
45
51
|
registry?: SubagentSessionRegistry;
|
|
52
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
53
|
+
events?: PermissionEventBus;
|
|
46
54
|
logger: ForwardedPermissionLogger;
|
|
47
55
|
writeReviewLog: (event: string, details: Record<string, unknown>) => void;
|
|
48
56
|
requestPermissionDecisionFromUi: (
|
|
@@ -103,6 +111,7 @@ export async function waitForForwardedPermissionApproval(
|
|
|
103
111
|
ctx: ExtensionContext,
|
|
104
112
|
message: string,
|
|
105
113
|
deps: PermissionForwardingDeps,
|
|
114
|
+
forwarded?: ForwardedPromptDisplay,
|
|
106
115
|
): Promise<PermissionPromptDecision> {
|
|
107
116
|
const requesterSessionId = getSessionId(ctx);
|
|
108
117
|
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
@@ -155,6 +164,13 @@ export async function waitForForwardedPermissionApproval(
|
|
|
155
164
|
targetSessionId,
|
|
156
165
|
requesterAgentName,
|
|
157
166
|
message,
|
|
167
|
+
...(forwarded
|
|
168
|
+
? {
|
|
169
|
+
source: forwarded.source,
|
|
170
|
+
surface: forwarded.surface,
|
|
171
|
+
value: forwarded.value,
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
158
174
|
};
|
|
159
175
|
|
|
160
176
|
const requestPath = join(location.requestsDir, `${requestId}.json`);
|
|
@@ -296,10 +312,25 @@ export async function processForwardedPermissionRequests(
|
|
|
296
312
|
forwardedPermissionLogDetails,
|
|
297
313
|
);
|
|
298
314
|
try {
|
|
315
|
+
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
316
|
+
if (deps.events) {
|
|
317
|
+
emitUiPromptEvent(
|
|
318
|
+
deps.events,
|
|
319
|
+
buildForwardedUiPrompt({
|
|
320
|
+
requestId: request.id,
|
|
321
|
+
message: forwardedMessage,
|
|
322
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
323
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
324
|
+
source: request.source ?? null,
|
|
325
|
+
surface: request.surface ?? null,
|
|
326
|
+
value: request.value ?? null,
|
|
327
|
+
}),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
299
330
|
decision = await deps.requestPermissionDecisionFromUi(
|
|
300
331
|
ctx.ui,
|
|
301
332
|
"Permission Required (Subagent)",
|
|
302
|
-
|
|
333
|
+
forwardedMessage,
|
|
303
334
|
);
|
|
304
335
|
} catch (error) {
|
|
305
336
|
logPermissionForwardingError(
|
|
@@ -359,6 +390,7 @@ export async function confirmPermission(
|
|
|
359
390
|
message: string,
|
|
360
391
|
deps: PermissionForwardingDeps,
|
|
361
392
|
options?: RequestPermissionOptions,
|
|
393
|
+
forwarded?: ForwardedPromptDisplay,
|
|
362
394
|
): Promise<PermissionPromptDecision> {
|
|
363
395
|
if (ctx.hasUI) {
|
|
364
396
|
return deps.requestPermissionDecisionFromUi(
|
|
@@ -375,5 +407,5 @@ export async function confirmPermission(
|
|
|
375
407
|
return { approved: false, state: "denied" };
|
|
376
408
|
}
|
|
377
409
|
|
|
378
|
-
return waitForForwardedPermissionApproval(ctx, message, deps);
|
|
410
|
+
return waitForForwardedPermissionApproval(ctx, message, deps, forwarded);
|
|
379
411
|
}
|
package/src/index.ts
CHANGED
|
@@ -54,6 +54,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
54
54
|
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
55
55
|
forwardingDir: runtime.forwardingDir,
|
|
56
56
|
registry: subagentRegistry,
|
|
57
|
+
events: pi.events,
|
|
57
58
|
requestPermissionDecisionFromUi,
|
|
58
59
|
});
|
|
59
60
|
|
|
@@ -61,6 +62,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
61
62
|
forwardingDir: runtime.forwardingDir,
|
|
62
63
|
subagentSessionsDir: runtime.subagentSessionsDir,
|
|
63
64
|
registry: subagentRegistry,
|
|
65
|
+
events: pi.events,
|
|
64
66
|
logger: {
|
|
65
67
|
writeReviewLog: runtime.writeReviewLog.bind(runtime),
|
|
66
68
|
writeDebugLog: runtime.writeDebugLog.bind(runtime),
|
|
@@ -21,11 +21,13 @@ import type {
|
|
|
21
21
|
PermissionsRpcReply,
|
|
22
22
|
} from "./permission-events";
|
|
23
23
|
import {
|
|
24
|
+
emitUiPromptEvent,
|
|
24
25
|
PERMISSIONS_PROTOCOL_VERSION,
|
|
25
26
|
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
26
27
|
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
27
28
|
} from "./permission-events";
|
|
28
29
|
import type { PermissionManager } from "./permission-manager";
|
|
30
|
+
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
29
31
|
import type { Rule } from "./rule";
|
|
30
32
|
|
|
31
33
|
/** Dependencies injected into the RPC handler registry. */
|
|
@@ -155,6 +157,11 @@ async function handlePromptRpc(
|
|
|
155
157
|
? `Permission request${agentName ? ` from ${agentName}` : ""}`
|
|
156
158
|
: "Permission request";
|
|
157
159
|
|
|
160
|
+
emitUiPromptEvent(
|
|
161
|
+
events,
|
|
162
|
+
buildRpcUiPrompt({ requestId, surface, value, agentName, message }),
|
|
163
|
+
);
|
|
164
|
+
|
|
158
165
|
const decision = await deps.requestPermissionDecisionFromUi(
|
|
159
166
|
ctx.ui,
|
|
160
167
|
title,
|
package/src/permission-events.ts
CHANGED
|
@@ -27,6 +27,9 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
|
|
|
27
27
|
/** Emitted at `session_start`, after the service is published. */
|
|
28
28
|
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
29
29
|
|
|
30
|
+
/** Emitted when a permission request is committed to the active UI prompt path. */
|
|
31
|
+
export const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
|
32
|
+
|
|
30
33
|
/** Emitted after every permission gate resolution. */
|
|
31
34
|
export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
32
35
|
|
|
@@ -61,9 +64,63 @@ export type PermissionsRpcReply<T = void> =
|
|
|
61
64
|
|
|
62
65
|
// ── permissions:ready ──────────────────────────────────────────────────────
|
|
63
66
|
|
|
64
|
-
/**
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
/**
|
|
68
|
+
* Payload emitted on `permissions:ready`.
|
|
69
|
+
*
|
|
70
|
+
* Intentionally empty: the channel is a readiness signal. Version negotiation
|
|
71
|
+
* lives in the RPC envelope (`PermissionsRpcReply`), not in broadcast payloads —
|
|
72
|
+
* the published types plus package semver define the broadcast contract.
|
|
73
|
+
*/
|
|
74
|
+
export type PermissionsReadyEvent = Record<string, never>;
|
|
75
|
+
|
|
76
|
+
// ── permissions:ui_prompt ──────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Origin of a UI prompt.
|
|
80
|
+
*
|
|
81
|
+
* Forwarding is orthogonal to origin: a forwarded subagent prompt keeps its
|
|
82
|
+
* original source and is identified by a non-null `forwarding` field, not by a
|
|
83
|
+
* dedicated source value.
|
|
84
|
+
*/
|
|
85
|
+
export type PermissionUiPromptSource =
|
|
86
|
+
| "tool_call"
|
|
87
|
+
| "skill_input"
|
|
88
|
+
| "skill_read"
|
|
89
|
+
| "rpc_prompt";
|
|
90
|
+
|
|
91
|
+
/** Forwarding context, present only when a prompt was forwarded from a non-UI subagent. */
|
|
92
|
+
export interface ForwardedPromptContext {
|
|
93
|
+
/** Requesting subagent's display name, when known. */
|
|
94
|
+
requesterAgentName: string | null;
|
|
95
|
+
/** Requesting subagent's session id, when known. */
|
|
96
|
+
requesterSessionId: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Payload emitted on `permissions:ui_prompt`, immediately before the active
|
|
101
|
+
* user-facing permission UI is shown.
|
|
102
|
+
*
|
|
103
|
+
* Lean by design: `surface`/`value` are the normalized display projection a
|
|
104
|
+
* notification consumer reads; `source` is the origin; `forwarding` is non-null
|
|
105
|
+
* only for forwarded subagent prompts. There is no `protocolVersion` — the
|
|
106
|
+
* published types plus package semver define the broadcast contract, and
|
|
107
|
+
* consumers should read defensively.
|
|
108
|
+
*/
|
|
109
|
+
export interface PermissionUiPromptEvent {
|
|
110
|
+
/** Unique ID for the permission request being prompted. */
|
|
111
|
+
requestId: string;
|
|
112
|
+
/** Prompt origin. */
|
|
113
|
+
source: PermissionUiPromptSource;
|
|
114
|
+
/** Normalized display surface (e.g. "bash", "skill"), when known. */
|
|
115
|
+
surface: string | null;
|
|
116
|
+
/** Normalized display value (command, path, skill name, etc.), when known. */
|
|
117
|
+
value: string | null;
|
|
118
|
+
/** Agent name (when known). */
|
|
119
|
+
agentName: string | null;
|
|
120
|
+
/** Message displayed to the user. */
|
|
121
|
+
message: string;
|
|
122
|
+
/** Forwarding context, or null for a direct prompt. */
|
|
123
|
+
forwarding: ForwardedPromptContext | null;
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
// ── permissions:decision ───────────────────────────────────────────────────
|
|
@@ -164,10 +221,29 @@ export interface PermissionsPromptReplyData {
|
|
|
164
221
|
* reacting to ready can immediately resolve `getPermissionsService()`.
|
|
165
222
|
*/
|
|
166
223
|
export function emitReadyEvent(events: PermissionEventBus): void {
|
|
167
|
-
const payload: PermissionsReadyEvent = {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
224
|
+
const payload: PermissionsReadyEvent = {};
|
|
225
|
+
try {
|
|
226
|
+
events.emit(PERMISSIONS_READY_CHANNEL, payload);
|
|
227
|
+
} catch {
|
|
228
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
229
|
+
// permission system from completing session startup.
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Emit a `permissions:ui_prompt` broadcast.
|
|
235
|
+
* Call immediately before invoking the active user-facing permission UI.
|
|
236
|
+
*/
|
|
237
|
+
export function emitUiPromptEvent(
|
|
238
|
+
events: PermissionEventBus,
|
|
239
|
+
event: PermissionUiPromptEvent,
|
|
240
|
+
): void {
|
|
241
|
+
try {
|
|
242
|
+
events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
|
|
243
|
+
} catch {
|
|
244
|
+
// UI-prompt broadcasts are observational. A consumer failure must not block
|
|
245
|
+
// the permission dialog itself.
|
|
246
|
+
}
|
|
171
247
|
}
|
|
172
248
|
|
|
173
249
|
/**
|
|
@@ -178,5 +254,10 @@ export function emitDecisionEvent(
|
|
|
178
254
|
events: PermissionEventBus,
|
|
179
255
|
event: PermissionDecisionEvent,
|
|
180
256
|
): void {
|
|
181
|
-
|
|
257
|
+
try {
|
|
258
|
+
events.emit(PERMISSIONS_DECISION_CHANNEL, event);
|
|
259
|
+
} catch {
|
|
260
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
261
|
+
// permission gate from resolving.
|
|
262
|
+
}
|
|
182
263
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
3
|
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
|
+
import type { PermissionUiPromptSource } from "./permission-events";
|
|
4
5
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
5
6
|
|
|
6
7
|
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
@@ -38,6 +39,19 @@ const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
|
|
38
39
|
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
|
39
40
|
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Display fields relayed from a forwarding child to the parent UI so the parent
|
|
44
|
+
* can emit a non-degraded `permissions:ui_prompt` event.
|
|
45
|
+
*
|
|
46
|
+
* Carried separately from the prompt message because the parent reconstructs
|
|
47
|
+
* the original event through `buildForwardedUiPrompt`, not from the message text.
|
|
48
|
+
*/
|
|
49
|
+
export interface ForwardedPromptDisplay {
|
|
50
|
+
source: PermissionUiPromptSource;
|
|
51
|
+
surface: string | null;
|
|
52
|
+
value: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
export type ForwardedPermissionRequest = {
|
|
42
56
|
id: string;
|
|
43
57
|
createdAt: number;
|
|
@@ -45,6 +59,15 @@ export type ForwardedPermissionRequest = {
|
|
|
45
59
|
targetSessionId: string;
|
|
46
60
|
requesterAgentName: string;
|
|
47
61
|
message: string;
|
|
62
|
+
/**
|
|
63
|
+
* Original prompt display fields, persisted so the parent emits a
|
|
64
|
+
* non-degraded event. Optional for version-skew tolerance: a parent on a
|
|
65
|
+
* newer version may read a request written by an older child during an
|
|
66
|
+
* upgrade, in which case the reader defaults `source` to `"tool_call"`.
|
|
67
|
+
*/
|
|
68
|
+
source?: PermissionUiPromptSource;
|
|
69
|
+
surface?: string | null;
|
|
70
|
+
value?: string | null;
|
|
48
71
|
};
|
|
49
72
|
|
|
50
73
|
export type ForwardedPermissionResponse = {
|
|
@@ -9,6 +9,11 @@ import type {
|
|
|
9
9
|
PermissionPromptDecision,
|
|
10
10
|
RequestPermissionOptions,
|
|
11
11
|
} from "./permission-dialog";
|
|
12
|
+
import {
|
|
13
|
+
emitUiPromptEvent,
|
|
14
|
+
type PermissionEventBus,
|
|
15
|
+
} from "./permission-events";
|
|
16
|
+
import { buildDirectUiPrompt } from "./permission-ui-prompt";
|
|
12
17
|
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
13
18
|
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
14
19
|
|
|
@@ -57,6 +62,8 @@ export interface PermissionPrompterDeps {
|
|
|
57
62
|
forwardingDir: string;
|
|
58
63
|
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
59
64
|
registry?: SubagentSessionRegistry;
|
|
65
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
66
|
+
events: PermissionEventBus;
|
|
60
67
|
/** Show the interactive permission dialog in the UI. */
|
|
61
68
|
requestPermissionDecisionFromUi(
|
|
62
69
|
ui: ExtensionContext["ui"],
|
|
@@ -91,11 +98,25 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
91
98
|
|
|
92
99
|
this.writeReviewEntry("permission_request.waiting", details);
|
|
93
100
|
|
|
101
|
+
// Build the event once. When this session has UI it broadcasts directly;
|
|
102
|
+
// when it does not (a forwarding subagent), the display fields ride along
|
|
103
|
+
// to the parent so the parent emits a non-degraded event from the
|
|
104
|
+
// forwarded path instead of here.
|
|
105
|
+
const uiPrompt = buildDirectUiPrompt(details);
|
|
106
|
+
if (ctx.hasUI) {
|
|
107
|
+
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
108
|
+
}
|
|
109
|
+
|
|
94
110
|
const decision = await confirmPermission(
|
|
95
111
|
ctx,
|
|
96
112
|
details.message,
|
|
97
113
|
this.buildForwardingDeps(),
|
|
98
114
|
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
115
|
+
{
|
|
116
|
+
source: uiPrompt.source,
|
|
117
|
+
surface: uiPrompt.surface,
|
|
118
|
+
value: uiPrompt.value,
|
|
119
|
+
},
|
|
99
120
|
);
|
|
100
121
|
|
|
101
122
|
this.writeReviewEntry(
|
|
@@ -159,6 +180,7 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
159
180
|
forwardingDir: deps.forwardingDir,
|
|
160
181
|
subagentSessionsDir: deps.subagentSessionsDir,
|
|
161
182
|
registry: deps.registry,
|
|
183
|
+
events: deps.events,
|
|
162
184
|
logger,
|
|
163
185
|
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
164
186
|
writeReviewLog: deps.writeReviewLog,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized construction for `permissions:ui_prompt` payloads.
|
|
3
|
+
*
|
|
4
|
+
* Every emit site builds its event through one of these functions, so the
|
|
5
|
+
* public contract's shape — including the normalized `surface`/`value`
|
|
6
|
+
* projection — lives in exactly one place and cannot drift by source.
|
|
7
|
+
*
|
|
8
|
+
* This module is a leaf: it owns narrow input types that each call site's
|
|
9
|
+
* domain object satisfies structurally, so it imports nothing from the
|
|
10
|
+
* prompter, RPC, or forwarding modules (no import cycles, correct layering).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
PermissionUiPromptEvent,
|
|
15
|
+
PermissionUiPromptSource,
|
|
16
|
+
} from "./permission-events";
|
|
17
|
+
|
|
18
|
+
/** Input for a direct (non-forwarded) tool or skill prompt. */
|
|
19
|
+
export interface DirectPromptInput {
|
|
20
|
+
requestId: string;
|
|
21
|
+
source: "tool_call" | "skill_input" | "skill_read";
|
|
22
|
+
agentName: string | null;
|
|
23
|
+
message: string;
|
|
24
|
+
toolName?: string;
|
|
25
|
+
skillName?: string;
|
|
26
|
+
path?: string;
|
|
27
|
+
command?: string;
|
|
28
|
+
target?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Input for a `permissions:rpc:prompt` forwarded UI prompt. */
|
|
32
|
+
export interface RpcPromptInput {
|
|
33
|
+
requestId: string;
|
|
34
|
+
surface?: string | null;
|
|
35
|
+
value?: string | null;
|
|
36
|
+
agentName?: string | null;
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Input for a file-forwarded subagent prompt shown by the parent UI. */
|
|
41
|
+
export interface ForwardedPromptInput {
|
|
42
|
+
requestId: string;
|
|
43
|
+
message: string;
|
|
44
|
+
requesterAgentName: string | null;
|
|
45
|
+
requesterSessionId: string | null;
|
|
46
|
+
/** Original prompt origin, when the forwarded request carries it. */
|
|
47
|
+
source?: PermissionUiPromptSource | null;
|
|
48
|
+
/** Original normalized surface, when the forwarded request carries it. */
|
|
49
|
+
surface?: string | null;
|
|
50
|
+
/** Original normalized value, when the forwarded request carries it. */
|
|
51
|
+
value?: string | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Normalized display surface for a direct prompt. */
|
|
55
|
+
function directSurface(input: DirectPromptInput): string | null {
|
|
56
|
+
if (input.source === "skill_input" || input.source === "skill_read") {
|
|
57
|
+
return "skill";
|
|
58
|
+
}
|
|
59
|
+
return input.toolName ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Normalized display value for a direct prompt. */
|
|
63
|
+
function directValue(input: DirectPromptInput): string | null {
|
|
64
|
+
return (
|
|
65
|
+
input.command ??
|
|
66
|
+
input.path ??
|
|
67
|
+
input.target ??
|
|
68
|
+
input.skillName ??
|
|
69
|
+
input.toolName ??
|
|
70
|
+
null
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Build the UI prompt event for a direct tool/skill prompt. */
|
|
75
|
+
export function buildDirectUiPrompt(
|
|
76
|
+
input: DirectPromptInput,
|
|
77
|
+
): PermissionUiPromptEvent {
|
|
78
|
+
return {
|
|
79
|
+
requestId: input.requestId,
|
|
80
|
+
source: input.source,
|
|
81
|
+
surface: directSurface(input),
|
|
82
|
+
value: directValue(input),
|
|
83
|
+
agentName: input.agentName,
|
|
84
|
+
message: input.message,
|
|
85
|
+
forwarding: null,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build the UI prompt event for an RPC-forwarded prompt. */
|
|
90
|
+
export function buildRpcUiPrompt(
|
|
91
|
+
input: RpcPromptInput,
|
|
92
|
+
): PermissionUiPromptEvent {
|
|
93
|
+
return {
|
|
94
|
+
requestId: input.requestId,
|
|
95
|
+
source: "rpc_prompt",
|
|
96
|
+
surface: input.surface ?? null,
|
|
97
|
+
value: input.value ?? null,
|
|
98
|
+
agentName: input.agentName ?? null,
|
|
99
|
+
message: input.message,
|
|
100
|
+
forwarding: null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the UI prompt event for a file-forwarded subagent prompt.
|
|
106
|
+
*
|
|
107
|
+
* `source` defaults to `"tool_call"` (the dominant forwarded origin) when the
|
|
108
|
+
* persisted request predates carrying it — a parent on a newer version may read
|
|
109
|
+
* a request written by an older child during an upgrade. The consumer still
|
|
110
|
+
* receives the notify-now signal, message, and forwarding context.
|
|
111
|
+
*/
|
|
112
|
+
export function buildForwardedUiPrompt(
|
|
113
|
+
input: ForwardedPromptInput,
|
|
114
|
+
): PermissionUiPromptEvent {
|
|
115
|
+
return {
|
|
116
|
+
requestId: input.requestId,
|
|
117
|
+
source: input.source ?? "tool_call",
|
|
118
|
+
surface: input.surface ?? null,
|
|
119
|
+
value: input.value ?? null,
|
|
120
|
+
agentName: input.requesterAgentName,
|
|
121
|
+
message: input.message,
|
|
122
|
+
forwarding: {
|
|
123
|
+
requesterAgentName: input.requesterAgentName,
|
|
124
|
+
requesterSessionId: input.requesterSessionId,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/service.ts
CHANGED
|
@@ -14,6 +14,23 @@
|
|
|
14
14
|
import type { ToolInputFormatter } from "./tool-input-formatter-registry";
|
|
15
15
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
16
16
|
|
|
17
|
+
export type {
|
|
18
|
+
ForwardedPromptContext,
|
|
19
|
+
PermissionDecisionEvent,
|
|
20
|
+
PermissionsPromptReplyData,
|
|
21
|
+
PermissionsPromptRequest,
|
|
22
|
+
PermissionsReadyEvent,
|
|
23
|
+
PermissionsRpcReply,
|
|
24
|
+
PermissionUiPromptEvent,
|
|
25
|
+
PermissionUiPromptSource,
|
|
26
|
+
} from "./permission-events";
|
|
27
|
+
export {
|
|
28
|
+
PERMISSIONS_DECISION_CHANNEL,
|
|
29
|
+
PERMISSIONS_PROTOCOL_VERSION,
|
|
30
|
+
PERMISSIONS_READY_CHANNEL,
|
|
31
|
+
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
32
|
+
PERMISSIONS_UI_PROMPT_CHANNEL,
|
|
33
|
+
} from "./permission-events";
|
|
17
34
|
export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
|
|
18
35
|
|
|
19
36
|
/** Process-global key for the service slot. */
|
|
@@ -256,6 +256,11 @@ describe("subagent registry sharing across factory instances", () => {
|
|
|
256
256
|
);
|
|
257
257
|
expect(request.targetSessionId).toBe(parentSessionId);
|
|
258
258
|
expect(request.requesterSessionId).toBe(childSessionId);
|
|
259
|
+
// The child persists the original display fields so the parent emits a
|
|
260
|
+
// non-degraded `permissions:ui_prompt` event (forwarded non-degradation).
|
|
261
|
+
expect(request.source).toBe("tool_call");
|
|
262
|
+
expect(request.surface).toBe("read");
|
|
263
|
+
expect(request.value).toBe(join(externalDir, "secret.txt"));
|
|
259
264
|
|
|
260
265
|
const result = (await firePromise) as { block?: true };
|
|
261
266
|
expect(result.block).toBeUndefined();
|