@gotgenes/pi-permission-system 10.0.0 → 10.1.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 +1 -1
- package/package.json +1 -1
- package/src/agent-prep-session.ts +28 -0
- package/src/decision-reporter.ts +41 -0
- package/src/denial-messages.ts +11 -0
- package/src/forwarded-permissions/permission-forwarder.ts +549 -0
- package/src/forwarding-manager.ts +3 -7
- package/src/gate-handler-session.ts +13 -0
- package/src/gate-prompter.ts +14 -0
- package/src/handlers/before-agent-start.ts +2 -3
- package/src/handlers/gates/bash-command.ts +4 -18
- package/src/handlers/gates/bash-external-directory.ts +3 -15
- package/src/handlers/gates/bash-path.ts +3 -16
- package/src/handlers/gates/descriptor.ts +0 -28
- package/src/handlers/gates/path.ts +3 -15
- package/src/handlers/gates/runner.ts +142 -105
- package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
- package/src/handlers/gates/skill-input.ts +44 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
- package/src/handlers/lifecycle.ts +9 -9
- package/src/handlers/permission-gate-handler.ts +34 -238
- package/src/index.ts +49 -69
- package/src/mcp-targets.ts +56 -46
- package/src/permission-prompter.ts +7 -58
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/session-approval-recorder.ts +6 -0
- package/src/session-lifecycle-session.ts +24 -0
- package/src/tool-input-preview.ts +0 -62
- package/src/tool-input-prompt-formatters.ts +63 -0
- package/src/tool-preview-formatter.ts +6 -4
- package/test/decision-reporter.test.ts +112 -0
- package/test/denial-messages.test.ts +62 -0
- package/test/forwarding-manager.test.ts +26 -44
- package/test/handlers/before-agent-start.test.ts +45 -21
- package/test/handlers/external-directory-integration.test.ts +86 -22
- package/test/handlers/external-directory-session-dedup.test.ts +102 -55
- package/test/handlers/gates/bash-command.test.ts +49 -90
- package/test/handlers/gates/bash-external-directory.test.ts +54 -95
- package/test/handlers/gates/bash-path.test.ts +63 -148
- package/test/handlers/gates/path.test.ts +38 -105
- package/test/handlers/gates/runner.test.ts +150 -93
- package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
- package/test/handlers/gates/skill-input.test.ts +128 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
- package/test/handlers/input.test.ts +1 -2
- package/test/handlers/lifecycle.test.ts +49 -33
- package/test/handlers/tool-call-events.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +147 -16
- package/test/helpers/handler-fixtures.ts +143 -27
- package/test/mcp-targets.test.ts +55 -0
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-forwarding.test.ts +0 -282
- package/test/permission-prompter.test.ts +33 -44
- package/test/permission-session.test.ts +160 -27
- package/test/permissions-service.test.ts +151 -0
- package/test/runtime.test.ts +0 -4
- package/test/service-lifecycle.test.ts +162 -0
- package/test/tool-input-preview.test.ts +0 -111
- package/test/tool-input-prompt-formatters.test.ts +115 -0
- package/src/forwarded-permissions/polling.ts +0 -411
package/src/mcp-targets.ts
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "./common";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* An ordered accumulator that owns the uniqueness invariant.
|
|
5
|
+
*
|
|
6
|
+
* `add` ignores null/empty values and silently skips duplicates (first-insertion
|
|
7
|
+
* wins). `toArray` returns the ordered result as an independent copy.
|
|
8
|
+
*/
|
|
9
|
+
export class McpTargetList {
|
|
10
|
+
private readonly targets: string[] = [];
|
|
11
|
+
|
|
12
|
+
add(value: string | null): void {
|
|
13
|
+
if (!value) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (!this.targets.includes(value)) {
|
|
17
|
+
this.targets.push(value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
toArray(): string[] {
|
|
22
|
+
return [...this.targets];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
3
26
|
/**
|
|
4
27
|
* Parse a qualified MCP tool name of the form `server:tool`.
|
|
5
28
|
*
|
|
@@ -31,7 +54,7 @@ export function parseQualifiedMcpToolName(
|
|
|
31
54
|
function addDerivedMcpServerTargets(
|
|
32
55
|
toolName: string,
|
|
33
56
|
configuredServerNames: readonly string[],
|
|
34
|
-
|
|
57
|
+
targets: McpTargetList,
|
|
35
58
|
): void {
|
|
36
59
|
const trimmedToolName = toolName.trim();
|
|
37
60
|
if (!trimmedToolName) {
|
|
@@ -52,9 +75,9 @@ function addDerivedMcpServerTargets(
|
|
|
52
75
|
continue;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
targets.add(`${trimmedServerName}_${trimmedToolName}`);
|
|
79
|
+
targets.add(`${trimmedServerName}:${trimmedToolName}`);
|
|
80
|
+
targets.add(trimmedServerName);
|
|
58
81
|
}
|
|
59
82
|
}
|
|
60
83
|
|
|
@@ -62,22 +85,22 @@ function pushMcpToolPermissionTargets(
|
|
|
62
85
|
rawReference: string,
|
|
63
86
|
serverHint: string | null,
|
|
64
87
|
configuredServerNames: readonly string[],
|
|
65
|
-
|
|
88
|
+
targets: McpTargetList,
|
|
66
89
|
): void {
|
|
67
90
|
const qualified = parseQualifiedMcpToolName(rawReference);
|
|
68
91
|
const resolvedServer = serverHint ?? qualified?.server ?? null;
|
|
69
92
|
const resolvedTool = qualified?.tool ?? rawReference;
|
|
70
93
|
|
|
71
94
|
if (resolvedServer) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
95
|
+
targets.add(`${resolvedServer}_${resolvedTool}`);
|
|
96
|
+
targets.add(`${resolvedServer}:${resolvedTool}`);
|
|
97
|
+
targets.add(resolvedServer);
|
|
75
98
|
} else {
|
|
76
|
-
addDerivedMcpServerTargets(resolvedTool, configuredServerNames,
|
|
99
|
+
addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
|
|
77
100
|
}
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
targets.add(resolvedTool);
|
|
103
|
+
targets.add(rawReference);
|
|
81
104
|
}
|
|
82
105
|
|
|
83
106
|
/**
|
|
@@ -98,32 +121,19 @@ export function createMcpPermissionTargets(
|
|
|
98
121
|
const describe = getNonEmptyString(record.describe);
|
|
99
122
|
const search = getNonEmptyString(record.search);
|
|
100
123
|
|
|
101
|
-
const targets
|
|
102
|
-
const pushTarget = (value: string | null) => {
|
|
103
|
-
if (!value) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
if (!targets.includes(value)) {
|
|
107
|
-
targets.push(value);
|
|
108
|
-
}
|
|
109
|
-
};
|
|
124
|
+
const targets = new McpTargetList();
|
|
110
125
|
|
|
111
126
|
if (tool) {
|
|
112
|
-
pushMcpToolPermissionTargets(
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
configuredServerNames,
|
|
116
|
-
pushTarget,
|
|
117
|
-
);
|
|
118
|
-
pushTarget("mcp_call");
|
|
119
|
-
return targets;
|
|
127
|
+
pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
|
|
128
|
+
targets.add("mcp_call");
|
|
129
|
+
return targets.toArray();
|
|
120
130
|
}
|
|
121
131
|
|
|
122
132
|
if (connect) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return targets;
|
|
133
|
+
targets.add(`mcp_connect_${connect}`);
|
|
134
|
+
targets.add(connect);
|
|
135
|
+
targets.add("mcp_connect");
|
|
136
|
+
return targets.toArray();
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
if (describe) {
|
|
@@ -131,30 +141,30 @@ export function createMcpPermissionTargets(
|
|
|
131
141
|
describe,
|
|
132
142
|
server,
|
|
133
143
|
configuredServerNames,
|
|
134
|
-
|
|
144
|
+
targets,
|
|
135
145
|
);
|
|
136
|
-
|
|
137
|
-
return targets;
|
|
146
|
+
targets.add("mcp_describe");
|
|
147
|
+
return targets.toArray();
|
|
138
148
|
}
|
|
139
149
|
|
|
140
150
|
if (search) {
|
|
141
151
|
if (server) {
|
|
142
|
-
|
|
143
|
-
|
|
152
|
+
targets.add(`mcp_server_${server}`);
|
|
153
|
+
targets.add(server);
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return targets;
|
|
156
|
+
targets.add(search);
|
|
157
|
+
targets.add("mcp_search");
|
|
158
|
+
return targets.toArray();
|
|
149
159
|
}
|
|
150
160
|
|
|
151
161
|
if (server) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return targets;
|
|
162
|
+
targets.add(`mcp_server_${server}`);
|
|
163
|
+
targets.add(server);
|
|
164
|
+
targets.add("mcp_list");
|
|
165
|
+
return targets.toArray();
|
|
156
166
|
}
|
|
157
167
|
|
|
158
|
-
|
|
159
|
-
return targets;
|
|
168
|
+
targets.add("mcp_status");
|
|
169
|
+
return targets.toArray();
|
|
160
170
|
}
|
|
@@ -1,20 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
|
-
import type {
|
|
4
|
-
import {
|
|
5
|
-
confirmPermission,
|
|
6
|
-
type PermissionForwardingDeps,
|
|
7
|
-
} from "./forwarded-permissions/polling";
|
|
8
|
-
import type {
|
|
9
|
-
PermissionPromptDecision,
|
|
10
|
-
RequestPermissionOptions,
|
|
11
|
-
} from "./permission-dialog";
|
|
3
|
+
import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
|
|
4
|
+
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
12
5
|
import {
|
|
13
6
|
emitUiPromptEvent,
|
|
14
7
|
type PermissionEventBus,
|
|
15
8
|
} from "./permission-events";
|
|
16
9
|
import { buildDirectUiPrompt } from "./permission-ui-prompt";
|
|
17
|
-
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
18
10
|
import { shouldAutoApprovePermissionState } from "./yolo-mode";
|
|
19
11
|
|
|
20
12
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
@@ -48,29 +40,18 @@ export interface PermissionPrompterApi {
|
|
|
48
40
|
* Dependencies required by PermissionPrompter.
|
|
49
41
|
*
|
|
50
42
|
* Keeps the prompter's external surface narrow: callers provide config
|
|
51
|
-
* access, review-log writing,
|
|
52
|
-
*
|
|
43
|
+
* access, review-log writing, the UI-prompt event bus, and the forwarder
|
|
44
|
+
* that owns the UI/subagent-forwarding branching logic.
|
|
53
45
|
*/
|
|
54
46
|
export interface PermissionPrompterDeps {
|
|
55
47
|
/** Read current config for yolo-mode check (called at prompt time). */
|
|
56
48
|
getConfig(): PermissionSystemExtensionConfig;
|
|
57
49
|
/** Write structured entries to the permission review log. */
|
|
58
50
|
writeReviewLog(event: string, details: Record<string, unknown>): void;
|
|
59
|
-
/** Directory containing subagent session state. */
|
|
60
|
-
subagentSessionsDir: string;
|
|
61
|
-
/** Directory used for file-based permission forwarding requests/responses. */
|
|
62
|
-
forwardingDir: string;
|
|
63
|
-
/** In-process subagent session registry for detection and forwarding target resolution. */
|
|
64
|
-
registry?: SubagentSessionRegistry;
|
|
65
51
|
/** Event bus used for UI prompt broadcasts. */
|
|
66
52
|
events: PermissionEventBus;
|
|
67
|
-
/**
|
|
68
|
-
|
|
69
|
-
ui: ExtensionContext["ui"],
|
|
70
|
-
title: string,
|
|
71
|
-
message: string,
|
|
72
|
-
options?: RequestPermissionOptions,
|
|
73
|
-
): Promise<PermissionPromptDecision>;
|
|
53
|
+
/** Resolves the permission decision: direct UI dialog or forwarded to parent. */
|
|
54
|
+
forwarder: ApprovalRequester;
|
|
74
55
|
}
|
|
75
56
|
|
|
76
57
|
/**
|
|
@@ -107,10 +88,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
107
88
|
emitUiPromptEvent(this.deps.events, uiPrompt);
|
|
108
89
|
}
|
|
109
90
|
|
|
110
|
-
const decision = await
|
|
91
|
+
const decision = await this.deps.forwarder.requestApproval(
|
|
111
92
|
ctx,
|
|
112
93
|
details.message,
|
|
113
|
-
this.buildForwardingDeps(),
|
|
114
94
|
details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
|
|
115
95
|
{
|
|
116
96
|
source: uiPrompt.source,
|
|
@@ -158,35 +138,4 @@ export class PermissionPrompter implements PermissionPrompterApi {
|
|
|
158
138
|
denialReason: details.denialReason ?? null,
|
|
159
139
|
});
|
|
160
140
|
}
|
|
161
|
-
|
|
162
|
-
/**
|
|
163
|
-
* Build a PermissionForwardingDeps to pass to confirmPermission.
|
|
164
|
-
*
|
|
165
|
-
* Yolo-mode is already handled at the prompter level, so shouldAutoApprove
|
|
166
|
-
* returns false here (confirmPermission does not call it; only
|
|
167
|
-
* processForwardedPermissionRequests does, and that has its own deps).
|
|
168
|
-
*
|
|
169
|
-
* The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
|
|
170
|
-
* (trace-level forwarding debug is deferred — see open question in the plan).
|
|
171
|
-
*/
|
|
172
|
-
private buildForwardingDeps(): PermissionForwardingDeps {
|
|
173
|
-
const { deps } = this;
|
|
174
|
-
const logger: ForwardedPermissionLogger = {
|
|
175
|
-
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
176
|
-
writeReviewLog: deps.writeReviewLog,
|
|
177
|
-
writeDebugLog: () => undefined,
|
|
178
|
-
};
|
|
179
|
-
return {
|
|
180
|
-
forwardingDir: deps.forwardingDir,
|
|
181
|
-
subagentSessionsDir: deps.subagentSessionsDir,
|
|
182
|
-
registry: deps.registry,
|
|
183
|
-
events: deps.events,
|
|
184
|
-
logger,
|
|
185
|
-
// eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
|
|
186
|
-
writeReviewLog: deps.writeReviewLog,
|
|
187
|
-
// eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
|
|
188
|
-
requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
|
|
189
|
-
shouldAutoApprove: () => false,
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
141
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PermissionCheckResult } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the effective permission for a surface/input, applying the current
|
|
5
|
+
* session rules internally.
|
|
6
|
+
*
|
|
7
|
+
* Collapses the `checkPermission` + `getSessionRuleset` relay that every gate
|
|
8
|
+
* previously threaded by hand: the ruleset was only ever fetched to be passed
|
|
9
|
+
* straight back into `checkPermission`, so the two are one operation.
|
|
10
|
+
*/
|
|
11
|
+
export interface PermissionResolver {
|
|
12
|
+
resolve(
|
|
13
|
+
surface: string,
|
|
14
|
+
input: unknown,
|
|
15
|
+
agentName?: string,
|
|
16
|
+
): PermissionCheckResult;
|
|
17
|
+
}
|
|
@@ -4,18 +4,28 @@ import {
|
|
|
4
4
|
getActiveAgentName,
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
6
6
|
} from "./active-agent";
|
|
7
|
+
import type { AgentPrepSession } from "./agent-prep-session";
|
|
7
8
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
8
9
|
import type { ExtensionPaths } from "./extension-paths";
|
|
9
10
|
import type { ForwardingController } from "./forwarding-manager";
|
|
11
|
+
import type { GateHandlerSession } from "./gate-handler-session";
|
|
12
|
+
import type { GatePrompter } from "./gate-prompter";
|
|
10
13
|
import type { PermissionPromptDecision } from "./permission-dialog";
|
|
11
14
|
import type { PermissionManager } from "./permission-manager";
|
|
12
15
|
import type { PromptPermissionDetails } from "./permission-prompter";
|
|
16
|
+
import type { PermissionResolver } from "./permission-resolver";
|
|
13
17
|
import type { Rule } from "./rule";
|
|
14
18
|
import { createPermissionManagerForCwd } from "./runtime";
|
|
15
19
|
import type { SessionApproval } from "./session-approval";
|
|
20
|
+
import type { SessionApprovalRecorder } from "./session-approval-recorder";
|
|
21
|
+
import type { SessionLifecycleSession } from "./session-lifecycle-session";
|
|
16
22
|
import type { SessionLogger } from "./session-logger";
|
|
17
23
|
import { SessionRules } from "./session-rules";
|
|
18
24
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
25
|
+
import {
|
|
26
|
+
resolveToolPreviewLimits,
|
|
27
|
+
type ToolPreviewFormatterOptions,
|
|
28
|
+
} from "./tool-preview-formatter";
|
|
19
29
|
import type { PermissionCheckResult, PermissionState } from "./types";
|
|
20
30
|
|
|
21
31
|
/**
|
|
@@ -54,7 +64,15 @@ export interface PermissionSessionRuntimeDeps {
|
|
|
54
64
|
* - `ForwardingController` — polling lifecycle
|
|
55
65
|
* - `PermissionSessionRuntimeDeps` — config refresh + log delegates
|
|
56
66
|
*/
|
|
57
|
-
export class PermissionSession
|
|
67
|
+
export class PermissionSession
|
|
68
|
+
implements
|
|
69
|
+
PermissionResolver,
|
|
70
|
+
SessionApprovalRecorder,
|
|
71
|
+
GatePrompter,
|
|
72
|
+
GateHandlerSession,
|
|
73
|
+
AgentPrepSession,
|
|
74
|
+
SessionLifecycleSession
|
|
75
|
+
{
|
|
58
76
|
private context: ExtensionContext | null = null;
|
|
59
77
|
private permissionManager: PermissionManager;
|
|
60
78
|
private readonly sessionRules = new SessionRules();
|
|
@@ -110,6 +128,24 @@ export class PermissionSession {
|
|
|
110
128
|
);
|
|
111
129
|
}
|
|
112
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the effective permission for a surface/input, applying the current
|
|
133
|
+
* session rules. Composes `checkPermission` with `getSessionRuleset` so
|
|
134
|
+
* callers never thread the ruleset by hand.
|
|
135
|
+
*/
|
|
136
|
+
resolve(
|
|
137
|
+
surface: string,
|
|
138
|
+
input: unknown,
|
|
139
|
+
agentName?: string,
|
|
140
|
+
): PermissionCheckResult {
|
|
141
|
+
return this.checkPermission(
|
|
142
|
+
surface,
|
|
143
|
+
input,
|
|
144
|
+
agentName,
|
|
145
|
+
this.getSessionRuleset(),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
113
149
|
getToolPermission(toolName: string, agentName?: string): PermissionState {
|
|
114
150
|
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
115
151
|
}
|
|
@@ -251,13 +287,25 @@ export class PermissionSession {
|
|
|
251
287
|
|
|
252
288
|
// ── Infrastructure paths ───────────────────────────────────────────────
|
|
253
289
|
|
|
254
|
-
|
|
255
|
-
|
|
290
|
+
/**
|
|
291
|
+
* Combined infrastructure read directories: static paths from
|
|
292
|
+
* `ExtensionPaths` plus config-derived paths.
|
|
293
|
+
*/
|
|
294
|
+
getInfrastructureReadDirs(): string[] {
|
|
295
|
+
return [
|
|
296
|
+
...this.paths.piInfrastructureDirs,
|
|
297
|
+
...(this.config.piInfrastructureReadPaths ?? []),
|
|
298
|
+
];
|
|
256
299
|
}
|
|
257
300
|
|
|
258
|
-
/**
|
|
259
|
-
|
|
260
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Resolved tool-preview formatter options from the current config.
|
|
303
|
+
*
|
|
304
|
+
* Replaces the handler's `resolveToolPreviewLimits(session.config)` reach
|
|
305
|
+
* so the pipeline reads a clean value rather than pulling raw config.
|
|
306
|
+
*/
|
|
307
|
+
getToolPreviewLimits(): ToolPreviewFormatterOptions {
|
|
308
|
+
return resolveToolPreviewLimits(this.config);
|
|
261
309
|
}
|
|
262
310
|
|
|
263
311
|
// ── Prompting ──────────────────────────────────────────────────────────
|
|
@@ -275,8 +323,28 @@ export class PermissionSession {
|
|
|
275
323
|
return this.runtimeDeps.promptPermission(ctx, details);
|
|
276
324
|
}
|
|
277
325
|
|
|
278
|
-
/**
|
|
279
|
-
|
|
280
|
-
|
|
326
|
+
/**
|
|
327
|
+
* Whether an interactive confirmation is possible using the stored context.
|
|
328
|
+
* Returns `false` when no context is active (before `activate` is called).
|
|
329
|
+
* Implements {@link GatePrompter}.
|
|
330
|
+
*/
|
|
331
|
+
canConfirm(): boolean {
|
|
332
|
+
return this.context !== null && this.canPrompt(this.context);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Prompt the user for a permission decision using the stored context.
|
|
337
|
+
* Throws if no context is active — `canConfirm()` guards this in normal use.
|
|
338
|
+
* Implements {@link GatePrompter}.
|
|
339
|
+
*/
|
|
340
|
+
promptPermission(
|
|
341
|
+
details: PromptPermissionDetails,
|
|
342
|
+
): Promise<PermissionPromptDecision> {
|
|
343
|
+
if (this.context === null) {
|
|
344
|
+
return Promise.reject(
|
|
345
|
+
new Error("promptPermission called before the session was activated"),
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return this.prompt(this.context, details);
|
|
281
349
|
}
|
|
282
350
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { buildInputForSurface } from "./input-normalizer";
|
|
2
|
+
import type { PermissionManager } from "./permission-manager";
|
|
3
|
+
import type { PermissionsService } from "./service";
|
|
4
|
+
import type { SessionRules } from "./session-rules";
|
|
5
|
+
import type {
|
|
6
|
+
ToolInputFormatter,
|
|
7
|
+
ToolInputFormatterRegistry,
|
|
8
|
+
} from "./tool-input-formatter-registry";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* In-process implementation of the cross-extension {@link PermissionsService}.
|
|
12
|
+
*
|
|
13
|
+
* Constructed once in the composition root and backed by the runtime's
|
|
14
|
+
* permission manager and session rules. Both injected instances are stable
|
|
15
|
+
* for the lifetime of the factory — `runtime.permissionManager` is never
|
|
16
|
+
* reassigned on the runtime object (only `PermissionSession` reassigns its
|
|
17
|
+
* own internal copy), and `runtime.sessionRules` is `readonly`.
|
|
18
|
+
*/
|
|
19
|
+
export class LocalPermissionsService implements PermissionsService {
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly permissionManager: PermissionManager,
|
|
22
|
+
private readonly sessionRules: SessionRules,
|
|
23
|
+
private readonly formatterRegistry: ToolInputFormatterRegistry,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
checkPermission(
|
|
27
|
+
surface: string,
|
|
28
|
+
value?: string,
|
|
29
|
+
agentName?: string,
|
|
30
|
+
): ReturnType<PermissionsService["checkPermission"]> {
|
|
31
|
+
const input = buildInputForSurface(surface, value);
|
|
32
|
+
return this.permissionManager.checkPermission(
|
|
33
|
+
surface,
|
|
34
|
+
input,
|
|
35
|
+
agentName,
|
|
36
|
+
this.sessionRules.getRuleset(),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getToolPermission(
|
|
41
|
+
toolName: string,
|
|
42
|
+
agentName?: string,
|
|
43
|
+
): ReturnType<PermissionsService["getToolPermission"]> {
|
|
44
|
+
return this.permissionManager.getToolPermission(toolName, agentName);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
registerToolInputFormatter(
|
|
48
|
+
toolName: string,
|
|
49
|
+
formatter: ToolInputFormatter,
|
|
50
|
+
): ReturnType<PermissionsService["registerToolInputFormatter"]> {
|
|
51
|
+
return this.formatterRegistry.register(toolName, formatter);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
|
|
4
|
+
import {
|
|
5
|
+
type PermissionsService,
|
|
6
|
+
publishPermissionsService,
|
|
7
|
+
unpublishPermissionsService,
|
|
8
|
+
} from "./service";
|
|
9
|
+
import { isRegisteredSubagentChild } from "./subagent-context";
|
|
10
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
11
|
+
|
|
12
|
+
/** The session-scoped service lifecycle that the lifecycle handler drives. */
|
|
13
|
+
export interface ServiceLifecycle {
|
|
14
|
+
activate(ctx: ExtensionContext): void;
|
|
15
|
+
teardown(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Owns the process-global service publication lifecycle for one extension
|
|
20
|
+
* instance.
|
|
21
|
+
*
|
|
22
|
+
* - `activate` publishes the service (skipped for registered subagent children
|
|
23
|
+
* so they never clobber the parent's slot — see #302), then emits the ready
|
|
24
|
+
* event.
|
|
25
|
+
* - `teardown` runs all session-scoped subscription cleanups in order, then
|
|
26
|
+
* unpublishes the service.
|
|
27
|
+
*/
|
|
28
|
+
export class PermissionServiceLifecycle implements ServiceLifecycle {
|
|
29
|
+
constructor(
|
|
30
|
+
private readonly service: PermissionsService,
|
|
31
|
+
private readonly registry: SubagentSessionRegistry,
|
|
32
|
+
private readonly events: PermissionEventBus,
|
|
33
|
+
private readonly subscriptions: readonly (() => void)[],
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
activate(ctx: ExtensionContext): void {
|
|
37
|
+
if (!isRegisteredSubagentChild(ctx, this.registry)) {
|
|
38
|
+
publishPermissionsService(this.service);
|
|
39
|
+
}
|
|
40
|
+
emitReadyEvent(this.events);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
teardown(): void {
|
|
44
|
+
for (const unsubscribe of this.subscriptions) {
|
|
45
|
+
unsubscribe();
|
|
46
|
+
}
|
|
47
|
+
unpublishPermissionsService(this.service);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { SessionLogger } from "./session-logger";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The session surface `SessionLifecycleHandler` invokes across
|
|
7
|
+
* `session_start`, `resources_discover`, and `session_shutdown`: refresh and
|
|
8
|
+
* report config, reset / reload / shut down session state, resolve the agent
|
|
9
|
+
* name, surface config issues, read the runtime context, and log.
|
|
10
|
+
*
|
|
11
|
+
* `activate` is intentionally absent — the lifecycle handler never calls it
|
|
12
|
+
* directly (ISP: do not depend on methods you do not use).
|
|
13
|
+
*/
|
|
14
|
+
export interface SessionLifecycleSession {
|
|
15
|
+
refreshConfig(ctx?: ExtensionContext): void;
|
|
16
|
+
resetForNewSession(ctx: ExtensionContext): void;
|
|
17
|
+
logResolvedConfigPaths(): void;
|
|
18
|
+
resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
|
|
19
|
+
getConfigIssues(agentName?: string): string[];
|
|
20
|
+
reload(): void;
|
|
21
|
+
getRuntimeContext(): ExtensionContext | null;
|
|
22
|
+
shutdown(): void;
|
|
23
|
+
readonly logger: SessionLogger;
|
|
24
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
2
1
|
import { safeJsonStringify } from "./logging";
|
|
3
2
|
|
|
4
3
|
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
@@ -25,67 +24,6 @@ export function formatCount(
|
|
|
25
24
|
return `${value} ${value === 1 ? singular : plural}`;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
export function getPromptPath(input: Record<string, unknown>): string | null {
|
|
29
|
-
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function formatEditInputForPrompt(
|
|
33
|
-
input: Record<string, unknown>,
|
|
34
|
-
): string {
|
|
35
|
-
const path = getPromptPath(input);
|
|
36
|
-
const rawEdits = Array.isArray(input.edits)
|
|
37
|
-
? input.edits
|
|
38
|
-
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
39
|
-
? [{ oldText: input.oldText, newText: input.newText }]
|
|
40
|
-
: [];
|
|
41
|
-
|
|
42
|
-
const edits = rawEdits
|
|
43
|
-
.map((edit) => toRecord(edit))
|
|
44
|
-
.filter(
|
|
45
|
-
(edit) =>
|
|
46
|
-
typeof edit.oldText === "string" && typeof edit.newText === "string",
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
const pathPart = path ? `for '${path}'` : "";
|
|
50
|
-
if (edits.length === 0) {
|
|
51
|
-
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const firstEdit = edits[0];
|
|
55
|
-
const oldText = String(firstEdit.oldText);
|
|
56
|
-
const newText = String(firstEdit.newText);
|
|
57
|
-
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
58
|
-
const extraEdits =
|
|
59
|
-
edits.length > 1
|
|
60
|
-
? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
|
|
61
|
-
: "";
|
|
62
|
-
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
63
|
-
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function formatWriteInputForPrompt(
|
|
67
|
-
input: Record<string, unknown>,
|
|
68
|
-
): string {
|
|
69
|
-
const path = getPromptPath(input);
|
|
70
|
-
const content = typeof input.content === "string" ? input.content : "";
|
|
71
|
-
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
72
|
-
return path ? `for '${path}' ${summary}` : summary;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function formatReadInputForPrompt(
|
|
76
|
-
input: Record<string, unknown>,
|
|
77
|
-
): string {
|
|
78
|
-
const path = getPromptPath(input);
|
|
79
|
-
const parts = path ? [`path '${path}'`] : [];
|
|
80
|
-
if (typeof input.offset === "number") {
|
|
81
|
-
parts.push(`offset ${input.offset}`);
|
|
82
|
-
}
|
|
83
|
-
if (typeof input.limit === "number") {
|
|
84
|
-
parts.push(`limit ${input.limit}`);
|
|
85
|
-
}
|
|
86
|
-
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
27
|
export function serializeToolInputPreview(input: unknown): string {
|
|
90
28
|
const serialized = safeJsonStringify(input);
|
|
91
29
|
if (!serialized || serialized === "{}" || serialized === "null") {
|