@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,580 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getActiveAgentName,
|
|
5
|
+
getActiveAgentNameFromSystemPrompt,
|
|
6
|
+
type SessionEntryView,
|
|
7
|
+
} from "#src/active-agent";
|
|
8
|
+
import { toRecord } from "#src/common";
|
|
9
|
+
import type { ConfigReader } from "#src/config-store";
|
|
10
|
+
import type {
|
|
11
|
+
PermissionDecisionUi,
|
|
12
|
+
PermissionPromptDecision,
|
|
13
|
+
RequestPermissionOptions,
|
|
14
|
+
} from "#src/permission-dialog";
|
|
15
|
+
import {
|
|
16
|
+
emitUiPromptEvent,
|
|
17
|
+
type PermissionEventBus,
|
|
18
|
+
} from "#src/permission-events";
|
|
19
|
+
import {
|
|
20
|
+
type ForwardedPermissionRequest,
|
|
21
|
+
type ForwardedPermissionResponse,
|
|
22
|
+
type ForwardedPromptDisplay,
|
|
23
|
+
isForwardedPermissionRequestForSession,
|
|
24
|
+
PERMISSION_FORWARDING_POLL_INTERVAL_MS,
|
|
25
|
+
PERMISSION_FORWARDING_TIMEOUT_MS,
|
|
26
|
+
type PermissionForwardingLocation,
|
|
27
|
+
resolvePermissionForwardingTargetSessionId,
|
|
28
|
+
SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
29
|
+
} from "#src/permission-forwarding";
|
|
30
|
+
import { buildForwardedUiPrompt } from "#src/permission-ui-prompt";
|
|
31
|
+
import type { DebugReviewLogger } from "#src/session-logger";
|
|
32
|
+
import { isSubagentExecutionContext } from "#src/subagent-context";
|
|
33
|
+
import type { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
34
|
+
import { shouldAutoApprovePermissionState } from "#src/yolo-mode";
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
cleanupPermissionForwardingLocationIfEmpty,
|
|
38
|
+
ensureDirectoryExists,
|
|
39
|
+
ensurePermissionForwardingLocation,
|
|
40
|
+
getExistingPermissionForwardingLocation,
|
|
41
|
+
listRequestFiles,
|
|
42
|
+
logPermissionForwardingError,
|
|
43
|
+
logPermissionForwardingWarning,
|
|
44
|
+
readForwardedPermissionRequest,
|
|
45
|
+
readForwardedPermissionResponse,
|
|
46
|
+
safeDeleteFile,
|
|
47
|
+
sleep,
|
|
48
|
+
writeJsonFileAtomic,
|
|
49
|
+
} from "./io";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Narrow context the forwarder reads: the UI gate (`hasUI`), the dialog UI
|
|
53
|
+
* surface, and the three session-manager readers it uses directly or via
|
|
54
|
+
* {@link isSubagentExecutionContext} / {@link getActiveAgentName}.
|
|
55
|
+
*
|
|
56
|
+
* `getSystemPrompt` is read reflectively (see `getContextSystemPrompt`), so it
|
|
57
|
+
* is intentionally not a typed member. A full `ExtensionContext` satisfies this
|
|
58
|
+
* structurally, so production callers pass `ctx` unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export interface ForwarderContext {
|
|
61
|
+
hasUI: boolean;
|
|
62
|
+
ui: PermissionDecisionUi;
|
|
63
|
+
sessionManager: {
|
|
64
|
+
getSessionId(): string;
|
|
65
|
+
getSessionDir(): string;
|
|
66
|
+
getEntries(): readonly SessionEntryView[];
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Constructor config for `PermissionForwarder`.
|
|
72
|
+
*
|
|
73
|
+
* Replaces the `PermissionForwardingDeps` interface that was previously
|
|
74
|
+
* threaded into free functions in `polling.ts`. The forwarder consumes it
|
|
75
|
+
* once at construction and stores each member as a private readonly field.
|
|
76
|
+
*/
|
|
77
|
+
export interface PermissionForwarderDeps {
|
|
78
|
+
forwardingDir: string;
|
|
79
|
+
subagentSessionsDir: string;
|
|
80
|
+
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
81
|
+
registry?: SubagentSessionRegistry;
|
|
82
|
+
/** Event bus used for UI prompt broadcasts. */
|
|
83
|
+
events?: PermissionEventBus;
|
|
84
|
+
logger: DebugReviewLogger;
|
|
85
|
+
requestPermissionDecisionFromUi: (
|
|
86
|
+
ui: PermissionDecisionUi,
|
|
87
|
+
title: string,
|
|
88
|
+
message: string,
|
|
89
|
+
options?: RequestPermissionOptions,
|
|
90
|
+
) => Promise<PermissionPromptDecision>;
|
|
91
|
+
/** Read current config for yolo-mode auto-approve check (called at prompt time). */
|
|
92
|
+
config: ConfigReader;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Module-private helpers ────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
function getSessionId(ctx: ForwarderContext): string {
|
|
98
|
+
try {
|
|
99
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
100
|
+
if (typeof sessionId === "string" && sessionId.trim()) {
|
|
101
|
+
return sessionId.trim();
|
|
102
|
+
}
|
|
103
|
+
} catch {}
|
|
104
|
+
|
|
105
|
+
return "unknown";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getContextSystemPrompt(ctx: ForwarderContext): string | undefined {
|
|
109
|
+
const getSystemPrompt = toRecord(ctx).getSystemPrompt;
|
|
110
|
+
if (typeof getSystemPrompt !== "function") {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- getSystemPrompt is a Pi SDK accessor returning any
|
|
116
|
+
const systemPrompt = getSystemPrompt.call(ctx);
|
|
117
|
+
return typeof systemPrompt === "string" ? systemPrompt : undefined;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// No deps available in this helper — warning silently dropped.
|
|
120
|
+
logPermissionForwardingWarning(
|
|
121
|
+
null,
|
|
122
|
+
"Failed to read context system prompt for forwarded permission metadata",
|
|
123
|
+
error,
|
|
124
|
+
);
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatForwardedPermissionPrompt(
|
|
130
|
+
request: ForwardedPermissionRequest,
|
|
131
|
+
): string {
|
|
132
|
+
const agentName = request.requesterAgentName || "unknown";
|
|
133
|
+
const sessionId = request.requesterSessionId || "unknown";
|
|
134
|
+
return [
|
|
135
|
+
`Subagent '${agentName}' requested permission.`,
|
|
136
|
+
`Session ID: ${sessionId}`,
|
|
137
|
+
"",
|
|
138
|
+
request.message,
|
|
139
|
+
].join("\n");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Public seam interfaces ────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Narrow seam describing what `PermissionPrompter` needs from the forwarder:
|
|
146
|
+
* a single method that resolves a permission decision for the current context
|
|
147
|
+
* (prompt directly when the session has UI, otherwise forward to the parent).
|
|
148
|
+
*
|
|
149
|
+
* Depending on the interface (not the concrete `PermissionForwarder`) keeps
|
|
150
|
+
* the prompter's unit tests free of casts — they inject a plain
|
|
151
|
+
* `{ requestApproval: vi.fn() }` mock.
|
|
152
|
+
*/
|
|
153
|
+
export interface ApprovalRequester {
|
|
154
|
+
requestApproval(
|
|
155
|
+
ctx: ForwarderContext,
|
|
156
|
+
message: string,
|
|
157
|
+
options?: RequestPermissionOptions,
|
|
158
|
+
forwarded?: ForwardedPromptDisplay,
|
|
159
|
+
): Promise<PermissionPromptDecision>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Narrow seam describing what `ForwardingManager` needs from the forwarder:
|
|
164
|
+
* a single method that drains this session's forwarded-permission inbox.
|
|
165
|
+
*
|
|
166
|
+
* Depending on the interface (not the concrete `PermissionForwarder`) keeps
|
|
167
|
+
* the manager's unit tests free of casts — they inject a plain
|
|
168
|
+
* `{ processInbox: vi.fn() }` mock.
|
|
169
|
+
*/
|
|
170
|
+
export interface InboxProcessor {
|
|
171
|
+
processInbox(ctx: ForwarderContext): Promise<void>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── PermissionForwarder ───────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Owner of the forwarded-permission behavior.
|
|
178
|
+
*
|
|
179
|
+
* Holds all forwarding state as private readonly fields and provides two
|
|
180
|
+
* public methods (`requestApproval`, `processInbox`) that together encapsulate
|
|
181
|
+
* the full forwarding lifecycle: deciding whether to prompt directly or
|
|
182
|
+
* forward to the parent, building and persisting request files, polling for
|
|
183
|
+
* responses, and processing the parent-session inbox.
|
|
184
|
+
*/
|
|
185
|
+
export class PermissionForwarder implements ApprovalRequester, InboxProcessor {
|
|
186
|
+
private readonly forwardingDir: string;
|
|
187
|
+
private readonly subagentSessionsDir: string;
|
|
188
|
+
private readonly registry: SubagentSessionRegistry | undefined;
|
|
189
|
+
private readonly events: PermissionEventBus | undefined;
|
|
190
|
+
private readonly logger: DebugReviewLogger;
|
|
191
|
+
private readonly requestPermissionDecisionFromUi: (
|
|
192
|
+
ui: PermissionDecisionUi,
|
|
193
|
+
title: string,
|
|
194
|
+
message: string,
|
|
195
|
+
options?: RequestPermissionOptions,
|
|
196
|
+
) => Promise<PermissionPromptDecision>;
|
|
197
|
+
private readonly config: ConfigReader;
|
|
198
|
+
|
|
199
|
+
constructor(deps: PermissionForwarderDeps) {
|
|
200
|
+
this.forwardingDir = deps.forwardingDir;
|
|
201
|
+
this.subagentSessionsDir = deps.subagentSessionsDir;
|
|
202
|
+
this.registry = deps.registry;
|
|
203
|
+
this.events = deps.events;
|
|
204
|
+
this.logger = deps.logger;
|
|
205
|
+
this.requestPermissionDecisionFromUi = deps.requestPermissionDecisionFromUi;
|
|
206
|
+
this.config = deps.config;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Public seam methods ────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resolve a permission decision for the current context: prompt directly
|
|
213
|
+
* when this session has UI, otherwise forward to the parent session.
|
|
214
|
+
*/
|
|
215
|
+
requestApproval(
|
|
216
|
+
ctx: ForwarderContext,
|
|
217
|
+
message: string,
|
|
218
|
+
options?: RequestPermissionOptions,
|
|
219
|
+
forwarded?: ForwardedPromptDisplay,
|
|
220
|
+
): Promise<PermissionPromptDecision> {
|
|
221
|
+
if (ctx.hasUI) {
|
|
222
|
+
return this.requestPermissionDecisionFromUi(
|
|
223
|
+
ctx.ui,
|
|
224
|
+
"Permission Required",
|
|
225
|
+
message,
|
|
226
|
+
options,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (
|
|
231
|
+
!isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
|
|
232
|
+
) {
|
|
233
|
+
return Promise.resolve({ approved: false, state: "denied" });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return this.waitForForwardedApproval(ctx, message, forwarded);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Drain and respond to this session's forwarded-permission inbox. */
|
|
240
|
+
async processInbox(ctx: ForwarderContext): Promise<void> {
|
|
241
|
+
if (!ctx.hasUI) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const currentSessionId = getSessionId(ctx);
|
|
246
|
+
const location = getExistingPermissionForwardingLocation(
|
|
247
|
+
this.forwardingDir,
|
|
248
|
+
currentSessionId,
|
|
249
|
+
);
|
|
250
|
+
if (!location) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const requestFiles = listRequestFiles(this.logger, location.requestsDir);
|
|
255
|
+
if (requestFiles.length === 0) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Defensively recreate responses/ before writing any response — a
|
|
260
|
+
// concurrent cleanup pass may have removed it between the requestsDir
|
|
261
|
+
// existence check above and the write inside processSingleForwardedRequest
|
|
262
|
+
// (the ENOENT write loop reported in issue #398).
|
|
263
|
+
if (
|
|
264
|
+
!ensureDirectoryExists(
|
|
265
|
+
this.logger,
|
|
266
|
+
location.responsesDir,
|
|
267
|
+
"permission forwarding responses",
|
|
268
|
+
)
|
|
269
|
+
) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
for (const fileName of requestFiles) {
|
|
274
|
+
const requestPath = join(location.requestsDir, fileName);
|
|
275
|
+
const request = readForwardedPermissionRequest(this.logger, requestPath);
|
|
276
|
+
if (!request) {
|
|
277
|
+
safeDeleteFile(
|
|
278
|
+
this.logger,
|
|
279
|
+
requestPath,
|
|
280
|
+
`${location.label} forwarded permission request`,
|
|
281
|
+
);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
await this.processSingleForwardedRequest(
|
|
286
|
+
ctx,
|
|
287
|
+
request,
|
|
288
|
+
location,
|
|
289
|
+
requestPath,
|
|
290
|
+
currentSessionId,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Private methods ────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
private async waitForForwardedApproval(
|
|
300
|
+
ctx: ForwarderContext,
|
|
301
|
+
message: string,
|
|
302
|
+
forwarded?: ForwardedPromptDisplay,
|
|
303
|
+
): Promise<PermissionPromptDecision> {
|
|
304
|
+
const requesterSessionId = getSessionId(ctx);
|
|
305
|
+
const targetSessionId = resolvePermissionForwardingTargetSessionId({
|
|
306
|
+
hasUI: ctx.hasUI,
|
|
307
|
+
isSubagent: isSubagentExecutionContext(
|
|
308
|
+
ctx,
|
|
309
|
+
this.subagentSessionsDir,
|
|
310
|
+
this.registry,
|
|
311
|
+
),
|
|
312
|
+
currentSessionId: requesterSessionId,
|
|
313
|
+
env: process.env,
|
|
314
|
+
sessionId: requesterSessionId,
|
|
315
|
+
registry: this.registry,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
if (!targetSessionId) {
|
|
319
|
+
logPermissionForwardingError(
|
|
320
|
+
this.logger,
|
|
321
|
+
`Permission forwarding target session could not be resolved. ` +
|
|
322
|
+
`Checked env vars: ${SUBAGENT_PARENT_SESSION_ENV_CANDIDATES.join(", ")}. ` +
|
|
323
|
+
`If you are using a subagent extension (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.), ` +
|
|
324
|
+
`ask its maintainer to set PI_SUBAGENT_PARENT_SESSION in the child process environment ` +
|
|
325
|
+
`(see https://github.com/gotgenes/pi-permission-system/issues/143).`,
|
|
326
|
+
);
|
|
327
|
+
return { approved: false, state: "denied" };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const location = ensurePermissionForwardingLocation(
|
|
331
|
+
this.logger,
|
|
332
|
+
this.forwardingDir,
|
|
333
|
+
targetSessionId,
|
|
334
|
+
);
|
|
335
|
+
if (!location) {
|
|
336
|
+
logPermissionForwardingError(
|
|
337
|
+
this.logger,
|
|
338
|
+
`Permission forwarding is unavailable because session-scoped directories could not be prepared for '${targetSessionId}'`,
|
|
339
|
+
);
|
|
340
|
+
return { approved: false, state: "denied" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const request = this.buildForwardedRequest(
|
|
344
|
+
ctx,
|
|
345
|
+
message,
|
|
346
|
+
requesterSessionId,
|
|
347
|
+
targetSessionId,
|
|
348
|
+
forwarded,
|
|
349
|
+
);
|
|
350
|
+
const requestPath = join(location.requestsDir, `${request.id}.json`);
|
|
351
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
352
|
+
|
|
353
|
+
this.logger.review("forwarded_permission.request_created", {
|
|
354
|
+
requestId: request.id,
|
|
355
|
+
requesterAgentName: request.requesterAgentName,
|
|
356
|
+
requesterSessionId: request.requesterSessionId,
|
|
357
|
+
targetSessionId,
|
|
358
|
+
requestPath,
|
|
359
|
+
responsePath,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
writeJsonFileAtomic(this.logger, requestPath, request);
|
|
364
|
+
} catch (error) {
|
|
365
|
+
logPermissionForwardingError(
|
|
366
|
+
this.logger,
|
|
367
|
+
`Failed to write forwarded permission request '${requestPath}'`,
|
|
368
|
+
error,
|
|
369
|
+
);
|
|
370
|
+
return { approved: false, state: "denied" };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return this.pollForForwardedResponse(
|
|
374
|
+
location,
|
|
375
|
+
request,
|
|
376
|
+
requestPath,
|
|
377
|
+
responsePath,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private buildForwardedRequest(
|
|
382
|
+
ctx: ForwarderContext,
|
|
383
|
+
message: string,
|
|
384
|
+
requesterSessionId: string,
|
|
385
|
+
targetSessionId: string,
|
|
386
|
+
forwarded?: ForwardedPromptDisplay,
|
|
387
|
+
): ForwardedPermissionRequest {
|
|
388
|
+
const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
|
|
389
|
+
const requesterAgentName =
|
|
390
|
+
getActiveAgentName(ctx) ??
|
|
391
|
+
getActiveAgentNameFromSystemPrompt(getContextSystemPrompt(ctx)) ??
|
|
392
|
+
"unknown";
|
|
393
|
+
return {
|
|
394
|
+
id: requestId,
|
|
395
|
+
createdAt: Date.now(),
|
|
396
|
+
requesterSessionId,
|
|
397
|
+
targetSessionId,
|
|
398
|
+
requesterAgentName,
|
|
399
|
+
message,
|
|
400
|
+
...(forwarded
|
|
401
|
+
? {
|
|
402
|
+
source: forwarded.source,
|
|
403
|
+
surface: forwarded.surface,
|
|
404
|
+
value: forwarded.value,
|
|
405
|
+
}
|
|
406
|
+
: {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
private async pollForForwardedResponse(
|
|
411
|
+
location: PermissionForwardingLocation,
|
|
412
|
+
request: ForwardedPermissionRequest,
|
|
413
|
+
requestPath: string,
|
|
414
|
+
responsePath: string,
|
|
415
|
+
): Promise<PermissionPromptDecision> {
|
|
416
|
+
const { id: requestId, requesterAgentName, targetSessionId } = request;
|
|
417
|
+
const deadline = Date.now() + PERMISSION_FORWARDING_TIMEOUT_MS;
|
|
418
|
+
|
|
419
|
+
while (Date.now() < deadline) {
|
|
420
|
+
if (existsSync(responsePath)) {
|
|
421
|
+
const response = readForwardedPermissionResponse(
|
|
422
|
+
this.logger,
|
|
423
|
+
responsePath,
|
|
424
|
+
);
|
|
425
|
+
this.logger.review("forwarded_permission.response_received", {
|
|
426
|
+
requestId,
|
|
427
|
+
approved: response?.approved ?? null,
|
|
428
|
+
state: response?.state ?? null,
|
|
429
|
+
denialReason: response?.denialReason ?? null,
|
|
430
|
+
responderSessionId: response?.responderSessionId ?? null,
|
|
431
|
+
targetSessionId,
|
|
432
|
+
responsePath,
|
|
433
|
+
});
|
|
434
|
+
safeDeleteFile(
|
|
435
|
+
this.logger,
|
|
436
|
+
responsePath,
|
|
437
|
+
"forwarded permission response",
|
|
438
|
+
);
|
|
439
|
+
safeDeleteFile(
|
|
440
|
+
this.logger,
|
|
441
|
+
requestPath,
|
|
442
|
+
"forwarded permission request",
|
|
443
|
+
);
|
|
444
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
445
|
+
return response ?? { approved: false, state: "denied" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await sleep(PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
logPermissionForwardingWarning(
|
|
452
|
+
this.logger,
|
|
453
|
+
`Timed out waiting for forwarded permission response '${responsePath}'`,
|
|
454
|
+
);
|
|
455
|
+
this.logger.review("forwarded_permission.response_timed_out", {
|
|
456
|
+
requestId,
|
|
457
|
+
requesterAgentName,
|
|
458
|
+
targetSessionId,
|
|
459
|
+
responsePath,
|
|
460
|
+
});
|
|
461
|
+
safeDeleteFile(this.logger, requestPath, "forwarded permission request");
|
|
462
|
+
cleanupPermissionForwardingLocationIfEmpty(this.logger, location);
|
|
463
|
+
return { approved: false, state: "denied" };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async processSingleForwardedRequest(
|
|
467
|
+
ctx: ForwarderContext,
|
|
468
|
+
request: ForwardedPermissionRequest,
|
|
469
|
+
location: PermissionForwardingLocation,
|
|
470
|
+
requestPath: string,
|
|
471
|
+
currentSessionId: string,
|
|
472
|
+
): Promise<void> {
|
|
473
|
+
if (!isForwardedPermissionRequestForSession(request, currentSessionId)) {
|
|
474
|
+
logPermissionForwardingWarning(
|
|
475
|
+
this.logger,
|
|
476
|
+
`Ignoring forwarded permission request '${request.id}' because it targets session '${request.targetSessionId}' instead of '${currentSessionId}'`,
|
|
477
|
+
);
|
|
478
|
+
safeDeleteFile(
|
|
479
|
+
this.logger,
|
|
480
|
+
requestPath,
|
|
481
|
+
`${location.label} forwarded permission request`,
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const forwardedPermissionLogDetails = {
|
|
487
|
+
requestId: request.id,
|
|
488
|
+
source: location.label,
|
|
489
|
+
requesterAgentName: request.requesterAgentName,
|
|
490
|
+
requesterSessionId: request.requesterSessionId,
|
|
491
|
+
targetSessionId: request.targetSessionId,
|
|
492
|
+
requestPath,
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
let decision: PermissionPromptDecision = {
|
|
496
|
+
approved: false,
|
|
497
|
+
state: "denied",
|
|
498
|
+
};
|
|
499
|
+
if (shouldAutoApprovePermissionState("ask", this.config.current())) {
|
|
500
|
+
this.logger.review(
|
|
501
|
+
"forwarded_permission.auto_approved",
|
|
502
|
+
forwardedPermissionLogDetails,
|
|
503
|
+
);
|
|
504
|
+
decision = { approved: true, state: "approved" };
|
|
505
|
+
} else {
|
|
506
|
+
this.logger.review(
|
|
507
|
+
"forwarded_permission.prompted",
|
|
508
|
+
forwardedPermissionLogDetails,
|
|
509
|
+
);
|
|
510
|
+
try {
|
|
511
|
+
const forwardedMessage = formatForwardedPermissionPrompt(request);
|
|
512
|
+
if (this.events) {
|
|
513
|
+
emitUiPromptEvent(
|
|
514
|
+
this.events,
|
|
515
|
+
buildForwardedUiPrompt({
|
|
516
|
+
requestId: request.id,
|
|
517
|
+
message: forwardedMessage,
|
|
518
|
+
requesterAgentName: request.requesterAgentName || null,
|
|
519
|
+
requesterSessionId: request.requesterSessionId || null,
|
|
520
|
+
source: request.source ?? null,
|
|
521
|
+
surface: request.surface ?? null,
|
|
522
|
+
value: request.value ?? null,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
decision = await this.requestPermissionDecisionFromUi(
|
|
527
|
+
ctx.ui,
|
|
528
|
+
"Permission Required (Subagent)",
|
|
529
|
+
forwardedMessage,
|
|
530
|
+
);
|
|
531
|
+
} catch (error) {
|
|
532
|
+
logPermissionForwardingError(
|
|
533
|
+
this.logger,
|
|
534
|
+
"Failed to show forwarded permission confirmation dialog",
|
|
535
|
+
error,
|
|
536
|
+
);
|
|
537
|
+
decision = { approved: false, state: "denied" };
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const responsePath = join(location.responsesDir, `${request.id}.json`);
|
|
542
|
+
this.logger.review(
|
|
543
|
+
decision.approved
|
|
544
|
+
? "forwarded_permission.approved"
|
|
545
|
+
: "forwarded_permission.denied",
|
|
546
|
+
{
|
|
547
|
+
requestId: request.id,
|
|
548
|
+
source: location.label,
|
|
549
|
+
requesterAgentName: request.requesterAgentName,
|
|
550
|
+
requesterSessionId: request.requesterSessionId,
|
|
551
|
+
targetSessionId: request.targetSessionId,
|
|
552
|
+
responsePath,
|
|
553
|
+
resolution: decision.state,
|
|
554
|
+
denialReason: decision.denialReason ?? null,
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
try {
|
|
558
|
+
writeJsonFileAtomic(this.logger, responsePath, {
|
|
559
|
+
approved: decision.approved,
|
|
560
|
+
state: decision.state,
|
|
561
|
+
denialReason: decision.denialReason,
|
|
562
|
+
responderSessionId: currentSessionId,
|
|
563
|
+
respondedAt: Date.now(),
|
|
564
|
+
} satisfies ForwardedPermissionResponse);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
logPermissionForwardingError(
|
|
567
|
+
this.logger,
|
|
568
|
+
`Failed to write ${location.label} forwarded permission response '${responsePath}'`,
|
|
569
|
+
error,
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
safeDeleteFile(
|
|
575
|
+
this.logger,
|
|
576
|
+
requestPath,
|
|
577
|
+
`${location.label} forwarded permission request`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { InboxProcessor } from "./forwarded-permissions/permission-forwarder";
|
|
4
|
+
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
5
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
6
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Narrow interface for the forwarding lifecycle used by `PermissionSession`.
|
|
10
|
+
* `ForwardingManager` satisfies it; tests can provide a plain object mock.
|
|
11
|
+
*/
|
|
12
|
+
export interface ForwardingController {
|
|
13
|
+
start(ctx: ExtensionContext): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encapsulates the forwarded-permission polling lifecycle.
|
|
19
|
+
*
|
|
20
|
+
* Owns the timer, current context, and processing-lock state that previously
|
|
21
|
+
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
|
|
22
|
+
* session event that may activate forwarding; call `stop()` on session
|
|
23
|
+
* shutdown.
|
|
24
|
+
*/
|
|
25
|
+
export class ForwardingManager {
|
|
26
|
+
private timer: NodeJS.Timeout | null = null;
|
|
27
|
+
private context: ExtensionContext | null = null;
|
|
28
|
+
private processing = false;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly subagentSessionsDir: string,
|
|
32
|
+
private readonly forwarder: InboxProcessor,
|
|
33
|
+
private readonly registry?: SubagentSessionRegistry,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start polling if `ctx` has UI and is not a subagent execution context.
|
|
38
|
+
* No-op (timer stays running) if already polling — updates the stored
|
|
39
|
+
* context so the next tick uses the latest session.
|
|
40
|
+
* Stops any existing poll when the context does not qualify for forwarding.
|
|
41
|
+
*/
|
|
42
|
+
start(ctx: ExtensionContext): void {
|
|
43
|
+
if (
|
|
44
|
+
!ctx.hasUI ||
|
|
45
|
+
isSubagentExecutionContext(ctx, this.subagentSessionsDir, this.registry)
|
|
46
|
+
) {
|
|
47
|
+
this.stop();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.context = ctx;
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
this.timer = setInterval(() => {
|
|
55
|
+
if (!this.context || this.processing) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
this.processing = true;
|
|
59
|
+
void this.forwarder.processInbox(this.context).finally(() => {
|
|
60
|
+
this.processing = false;
|
|
61
|
+
});
|
|
62
|
+
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Stop polling and clear all internal state. */
|
|
66
|
+
stop(): void {
|
|
67
|
+
if (this.timer) {
|
|
68
|
+
clearInterval(this.timer);
|
|
69
|
+
this.timer = null;
|
|
70
|
+
}
|
|
71
|
+
this.context = null;
|
|
72
|
+
this.processing = false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
2
|
+
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The prompting role the gate runner needs: a yes/no on whether an
|
|
6
|
+
* interactive confirmation is possible, and the prompt itself. The context
|
|
7
|
+
* is bound by the implementor, not threaded per call.
|
|
8
|
+
*/
|
|
9
|
+
export interface GatePrompter {
|
|
10
|
+
canConfirm(): boolean;
|
|
11
|
+
prompt(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
|
12
|
+
}
|