@komarspn/pi-permission-system 16.0.2
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 +2234 -0
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/config/config.example.json +39 -0
- package/package.json +82 -0
- package/schemas/permissions.schema.json +158 -0
- package/src/active-agent.ts +72 -0
- package/src/async-cache.ts +21 -0
- package/src/bash-arity.ts +210 -0
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/canonicalize-path.ts +30 -0
- package/src/common.ts +121 -0
- package/src/config-loader.ts +432 -0
- package/src/config-modal.ts +259 -0
- package/src/config-paths.ts +47 -0
- package/src/config-reporter.ts +34 -0
- package/src/config-store.ts +222 -0
- package/src/decision-audit.ts +75 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +232 -0
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +79 -0
- package/src/extension-paths.ts +66 -0
- package/src/forwarded-permissions/io.ts +404 -0
- package/src/forwarded-permissions/permission-forwarder.ts +580 -0
- package/src/forwarding-manager.ts +74 -0
- package/src/gate-prompter.ts +12 -0
- package/src/handlers/before-agent-start.ts +94 -0
- package/src/handlers/gates/bash-command.ts +75 -0
- package/src/handlers/gates/bash-external-directory.ts +127 -0
- package/src/handlers/gates/bash-path-extractor.ts +15 -0
- package/src/handlers/gates/bash-path.ts +152 -0
- package/src/handlers/gates/bash-program.ts +1143 -0
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/gates/descriptor.ts +81 -0
- package/src/handlers/gates/external-directory-messages.ts +20 -0
- package/src/handlers/gates/external-directory.ts +133 -0
- package/src/handlers/gates/helpers.ts +76 -0
- package/src/handlers/gates/path.ts +91 -0
- package/src/handlers/gates/runner.ts +186 -0
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +46 -0
- package/src/handlers/gates/skill-read.ts +87 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
- package/src/handlers/gates/tool.ts +102 -0
- package/src/handlers/gates/types.ts +13 -0
- package/src/handlers/index.ts +3 -0
- package/src/handlers/lifecycle.ts +95 -0
- package/src/handlers/permission-gate-handler.ts +190 -0
- package/src/handlers/tool-call-boundary.ts +91 -0
- package/src/index.ts +225 -0
- package/src/input-normalizer.ts +157 -0
- package/src/logging.ts +113 -0
- package/src/mcp-targets.ts +170 -0
- package/src/node-modules-discovery.ts +76 -0
- package/src/normalize.ts +43 -0
- package/src/path-utils.ts +355 -0
- package/src/pattern-suggest.ts +132 -0
- package/src/permission-dialog.ts +138 -0
- package/src/permission-event-rpc.ts +223 -0
- package/src/permission-events.ts +266 -0
- package/src/permission-forwarding.ts +188 -0
- package/src/permission-gate.ts +94 -0
- package/src/permission-manager.ts +392 -0
- package/src/permission-merge.ts +32 -0
- package/src/permission-prompter.ts +142 -0
- package/src/permission-prompts.ts +93 -0
- package/src/permission-resolver.ts +109 -0
- package/src/permission-session.ts +189 -0
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +63 -0
- package/src/persistent-approval-recorder.ts +139 -0
- package/src/policy-loader.ts +350 -0
- package/src/prompting-gateway.ts +104 -0
- package/src/rule.ts +188 -0
- package/src/scope-merge.ts +72 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +163 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-approval.ts +43 -0
- package/src/session-logger.ts +91 -0
- package/src/session-rules.ts +79 -0
- package/src/skill-prompt-sanitizer.ts +292 -0
- package/src/status.ts +35 -0
- package/src/subagent-context.ts +104 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/src/subagent-registry.ts +105 -0
- package/src/synthesize.ts +92 -0
- package/src/system-prompt-sanitizer.ts +274 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/src/tool-input-formatter-registry.ts +67 -0
- package/src/tool-input-preview.ts +34 -0
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +207 -0
- package/src/tool-registry.ts +148 -0
- package/src/types.ts +64 -0
- package/src/wildcard-matcher.ts +120 -0
- package/src/yolo-mode.ts +30 -0
- package/test/active-agent.test.ts +155 -0
- package/test/async-cache.test.ts +48 -0
- package/test/bash-arity.test.ts +144 -0
- package/test/bash-external-directory.test.ts +956 -0
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/canonicalize-path.test.ts +93 -0
- package/test/common.test.ts +287 -0
- package/test/composition-root.test.ts +603 -0
- package/test/config-loader.test.ts +740 -0
- package/test/config-modal.test.ts +320 -0
- package/test/config-paths.test.ts +83 -0
- package/test/config-pipeline.test.ts +90 -0
- package/test/config-reporter.test.ts +147 -0
- package/test/config-store.test.ts +466 -0
- package/test/decision-audit.test.ts +72 -0
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +656 -0
- package/test/detect-permissive-bash-fallback.test.ts +56 -0
- package/test/expand-home.test.ts +93 -0
- package/test/extension-config.test.ts +129 -0
- package/test/extension-paths.test.ts +108 -0
- package/test/forwarded-permissions/io.test.ts +251 -0
- package/test/forwarding-manager.test.ts +194 -0
- package/test/handlers/before-agent-start.test.ts +317 -0
- package/test/handlers/external-directory-integration.test.ts +623 -0
- package/test/handlers/external-directory-session-dedup.test.ts +430 -0
- package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
- package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
- package/test/handlers/gates/bash-command.test.ts +191 -0
- package/test/handlers/gates/bash-external-directory.test.ts +269 -0
- package/test/handlers/gates/bash-path.test.ts +337 -0
- package/test/handlers/gates/bash-program.test.ts +410 -0
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/gates/external-directory-messages.test.ts +61 -0
- package/test/handlers/gates/external-directory.test.ts +259 -0
- package/test/handlers/gates/helpers.test.ts +177 -0
- package/test/handlers/gates/path.test.ts +294 -0
- package/test/handlers/gates/runner.test.ts +447 -0
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +131 -0
- package/test/handlers/gates/skill-read.test.ts +158 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
- package/test/handlers/gates/tool.test.ts +223 -0
- package/test/handlers/input-events.test.ts +168 -0
- package/test/handlers/input.test.ts +199 -0
- package/test/handlers/lifecycle.test.ts +221 -0
- package/test/handlers/tool-call-boundary.test.ts +145 -0
- package/test/handlers/tool-call-events.test.ts +277 -0
- package/test/handlers/tool-call.test.ts +395 -0
- package/test/handlers/validate-requested-tool.test.ts +92 -0
- package/test/helpers/gate-fixtures.ts +323 -0
- package/test/helpers/handler-fixtures.ts +335 -0
- package/test/helpers/make-fake-pi.ts +100 -0
- package/test/helpers/manager-harness.ts +112 -0
- package/test/helpers/session-fixtures.ts +204 -0
- package/test/input-normalizer.test.ts +367 -0
- package/test/logging.test.ts +51 -0
- package/test/mcp-targets.test.ts +233 -0
- package/test/node-modules-discovery.test.ts +97 -0
- package/test/normalize.test.ts +247 -0
- package/test/path-utils.test.ts +650 -0
- package/test/pattern-suggest.test.ts +248 -0
- package/test/permission-dialog.test.ts +241 -0
- package/test/permission-event-rpc.test.ts +541 -0
- package/test/permission-events.test.ts +402 -0
- package/test/permission-forwarder.test.ts +369 -0
- package/test/permission-forwarding.test.ts +315 -0
- package/test/permission-gate.test.ts +305 -0
- package/test/permission-manager-unified.test.ts +3368 -0
- package/test/permission-merge.test.ts +61 -0
- package/test/permission-prompter.test.ts +518 -0
- package/test/permission-prompts.test.ts +363 -0
- package/test/permission-resolver.test.ts +265 -0
- package/test/permission-session.test.ts +363 -0
- package/test/permission-ui-prompt.test.ts +146 -0
- package/test/permissions-service.test.ts +177 -0
- package/test/persistent-approval-recorder.test.ts +133 -0
- package/test/pi-infrastructure-read.test.ts +369 -0
- package/test/policy-loader.test.ts +561 -0
- package/test/prompting-gateway.test.ts +230 -0
- package/test/rule.test.ts +604 -0
- package/test/scope-merge.test.ts +116 -0
- package/test/service-lifecycle.test.ts +163 -0
- package/test/service.test.ts +308 -0
- package/test/session-approval.test.ts +75 -0
- package/test/session-logger.test.ts +200 -0
- package/test/session-rules.test.ts +304 -0
- package/test/session-start.test.ts +112 -0
- package/test/skill-prompt-sanitizer.test.ts +374 -0
- package/test/status.test.ts +10 -0
- package/test/subagent-context.test.ts +326 -0
- package/test/subagent-lifecycle-events.test.ts +132 -0
- package/test/subagent-registry.test.ts +145 -0
- package/test/synthesize.test.ts +300 -0
- package/test/system-prompt-sanitizer.test.ts +382 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-input-preview.test.ts +129 -0
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/test/tool-preview-formatter.test.ts +458 -0
- package/test/tool-registry.test.ts +197 -0
- package/test/wildcard-matcher.test.ts +424 -0
- package/test/yolo-mode.test.ts +188 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-deprecated -- this module implements the deprecated event-bus RPC channel; references to its own deprecated symbols are intentional */
|
|
2
|
+
/**
|
|
3
|
+
* Permission event bus RPC handlers.
|
|
4
|
+
*
|
|
5
|
+
* Registers `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
|
|
6
|
+
* the Pi event bus so other extensions can query our policy and forward
|
|
7
|
+
* permission prompts without importing this package.
|
|
8
|
+
*/
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { buildInputForSurface } from "./input-normalizer";
|
|
11
|
+
import type {
|
|
12
|
+
PermissionPromptDecision,
|
|
13
|
+
RequestPermissionOptions,
|
|
14
|
+
} from "./permission-dialog";
|
|
15
|
+
import type {
|
|
16
|
+
PermissionEventBus,
|
|
17
|
+
PermissionsCheckReplyData,
|
|
18
|
+
PermissionsCheckRequest,
|
|
19
|
+
PermissionsPromptReplyData,
|
|
20
|
+
PermissionsPromptRequest,
|
|
21
|
+
PermissionsRpcReply,
|
|
22
|
+
} from "./permission-events";
|
|
23
|
+
import {
|
|
24
|
+
emitUiPromptEvent,
|
|
25
|
+
PERMISSIONS_PROTOCOL_VERSION,
|
|
26
|
+
PERMISSIONS_RPC_CHECK_CHANNEL,
|
|
27
|
+
PERMISSIONS_RPC_PROMPT_CHANNEL,
|
|
28
|
+
} from "./permission-events";
|
|
29
|
+
import type { PermissionManager } from "./permission-manager";
|
|
30
|
+
import { buildRpcUiPrompt } from "./permission-ui-prompt";
|
|
31
|
+
import type { ReviewLogger } from "./session-logger";
|
|
32
|
+
import type { SessionRules } from "./session-rules";
|
|
33
|
+
|
|
34
|
+
/** Dependencies injected into the RPC handler registry. */
|
|
35
|
+
export interface PermissionRpcDeps {
|
|
36
|
+
/** The shared PermissionManager instance. */
|
|
37
|
+
permissionManager: Pick<PermissionManager, "checkPermission">;
|
|
38
|
+
/** The shared SessionRules instance. */
|
|
39
|
+
sessionRules: Pick<SessionRules, "getRuleset">;
|
|
40
|
+
/**
|
|
41
|
+
* Narrow session view: provides runtime context.
|
|
42
|
+
* Used by the prompt handler to check hasUI and access the UI dialog.
|
|
43
|
+
*/
|
|
44
|
+
session: { getRuntimeContext(): ExtensionContext | null };
|
|
45
|
+
/** Show the interactive permission dialog in the parent session UI. */
|
|
46
|
+
requestPermissionDecisionFromUi(
|
|
47
|
+
ui: ExtensionContext["ui"],
|
|
48
|
+
title: string,
|
|
49
|
+
message: string,
|
|
50
|
+
options?: RequestPermissionOptions,
|
|
51
|
+
): Promise<PermissionPromptDecision>;
|
|
52
|
+
/** Write review-log entries for prompted decisions. */
|
|
53
|
+
logger: ReviewLogger;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Unsubscribe handles returned from registerPermissionRpcHandlers. */
|
|
57
|
+
export interface PermissionRpcHandles {
|
|
58
|
+
/** Stop the permissions:rpc:check handler. */
|
|
59
|
+
unsubCheck: () => void;
|
|
60
|
+
/** Stop the permissions:rpc:prompt handler. */
|
|
61
|
+
unsubPrompt: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Internal helpers ───────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Build a success reply envelope. */
|
|
67
|
+
function successReply<T>(data?: T): PermissionsRpcReply<T> {
|
|
68
|
+
if (data !== undefined) {
|
|
69
|
+
return {
|
|
70
|
+
success: true,
|
|
71
|
+
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
72
|
+
data,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { success: true, protocolVersion: PERMISSIONS_PROTOCOL_VERSION };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build an error reply envelope. */
|
|
79
|
+
function errorReply(error: string): PermissionsRpcReply {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
|
|
83
|
+
error,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── RPC handler: permissions:rpc:check ────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
function handleCheckRpc(
|
|
90
|
+
raw: unknown,
|
|
91
|
+
events: PermissionEventBus,
|
|
92
|
+
deps: PermissionRpcDeps,
|
|
93
|
+
): void {
|
|
94
|
+
const req = raw as Partial<PermissionsCheckRequest>;
|
|
95
|
+
const { requestId, surface, value, agentName } = req;
|
|
96
|
+
|
|
97
|
+
if (typeof requestId !== "string" || !requestId) {
|
|
98
|
+
// Cannot reply without a requestId — silently discard.
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const replyChannel = `${PERMISSIONS_RPC_CHECK_CHANNEL}:reply:${requestId}`;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
if (typeof surface !== "string" || !surface) {
|
|
106
|
+
events.emit(replyChannel, errorReply("surface is required"));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const input = buildInputForSurface(surface, value);
|
|
111
|
+
const sessionRules = deps.sessionRules.getRuleset();
|
|
112
|
+
const result = deps.permissionManager.checkPermission(
|
|
113
|
+
surface,
|
|
114
|
+
input,
|
|
115
|
+
agentName ?? undefined,
|
|
116
|
+
sessionRules,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const data: PermissionsCheckReplyData = {
|
|
120
|
+
result: result.state,
|
|
121
|
+
matchedPattern: result.matchedPattern ?? null,
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- ?? null normalises undefined to null for the reply record
|
|
123
|
+
origin: result.origin ?? null,
|
|
124
|
+
};
|
|
125
|
+
events.emit(replyChannel, successReply(data));
|
|
126
|
+
} catch (err) {
|
|
127
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
128
|
+
events.emit(replyChannel, errorReply(message));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── RPC handler: permissions:rpc:prompt ───────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async function handlePromptRpc(
|
|
135
|
+
raw: unknown,
|
|
136
|
+
events: PermissionEventBus,
|
|
137
|
+
deps: PermissionRpcDeps,
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
const req = raw as Partial<PermissionsPromptRequest>;
|
|
140
|
+
const { requestId, surface, value, agentName, message, sessionLabel } = req;
|
|
141
|
+
|
|
142
|
+
if (typeof requestId !== "string" || !requestId) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const replyChannel = `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:${requestId}`;
|
|
147
|
+
|
|
148
|
+
const ctx = deps.session.getRuntimeContext();
|
|
149
|
+
if (!ctx?.hasUI) {
|
|
150
|
+
events.emit(replyChannel, errorReply("no_ui"));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (typeof message !== "string" || !message) {
|
|
155
|
+
events.emit(replyChannel, errorReply("message is required"));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const title = surface
|
|
161
|
+
? `Permission request${agentName ? ` from ${agentName}` : ""}`
|
|
162
|
+
: "Permission request";
|
|
163
|
+
|
|
164
|
+
emitUiPromptEvent(
|
|
165
|
+
events,
|
|
166
|
+
buildRpcUiPrompt({ requestId, surface, value, agentName, message }),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const decision = await deps.requestPermissionDecisionFromUi(
|
|
170
|
+
ctx.ui,
|
|
171
|
+
title,
|
|
172
|
+
message,
|
|
173
|
+
sessionLabel ? { sessionLabel } : undefined,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
deps.logger.review("permission_request.rpc_prompt", {
|
|
177
|
+
requestId,
|
|
178
|
+
surface: surface ?? null,
|
|
179
|
+
value: value ?? null,
|
|
180
|
+
agentName: agentName ?? null,
|
|
181
|
+
message,
|
|
182
|
+
approved: decision.approved,
|
|
183
|
+
resolution: decision.state,
|
|
184
|
+
denialReason: decision.denialReason ?? null,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const data: PermissionsPromptReplyData = {
|
|
188
|
+
approved: decision.approved,
|
|
189
|
+
state: decision.state,
|
|
190
|
+
...(decision.denialReason !== undefined
|
|
191
|
+
? { denialReason: decision.denialReason }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
events.emit(replyChannel, successReply(data));
|
|
195
|
+
} catch (err) {
|
|
196
|
+
const message_ = err instanceof Error ? err.message : String(err);
|
|
197
|
+
events.emit(replyChannel, errorReply(message_));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Public API ─────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Register `permissions:rpc:check` and `permissions:rpc:prompt` handlers on
|
|
205
|
+
* the event bus.
|
|
206
|
+
*
|
|
207
|
+
* Returns unsubscribe handles — call them in session_shutdown to stop the
|
|
208
|
+
* handlers and prevent memory leaks.
|
|
209
|
+
*/
|
|
210
|
+
export function registerPermissionRpcHandlers(
|
|
211
|
+
events: PermissionEventBus,
|
|
212
|
+
deps: PermissionRpcDeps,
|
|
213
|
+
): PermissionRpcHandles {
|
|
214
|
+
const unsubCheck = events.on(PERMISSIONS_RPC_CHECK_CHANNEL, (raw) => {
|
|
215
|
+
handleCheckRpc(raw, events, deps);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const unsubPrompt = events.on(PERMISSIONS_RPC_PROMPT_CHANNEL, (raw) => {
|
|
219
|
+
void handlePromptRpc(raw, events, deps);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return { unsubCheck, unsubPrompt };
|
|
223
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission event channel — public contract.
|
|
3
|
+
*
|
|
4
|
+
* Exports channel name constants, protocol version, TypeScript types for all
|
|
5
|
+
* emitted events and RPC envelopes, and thin emit helpers.
|
|
6
|
+
*
|
|
7
|
+
* Stability guarantee: fields may be added, but existing fields will not be
|
|
8
|
+
* removed or renamed without a semver-major version bump.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Minimal event bus interface required by the emit helpers and RPC handlers. */
|
|
12
|
+
export interface PermissionEventBus {
|
|
13
|
+
emit(channel: string, data: unknown): void;
|
|
14
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ── Protocol version ───────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* RPC protocol version.
|
|
21
|
+
* Bumped when the envelope shape or method contracts change in a breaking way.
|
|
22
|
+
*/
|
|
23
|
+
export const PERMISSIONS_PROTOCOL_VERSION = 1;
|
|
24
|
+
|
|
25
|
+
// ── Channel name constants ─────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** Emitted at `session_start`, after the service is published. */
|
|
28
|
+
export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
|
|
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
|
+
|
|
33
|
+
/** Emitted after every permission gate resolution. */
|
|
34
|
+
export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* RPC request channel — query the permission policy (no prompting).
|
|
38
|
+
*
|
|
39
|
+
* @deprecated Use the `Symbol.for()`-backed service accessor instead:
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const { getPermissionsService } = await import("@gotgenes/pi-permission-system");
|
|
42
|
+
* const service = getPermissionsService();
|
|
43
|
+
* if (service) {
|
|
44
|
+
* const result = service.checkPermission("bash", "git push");
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
* The event-bus RPC remains available as a zero-dependency fallback.
|
|
48
|
+
*/
|
|
49
|
+
export const PERMISSIONS_RPC_CHECK_CHANNEL = "permissions:rpc:check";
|
|
50
|
+
|
|
51
|
+
/** RPC request channel — forward a permission prompt to the parent UI. */
|
|
52
|
+
export const PERMISSIONS_RPC_PROMPT_CHANNEL = "permissions:rpc:prompt";
|
|
53
|
+
|
|
54
|
+
// ── Shared RPC envelope ────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Standard RPC reply envelope.
|
|
58
|
+
* Success: `{ success: true, protocolVersion, data? }`.
|
|
59
|
+
* Error: `{ success: false, protocolVersion, error }`.
|
|
60
|
+
*/
|
|
61
|
+
export type PermissionsRpcReply<T = void> =
|
|
62
|
+
| { success: true; protocolVersion: number; data?: T }
|
|
63
|
+
| { success: false; protocolVersion: number; error: string };
|
|
64
|
+
|
|
65
|
+
// ── permissions:ready ──────────────────────────────────────────────────────
|
|
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;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── permissions:decision ───────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/** How a permission decision was reached. */
|
|
129
|
+
export type PermissionDecisionResolution =
|
|
130
|
+
| "policy_allow"
|
|
131
|
+
| "policy_deny"
|
|
132
|
+
| "session_approved"
|
|
133
|
+
| "infrastructure_auto_allowed"
|
|
134
|
+
| "user_approved"
|
|
135
|
+
| "user_approved_for_session"
|
|
136
|
+
| "user_approved_for_project"
|
|
137
|
+
| "user_approved_globally"
|
|
138
|
+
| "user_denied"
|
|
139
|
+
| "auto_approved"
|
|
140
|
+
| "confirmation_unavailable";
|
|
141
|
+
|
|
142
|
+
/** Payload emitted on `permissions:decision`. */
|
|
143
|
+
export interface PermissionDecisionEvent {
|
|
144
|
+
/** Permission surface: "bash", "read", "mcp", "skill", "external_directory", etc. */
|
|
145
|
+
surface: string;
|
|
146
|
+
/** The value that was evaluated (command, tool name, skill name, path). */
|
|
147
|
+
value: string;
|
|
148
|
+
/** Final decision. */
|
|
149
|
+
result: "allow" | "deny";
|
|
150
|
+
/** How the decision was reached. */
|
|
151
|
+
resolution: PermissionDecisionResolution;
|
|
152
|
+
/** Which config scope contributed the winning rule (when available). */
|
|
153
|
+
origin: string | null;
|
|
154
|
+
/** Agent name (when known). */
|
|
155
|
+
agentName: string | null;
|
|
156
|
+
/** Matched pattern from the winning rule (when available). */
|
|
157
|
+
matchedPattern: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ── permissions:rpc:check ──────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Request payload for `permissions:rpc:check`.
|
|
164
|
+
*
|
|
165
|
+
* @deprecated Prefer `getPermissionsService().checkPermission()` from the
|
|
166
|
+
* service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
|
|
167
|
+
*/
|
|
168
|
+
export interface PermissionsCheckRequest {
|
|
169
|
+
requestId: string;
|
|
170
|
+
/** Permission surface to evaluate. */
|
|
171
|
+
surface: string;
|
|
172
|
+
/** The value to evaluate: command string, tool name, skill name, or path. */
|
|
173
|
+
value?: string;
|
|
174
|
+
/** Optional agent name for per-agent policy resolution. */
|
|
175
|
+
agentName?: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Data field in a successful `permissions:rpc:check` reply.
|
|
180
|
+
*
|
|
181
|
+
* @deprecated Prefer `getPermissionsService().checkPermission()` from the
|
|
182
|
+
* service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
|
|
183
|
+
*/
|
|
184
|
+
export interface PermissionsCheckReplyData {
|
|
185
|
+
result: "allow" | "deny" | "ask";
|
|
186
|
+
matchedPattern: string | null;
|
|
187
|
+
origin: string | null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── permissions:rpc:prompt ─────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
/** Request payload for `permissions:rpc:prompt`. */
|
|
193
|
+
export interface PermissionsPromptRequest {
|
|
194
|
+
requestId: string;
|
|
195
|
+
/** Permission surface being evaluated. */
|
|
196
|
+
surface: string;
|
|
197
|
+
/** Value being evaluated (shown in the dialog). */
|
|
198
|
+
value: string;
|
|
199
|
+
/** Optional agent name for display. */
|
|
200
|
+
agentName?: string;
|
|
201
|
+
/** Message to display in the permission dialog. */
|
|
202
|
+
message: string;
|
|
203
|
+
/** Optional label for the "for this session" option. */
|
|
204
|
+
sessionLabel?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Data field in a successful `permissions:rpc:prompt` reply. */
|
|
208
|
+
export interface PermissionsPromptReplyData {
|
|
209
|
+
approved: boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Detailed state: "approved", "approved_for_session",
|
|
212
|
+
* "approved_for_project", "approved_globally", "denied",
|
|
213
|
+
* or "denied_with_reason".
|
|
214
|
+
*/
|
|
215
|
+
state: string;
|
|
216
|
+
denialReason?: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Emit helpers ───────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Emit the `permissions:ready` broadcast.
|
|
223
|
+
* Call at `session_start`, after the service is published, so a consumer
|
|
224
|
+
* reacting to ready can immediately resolve `getPermissionsService()`.
|
|
225
|
+
*/
|
|
226
|
+
export function emitReadyEvent(events: PermissionEventBus): void {
|
|
227
|
+
const payload: PermissionsReadyEvent = {};
|
|
228
|
+
try {
|
|
229
|
+
events.emit(PERMISSIONS_READY_CHANNEL, payload);
|
|
230
|
+
} catch {
|
|
231
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
232
|
+
// permission system from completing session startup.
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Emit a `permissions:ui_prompt` broadcast.
|
|
238
|
+
* Call immediately before invoking the active user-facing permission UI.
|
|
239
|
+
*/
|
|
240
|
+
export function emitUiPromptEvent(
|
|
241
|
+
events: PermissionEventBus,
|
|
242
|
+
event: PermissionUiPromptEvent,
|
|
243
|
+
): void {
|
|
244
|
+
try {
|
|
245
|
+
events.emit(PERMISSIONS_UI_PROMPT_CHANNEL, event);
|
|
246
|
+
} catch {
|
|
247
|
+
// UI-prompt broadcasts are observational. A consumer failure must not block
|
|
248
|
+
// the permission dialog itself.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Emit a `permissions:decision` broadcast.
|
|
254
|
+
* Call after every permission gate resolution.
|
|
255
|
+
*/
|
|
256
|
+
export function emitDecisionEvent(
|
|
257
|
+
events: PermissionEventBus,
|
|
258
|
+
event: PermissionDecisionEvent,
|
|
259
|
+
): void {
|
|
260
|
+
try {
|
|
261
|
+
events.emit(PERMISSIONS_DECISION_CHANNEL, event);
|
|
262
|
+
} catch {
|
|
263
|
+
// Broadcasts are best-effort. A throwing listener must not block the
|
|
264
|
+
// permission gate from resolving.
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { PermissionDecisionState } from "./permission-dialog";
|
|
4
|
+
import type { PermissionUiPromptSource } from "./permission-events";
|
|
5
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
6
|
+
|
|
7
|
+
export const PERMISSION_FORWARDING_POLL_INTERVAL_MS = 250;
|
|
8
|
+
export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
9
|
+
export const SUBAGENT_ENV_HINT_KEYS = [
|
|
10
|
+
// pi-agent-router (original)
|
|
11
|
+
"PI_IS_SUBAGENT",
|
|
12
|
+
"PI_SUBAGENT_SESSION_ID",
|
|
13
|
+
"PI_AGENT_ROUTER_SUBAGENT",
|
|
14
|
+
// nicobailon/pi-subagents
|
|
15
|
+
"PI_SUBAGENT_CHILD",
|
|
16
|
+
"PI_SUBAGENT_RUN_ID",
|
|
17
|
+
"PI_SUBAGENT_CHILD_AGENT",
|
|
18
|
+
"PI_SUBAGENT_DEPTH",
|
|
19
|
+
// HazAT/pi-interactive-subagents
|
|
20
|
+
"PI_SUBAGENT_NAME",
|
|
21
|
+
"PI_SUBAGENT_ID",
|
|
22
|
+
"PI_SUBAGENT_SESSION",
|
|
23
|
+
"PI_SUBAGENT_ACTIVITY_FILE",
|
|
24
|
+
] as const;
|
|
25
|
+
/** Ordered list of env var names to check for the parent session ID. First match wins. */
|
|
26
|
+
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
|
|
27
|
+
// pi-agent-router (original)
|
|
28
|
+
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
|
29
|
+
// Shared convention for CLI-based subagent extensions
|
|
30
|
+
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
|
|
31
|
+
"PI_SUBAGENT_PARENT_SESSION",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
|
|
35
|
+
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
|
|
36
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES[0];
|
|
37
|
+
|
|
38
|
+
const SESSION_FORWARDING_ROOT_DIRECTORY_NAME = "sessions";
|
|
39
|
+
const SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME = "requests";
|
|
40
|
+
const SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME = "responses";
|
|
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
|
+
|
|
55
|
+
export type ForwardedPermissionRequest = {
|
|
56
|
+
id: string;
|
|
57
|
+
createdAt: number;
|
|
58
|
+
requesterSessionId: string;
|
|
59
|
+
targetSessionId: string;
|
|
60
|
+
requesterAgentName: string;
|
|
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;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type ForwardedPermissionResponse = {
|
|
74
|
+
approved: boolean;
|
|
75
|
+
state: PermissionDecisionState;
|
|
76
|
+
denialReason?: string;
|
|
77
|
+
responderSessionId: string;
|
|
78
|
+
respondedAt: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type PermissionForwardingLocation = {
|
|
82
|
+
sessionId: string;
|
|
83
|
+
sessionRootDir: string;
|
|
84
|
+
requestsDir: string;
|
|
85
|
+
responsesDir: string;
|
|
86
|
+
label: "primary";
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function normalizePermissionForwardingSessionId(
|
|
90
|
+
value: unknown,
|
|
91
|
+
): string | null {
|
|
92
|
+
if (typeof value !== "string") {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const trimmed = value.trim();
|
|
97
|
+
if (!trimmed || trimmed.toLowerCase() === "unknown") {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return trimmed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function encodeSessionIdForPath(sessionId: string): string {
|
|
105
|
+
return encodeURIComponent(sessionId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createPermissionForwardingLocation(
|
|
109
|
+
forwardingRootDir: string,
|
|
110
|
+
sessionId: string,
|
|
111
|
+
): PermissionForwardingLocation {
|
|
112
|
+
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
|
113
|
+
if (!normalizedSessionId) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
"Permission forwarding session id must be a non-empty string.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const sessionRootDir = join(
|
|
120
|
+
forwardingRootDir,
|
|
121
|
+
SESSION_FORWARDING_ROOT_DIRECTORY_NAME,
|
|
122
|
+
encodeSessionIdForPath(normalizedSessionId),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
sessionId: normalizedSessionId,
|
|
127
|
+
sessionRootDir,
|
|
128
|
+
requestsDir: join(
|
|
129
|
+
sessionRootDir,
|
|
130
|
+
SESSION_FORWARDING_REQUESTS_DIRECTORY_NAME,
|
|
131
|
+
),
|
|
132
|
+
responsesDir: join(
|
|
133
|
+
sessionRootDir,
|
|
134
|
+
SESSION_FORWARDING_RESPONSES_DIRECTORY_NAME,
|
|
135
|
+
),
|
|
136
|
+
label: "primary",
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolvePermissionForwardingTargetSessionId(options: {
|
|
141
|
+
hasUI: boolean;
|
|
142
|
+
isSubagent: boolean;
|
|
143
|
+
currentSessionId?: string | null;
|
|
144
|
+
env?: NodeJS.ProcessEnv;
|
|
145
|
+
/** Child session id for registry lookup. */
|
|
146
|
+
sessionId?: string;
|
|
147
|
+
/** In-process subagent session registry (checked before env vars). */
|
|
148
|
+
registry?: SubagentSessionRegistry;
|
|
149
|
+
}): string | null {
|
|
150
|
+
if (options.hasUI) {
|
|
151
|
+
return normalizePermissionForwardingSessionId(options.currentSessionId);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!options.isSubagent) {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 1. Registry — in-process subagents register parentSessionId explicitly.
|
|
159
|
+
if (options.registry && options.sessionId) {
|
|
160
|
+
const entry = options.registry.get(options.sessionId);
|
|
161
|
+
const resolved = normalizePermissionForwardingSessionId(
|
|
162
|
+
entry?.parentSessionId,
|
|
163
|
+
);
|
|
164
|
+
if (resolved) return resolved;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 2. Env vars — process-based subagent extensions.
|
|
168
|
+
const env = options.env ?? process.env;
|
|
169
|
+
for (const key of SUBAGENT_PARENT_SESSION_ENV_CANDIDATES) {
|
|
170
|
+
const resolved = normalizePermissionForwardingSessionId(env[key]);
|
|
171
|
+
if (resolved) return resolved;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function isForwardedPermissionRequestForSession(
|
|
177
|
+
request: Pick<ForwardedPermissionRequest, "targetSessionId">,
|
|
178
|
+
sessionId: string | null | undefined,
|
|
179
|
+
): boolean {
|
|
180
|
+
const normalizedRequestSessionId = normalizePermissionForwardingSessionId(
|
|
181
|
+
request.targetSessionId,
|
|
182
|
+
);
|
|
183
|
+
const normalizedSessionId = normalizePermissionForwardingSessionId(sessionId);
|
|
184
|
+
return (
|
|
185
|
+
normalizedRequestSessionId !== null &&
|
|
186
|
+
normalizedRequestSessionId === normalizedSessionId
|
|
187
|
+
);
|
|
188
|
+
}
|