@gotgenes/pi-permission-system 9.2.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 +52 -0
- package/README.md +12 -11
- 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/io.ts +29 -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 +50 -68
- package/src/mcp-targets.ts +56 -46
- package/src/permission-event-rpc.ts +7 -0
- package/src/permission-events.ts +89 -8
- package/src/permission-forwarding.ts +23 -0
- package/src/permission-prompter.ts +27 -56
- package/src/permission-resolver.ts +17 -0
- package/src/permission-session.ts +77 -9
- package/src/permission-ui-prompt.ts +127 -0
- package/src/permissions-service.ts +53 -0
- package/src/service-lifecycle.ts +49 -0
- package/src/service.ts +17 -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/composition-root.test.ts +5 -0
- 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-event-rpc.test.ts +39 -0
- package/test/permission-events.test.ts +78 -10
- package/test/permission-forwarder.test.ts +295 -0
- package/test/permission-prompter.test.ts +147 -38
- package/test/permission-session.test.ts +160 -27
- package/test/permission-ui-prompt.test.ts +146 -0
- 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 -379
|
@@ -4,13 +4,11 @@ import { ForwardingManager } from "#src/forwarding-manager";
|
|
|
4
4
|
|
|
5
5
|
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const mockProcessInbox = vi.hoisted(() =>
|
|
8
|
+
vi.fn((): Promise<void> => Promise.resolve()),
|
|
9
|
+
);
|
|
8
10
|
const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
|
|
9
11
|
|
|
10
|
-
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
11
|
-
processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
12
|
vi.mock("../src/subagent-context", () => ({
|
|
15
13
|
isSubagentExecutionContext: mockIsSubagentExecutionContext,
|
|
16
14
|
}));
|
|
@@ -27,22 +25,12 @@ function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
|
|
|
27
25
|
} as unknown as import("@earendil-works/pi-coding-agent").ExtensionContext;
|
|
28
26
|
}
|
|
29
27
|
|
|
30
|
-
function
|
|
31
|
-
return {
|
|
32
|
-
forwardingDir: "/agent/sessions/permission-forwarding",
|
|
33
|
-
subagentSessionsDir: "/agent/subagent-sessions",
|
|
34
|
-
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
35
|
-
writeReviewLog: vi.fn(),
|
|
36
|
-
requestPermissionDecisionFromUi: vi.fn(),
|
|
37
|
-
shouldAutoApprove: vi.fn().mockReturnValue(false),
|
|
38
|
-
} as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
|
|
28
|
+
function makeForwarder() {
|
|
29
|
+
return { processInbox: mockProcessInbox };
|
|
39
30
|
}
|
|
40
31
|
|
|
41
32
|
function makeManager() {
|
|
42
|
-
return new ForwardingManager(
|
|
43
|
-
"/agent/subagent-sessions",
|
|
44
|
-
makeForwardingDeps(),
|
|
45
|
-
);
|
|
33
|
+
return new ForwardingManager("/agent/subagent-sessions", makeForwarder());
|
|
46
34
|
}
|
|
47
35
|
|
|
48
36
|
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
@@ -52,8 +40,8 @@ describe("ForwardingManager", () => {
|
|
|
52
40
|
vi.useFakeTimers();
|
|
53
41
|
mockIsSubagentExecutionContext.mockReset();
|
|
54
42
|
mockIsSubagentExecutionContext.mockReturnValue(false);
|
|
55
|
-
|
|
56
|
-
|
|
43
|
+
mockProcessInbox.mockReset();
|
|
44
|
+
mockProcessInbox.mockResolvedValue(undefined);
|
|
57
45
|
});
|
|
58
46
|
|
|
59
47
|
afterEach(() => {
|
|
@@ -73,9 +61,9 @@ describe("ForwardingManager", () => {
|
|
|
73
61
|
manager.stop();
|
|
74
62
|
|
|
75
63
|
// After stop, the timer fires no more callbacks.
|
|
76
|
-
|
|
64
|
+
mockProcessInbox.mockClear();
|
|
77
65
|
await vi.advanceTimersByTimeAsync(500);
|
|
78
|
-
expect(
|
|
66
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
79
67
|
});
|
|
80
68
|
});
|
|
81
69
|
|
|
@@ -86,7 +74,7 @@ describe("ForwardingManager", () => {
|
|
|
86
74
|
manager.start(ctx);
|
|
87
75
|
|
|
88
76
|
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
-
expect(
|
|
77
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
90
78
|
});
|
|
91
79
|
|
|
92
80
|
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
|
|
@@ -98,9 +86,9 @@ describe("ForwardingManager", () => {
|
|
|
98
86
|
// Now stop the polling by calling start() with no-UI ctx.
|
|
99
87
|
manager.start(noUiCtx);
|
|
100
88
|
|
|
101
|
-
|
|
89
|
+
mockProcessInbox.mockClear();
|
|
102
90
|
await vi.advanceTimersByTimeAsync(500);
|
|
103
|
-
expect(
|
|
91
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
104
92
|
});
|
|
105
93
|
|
|
106
94
|
it("does not start polling when isSubagentExecutionContext returns true", async () => {
|
|
@@ -110,7 +98,7 @@ describe("ForwardingManager", () => {
|
|
|
110
98
|
manager.start(ctx);
|
|
111
99
|
|
|
112
100
|
await vi.advanceTimersByTimeAsync(500);
|
|
113
|
-
expect(
|
|
101
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
114
102
|
});
|
|
115
103
|
|
|
116
104
|
it("stops any existing poll when called with a subagent context", async () => {
|
|
@@ -124,21 +112,18 @@ describe("ForwardingManager", () => {
|
|
|
124
112
|
const ctx2 = makeCtx();
|
|
125
113
|
manager.start(ctx2);
|
|
126
114
|
|
|
127
|
-
|
|
115
|
+
mockProcessInbox.mockClear();
|
|
128
116
|
await vi.advanceTimersByTimeAsync(500);
|
|
129
|
-
expect(
|
|
117
|
+
expect(mockProcessInbox).not.toHaveBeenCalled();
|
|
130
118
|
});
|
|
131
119
|
|
|
132
|
-
it("starts polling and calls
|
|
120
|
+
it("starts polling and calls processInbox on tick", async () => {
|
|
133
121
|
const manager = makeManager();
|
|
134
122
|
const ctx = makeCtx();
|
|
135
123
|
manager.start(ctx);
|
|
136
124
|
|
|
137
125
|
await vi.advanceTimersByTimeAsync(250);
|
|
138
|
-
expect(
|
|
139
|
-
ctx,
|
|
140
|
-
expect.anything(),
|
|
141
|
-
);
|
|
126
|
+
expect(mockProcessInbox).toHaveBeenCalledWith(ctx);
|
|
142
127
|
});
|
|
143
128
|
|
|
144
129
|
it("is idempotent — calling start() twice does not create a second timer", async () => {
|
|
@@ -149,7 +134,7 @@ describe("ForwardingManager", () => {
|
|
|
149
134
|
|
|
150
135
|
await vi.advanceTimersByTimeAsync(250);
|
|
151
136
|
// Only one tick should fire per interval, not two.
|
|
152
|
-
expect(
|
|
137
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
153
138
|
});
|
|
154
139
|
|
|
155
140
|
it("updates the context when called again while already running", async () => {
|
|
@@ -161,16 +146,13 @@ describe("ForwardingManager", () => {
|
|
|
161
146
|
|
|
162
147
|
await vi.advanceTimersByTimeAsync(250);
|
|
163
148
|
// The process call should use the newer context.
|
|
164
|
-
expect(
|
|
165
|
-
ctx2,
|
|
166
|
-
expect.anything(),
|
|
167
|
-
);
|
|
149
|
+
expect(mockProcessInbox).toHaveBeenCalledWith(ctx2);
|
|
168
150
|
});
|
|
169
151
|
|
|
170
152
|
it("skips a tick while processing is in progress", async () => {
|
|
171
|
-
// Make
|
|
153
|
+
// Make processInbox hang so processing=true persists.
|
|
172
154
|
let resolveProcess: () => void;
|
|
173
|
-
|
|
155
|
+
mockProcessInbox.mockReturnValue(
|
|
174
156
|
new Promise<void>((resolve) => {
|
|
175
157
|
resolveProcess = resolve;
|
|
176
158
|
}),
|
|
@@ -182,22 +164,22 @@ describe("ForwardingManager", () => {
|
|
|
182
164
|
|
|
183
165
|
// First tick starts processing.
|
|
184
166
|
await vi.advanceTimersByTimeAsync(250);
|
|
185
|
-
expect(
|
|
167
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
186
168
|
|
|
187
169
|
// Second tick is skipped because processing flag is still true.
|
|
188
170
|
await vi.advanceTimersByTimeAsync(250);
|
|
189
|
-
expect(
|
|
171
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(1);
|
|
190
172
|
|
|
191
173
|
// Resolve and a third tick should fire.
|
|
192
174
|
resolveProcess!();
|
|
193
175
|
await vi.advanceTimersByTimeAsync(250);
|
|
194
|
-
expect(
|
|
176
|
+
expect(mockProcessInbox).toHaveBeenCalledTimes(2);
|
|
195
177
|
});
|
|
196
178
|
|
|
197
179
|
it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
|
|
198
180
|
const manager = new ForwardingManager(
|
|
199
181
|
"/custom/subagent-dir",
|
|
200
|
-
|
|
182
|
+
makeForwarder(),
|
|
201
183
|
);
|
|
202
184
|
const ctx = makeCtx();
|
|
203
185
|
manager.start(ctx);
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
+
import type { AgentPrepSession } from "#src/agent-prep-session";
|
|
3
4
|
import {
|
|
4
5
|
AgentPrepHandler,
|
|
5
6
|
shouldExposeTool,
|
|
6
7
|
} from "#src/handlers/before-agent-start";
|
|
7
|
-
import type { PermissionSession } from "#src/permission-session";
|
|
8
8
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
9
9
|
|
|
10
|
-
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
10
|
+
import { makeCheckResult, makeCtx } from "#test/helpers/handler-fixtures";
|
|
11
11
|
|
|
12
12
|
// ── SDK stubs ──────────────────────────────────────────────────────────────
|
|
13
13
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -26,24 +26,48 @@ function makeEvent(systemPrompt = "You are an assistant.") {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function makeSession(
|
|
29
|
-
overrides: Partial<
|
|
30
|
-
):
|
|
29
|
+
overrides: Partial<AgentPrepSession> = {},
|
|
30
|
+
): AgentPrepSession {
|
|
31
31
|
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
resolveAgentName:
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
activate: overrides.activate ?? vi.fn<AgentPrepSession["activate"]>(),
|
|
33
|
+
refreshConfig:
|
|
34
|
+
overrides.refreshConfig ?? vi.fn<AgentPrepSession["refreshConfig"]>(),
|
|
35
|
+
resolveAgentName:
|
|
36
|
+
overrides.resolveAgentName ??
|
|
37
|
+
vi.fn<AgentPrepSession["resolveAgentName"]>().mockReturnValue(null),
|
|
38
|
+
checkPermission:
|
|
39
|
+
overrides.checkPermission ??
|
|
40
|
+
vi
|
|
41
|
+
.fn<AgentPrepSession["checkPermission"]>()
|
|
42
|
+
.mockReturnValue(makeCheckResult()),
|
|
43
|
+
getToolPermission:
|
|
44
|
+
overrides.getToolPermission ??
|
|
45
|
+
vi.fn<AgentPrepSession["getToolPermission"]>().mockReturnValue("allow"),
|
|
46
|
+
shouldUpdateActiveTools:
|
|
47
|
+
overrides.shouldUpdateActiveTools ??
|
|
48
|
+
vi
|
|
49
|
+
.fn<AgentPrepSession["shouldUpdateActiveTools"]>()
|
|
50
|
+
.mockReturnValue(true),
|
|
51
|
+
commitActiveToolsCacheKey:
|
|
52
|
+
overrides.commitActiveToolsCacheKey ??
|
|
53
|
+
vi.fn<AgentPrepSession["commitActiveToolsCacheKey"]>(),
|
|
54
|
+
getPolicyCacheStamp:
|
|
55
|
+
overrides.getPolicyCacheStamp ??
|
|
56
|
+
vi
|
|
57
|
+
.fn<AgentPrepSession["getPolicyCacheStamp"]>()
|
|
58
|
+
.mockReturnValue("stamp-1"),
|
|
59
|
+
shouldUpdatePromptState:
|
|
60
|
+
overrides.shouldUpdatePromptState ??
|
|
61
|
+
vi
|
|
62
|
+
.fn<AgentPrepSession["shouldUpdatePromptState"]>()
|
|
63
|
+
.mockReturnValue(true),
|
|
64
|
+
commitPromptStateCacheKey:
|
|
65
|
+
overrides.commitPromptStateCacheKey ??
|
|
66
|
+
vi.fn<AgentPrepSession["commitPromptStateCacheKey"]>(),
|
|
67
|
+
setActiveSkillEntries:
|
|
68
|
+
overrides.setActiveSkillEntries ??
|
|
69
|
+
vi.fn<AgentPrepSession["setActiveSkillEntries"]>(),
|
|
70
|
+
};
|
|
47
71
|
}
|
|
48
72
|
|
|
49
73
|
function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
@@ -55,11 +79,11 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
|
55
79
|
}
|
|
56
80
|
|
|
57
81
|
function makeHandler(overrides?: {
|
|
58
|
-
session?: Partial<
|
|
82
|
+
session?: Partial<AgentPrepSession>;
|
|
59
83
|
toolRegistry?: Partial<ToolRegistry>;
|
|
60
84
|
}): {
|
|
61
85
|
handler: AgentPrepHandler;
|
|
62
|
-
session:
|
|
86
|
+
session: AgentPrepSession;
|
|
63
87
|
toolRegistry: ToolRegistry;
|
|
64
88
|
} {
|
|
65
89
|
const session = makeSession(overrides?.session);
|
|
@@ -8,18 +8,25 @@
|
|
|
8
8
|
* Regression guard: importing the four external-directory message helpers
|
|
9
9
|
* ensures the test file fails to load if any helper is removed.
|
|
10
10
|
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import { describe, expect, it, vi } from "vitest";
|
|
12
14
|
|
|
15
|
+
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
13
16
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
14
17
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
15
18
|
import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
|
|
19
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
20
|
+
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
21
|
+
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
16
22
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
17
|
-
import
|
|
23
|
+
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
18
24
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
19
25
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
20
26
|
|
|
21
27
|
import {
|
|
22
28
|
getDecisionEvents,
|
|
29
|
+
type MockGateHandlerSession,
|
|
23
30
|
makeCtx,
|
|
24
31
|
makeEvents,
|
|
25
32
|
makeToolCallEvent,
|
|
@@ -73,24 +80,71 @@ function makeCheckPermission(
|
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
function makeSession(
|
|
76
|
-
overrides: Partial<
|
|
77
|
-
):
|
|
78
|
-
|
|
79
|
-
logger:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
83
|
+
overrides: Partial<MockGateHandlerSession> = {},
|
|
84
|
+
): MockGateHandlerSession {
|
|
85
|
+
const session: MockGateHandlerSession = {
|
|
86
|
+
logger: overrides.logger ?? {
|
|
87
|
+
debug: vi.fn(),
|
|
88
|
+
review: vi.fn(),
|
|
89
|
+
warn: vi.fn(),
|
|
90
|
+
},
|
|
91
|
+
activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
|
|
92
|
+
resolveAgentName:
|
|
93
|
+
overrides.resolveAgentName ??
|
|
94
|
+
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
95
|
+
checkPermission: overrides.checkPermission ?? makeCheckPermission("deny"),
|
|
96
|
+
getSessionRuleset:
|
|
97
|
+
overrides.getSessionRuleset ??
|
|
98
|
+
vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
|
|
99
|
+
recordSessionApproval:
|
|
100
|
+
overrides.recordSessionApproval ??
|
|
101
|
+
vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
|
|
102
|
+
getActiveSkillEntries:
|
|
103
|
+
overrides.getActiveSkillEntries ??
|
|
104
|
+
vi
|
|
105
|
+
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
106
|
+
.mockReturnValue([]),
|
|
107
|
+
getInfrastructureReadDirs:
|
|
108
|
+
overrides.getInfrastructureReadDirs ??
|
|
109
|
+
vi
|
|
110
|
+
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
111
|
+
.mockReturnValue([]),
|
|
112
|
+
getToolPreviewLimits:
|
|
113
|
+
overrides.getToolPreviewLimits ??
|
|
114
|
+
vi
|
|
115
|
+
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
116
|
+
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
117
|
+
canPrompt:
|
|
118
|
+
overrides.canPrompt ??
|
|
119
|
+
vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
|
|
120
|
+
prompt:
|
|
121
|
+
overrides.prompt ??
|
|
122
|
+
vi
|
|
123
|
+
.fn<MockGateHandlerSession["prompt"]>()
|
|
124
|
+
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
125
|
+
// Delegations — closures read `session` at call time so overrides win.
|
|
126
|
+
resolve:
|
|
127
|
+
overrides.resolve ??
|
|
128
|
+
vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
|
|
129
|
+
session.checkPermission(
|
|
130
|
+
surface,
|
|
131
|
+
input,
|
|
132
|
+
agentName,
|
|
133
|
+
session.getSessionRuleset(),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
canConfirm:
|
|
137
|
+
overrides.canConfirm ??
|
|
138
|
+
vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
|
|
139
|
+
session.canPrompt(undefined as unknown as ExtensionContext),
|
|
140
|
+
),
|
|
141
|
+
promptPermission:
|
|
142
|
+
overrides.promptPermission ??
|
|
143
|
+
vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
|
|
144
|
+
session.prompt(undefined as unknown as ExtensionContext, details),
|
|
145
|
+
),
|
|
146
|
+
};
|
|
147
|
+
return session;
|
|
94
148
|
}
|
|
95
149
|
|
|
96
150
|
/** All PATH_BEARING_TOOLS members. */
|
|
@@ -112,17 +166,27 @@ function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
|
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
function makeHandler(overrides?: {
|
|
115
|
-
session?: Partial<
|
|
169
|
+
session?: Partial<MockGateHandlerSession>;
|
|
116
170
|
toolRegistry?: Partial<ToolRegistry>;
|
|
117
171
|
}): {
|
|
118
172
|
handler: PermissionGateHandler;
|
|
119
173
|
events: ReturnType<typeof makeEvents>;
|
|
120
|
-
session:
|
|
174
|
+
session: MockGateHandlerSession;
|
|
121
175
|
} {
|
|
122
176
|
const session = makeSession(overrides?.session);
|
|
123
177
|
const events = makeEvents();
|
|
124
178
|
const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
|
|
125
|
-
const
|
|
179
|
+
const pipeline = new ToolCallGatePipeline(session);
|
|
180
|
+
const skillInputPipeline = new SkillInputGatePipeline(session);
|
|
181
|
+
const reporter = new GateDecisionReporter(session.logger, events);
|
|
182
|
+
const runner = new GateRunner(session, session, session, reporter);
|
|
183
|
+
const handler = new PermissionGateHandler(
|
|
184
|
+
session,
|
|
185
|
+
toolRegistry,
|
|
186
|
+
pipeline,
|
|
187
|
+
skillInputPipeline,
|
|
188
|
+
runner,
|
|
189
|
+
);
|
|
126
190
|
return { handler, events, session };
|
|
127
191
|
}
|
|
128
192
|
|
|
@@ -8,18 +8,29 @@
|
|
|
8
8
|
* the real interaction between PermissionSession, SessionRules, and
|
|
9
9
|
* PermissionManager.
|
|
10
10
|
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
13
|
import { describe, expect, it, vi } from "vitest";
|
|
12
14
|
|
|
15
|
+
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
13
16
|
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
|
|
17
|
+
import { GateRunner } from "#src/handlers/gates/runner";
|
|
18
|
+
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
19
|
+
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
14
20
|
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
15
|
-
import type {
|
|
21
|
+
import type { PromptPermissionDetails } from "#src/permission-prompter";
|
|
16
22
|
import type { Rule } from "#src/rule";
|
|
17
23
|
import type { SessionApproval } from "#src/session-approval";
|
|
24
|
+
import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
|
|
18
25
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
19
26
|
import type { PermissionCheckResult } from "#src/types";
|
|
20
27
|
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
21
28
|
|
|
22
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
type MockGateHandlerSession,
|
|
31
|
+
makeCtx,
|
|
32
|
+
makeEvents,
|
|
33
|
+
} from "#test/helpers/handler-fixtures";
|
|
23
34
|
|
|
24
35
|
// ── SDK stub ───────────────────────────────────────────────────────────────
|
|
25
36
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -39,12 +50,12 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
39
50
|
* "allow" by default.
|
|
40
51
|
*/
|
|
41
52
|
function makeStatefulSession(
|
|
42
|
-
overrides: Partial<
|
|
43
|
-
):
|
|
53
|
+
overrides: Partial<MockGateHandlerSession> = {},
|
|
54
|
+
): MockGateHandlerSession {
|
|
44
55
|
const sessionRules: Rule[] = [];
|
|
45
56
|
|
|
46
57
|
const checkPermission = vi
|
|
47
|
-
.fn()
|
|
58
|
+
.fn<MockGateHandlerSession["checkPermission"]>()
|
|
48
59
|
.mockImplementation(
|
|
49
60
|
(
|
|
50
61
|
surface: string,
|
|
@@ -97,7 +108,7 @@ function makeStatefulSession(
|
|
|
97
108
|
);
|
|
98
109
|
|
|
99
110
|
const recordSessionApproval = vi
|
|
100
|
-
.fn()
|
|
111
|
+
.fn<MockGateHandlerSession["recordSessionApproval"]>()
|
|
101
112
|
.mockImplementation((approval: SessionApproval) => {
|
|
102
113
|
for (const pattern of approval.patterns) {
|
|
103
114
|
sessionRules.push({
|
|
@@ -110,26 +121,86 @@ function makeStatefulSession(
|
|
|
110
121
|
}
|
|
111
122
|
});
|
|
112
123
|
|
|
113
|
-
const getSessionRuleset = vi
|
|
124
|
+
const getSessionRuleset = vi
|
|
125
|
+
.fn<MockGateHandlerSession["getSessionRuleset"]>()
|
|
126
|
+
.mockImplementation(() => [...sessionRules]);
|
|
127
|
+
|
|
128
|
+
const session: MockGateHandlerSession = {
|
|
129
|
+
logger: overrides.logger ?? {
|
|
130
|
+
debug: vi.fn(),
|
|
131
|
+
review: vi.fn(),
|
|
132
|
+
warn: vi.fn(),
|
|
133
|
+
},
|
|
134
|
+
activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
|
|
135
|
+
resolveAgentName:
|
|
136
|
+
overrides.resolveAgentName ??
|
|
137
|
+
vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
|
|
138
|
+
checkPermission: overrides.checkPermission ?? checkPermission,
|
|
139
|
+
getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
|
|
140
|
+
recordSessionApproval:
|
|
141
|
+
overrides.recordSessionApproval ?? recordSessionApproval,
|
|
142
|
+
getActiveSkillEntries:
|
|
143
|
+
overrides.getActiveSkillEntries ??
|
|
144
|
+
vi
|
|
145
|
+
.fn<MockGateHandlerSession["getActiveSkillEntries"]>()
|
|
146
|
+
.mockReturnValue([]),
|
|
147
|
+
getInfrastructureReadDirs:
|
|
148
|
+
overrides.getInfrastructureReadDirs ??
|
|
149
|
+
vi
|
|
150
|
+
.fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
|
|
151
|
+
.mockReturnValue([]),
|
|
152
|
+
getToolPreviewLimits:
|
|
153
|
+
overrides.getToolPreviewLimits ??
|
|
154
|
+
vi
|
|
155
|
+
.fn<MockGateHandlerSession["getToolPreviewLimits"]>()
|
|
156
|
+
.mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
|
|
157
|
+
canPrompt:
|
|
158
|
+
overrides.canPrompt ??
|
|
159
|
+
vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
|
|
160
|
+
prompt:
|
|
161
|
+
overrides.prompt ??
|
|
162
|
+
vi
|
|
163
|
+
.fn<MockGateHandlerSession["prompt"]>()
|
|
164
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
165
|
+
// Delegations — closures read `session` at call time so overrides win.
|
|
166
|
+
resolve:
|
|
167
|
+
overrides.resolve ??
|
|
168
|
+
vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
|
|
169
|
+
session.checkPermission(
|
|
170
|
+
surface,
|
|
171
|
+
input,
|
|
172
|
+
agentName,
|
|
173
|
+
session.getSessionRuleset(),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
canConfirm:
|
|
177
|
+
overrides.canConfirm ??
|
|
178
|
+
vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
|
|
179
|
+
session.canPrompt(undefined as unknown as ExtensionContext),
|
|
180
|
+
),
|
|
181
|
+
promptPermission:
|
|
182
|
+
overrides.promptPermission ??
|
|
183
|
+
vi.fn<MockGateHandlerSession["promptPermission"]>(
|
|
184
|
+
(details: PromptPermissionDetails) =>
|
|
185
|
+
session.prompt(undefined as unknown as ExtensionContext, details),
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
return session;
|
|
189
|
+
}
|
|
114
190
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
prompt: vi
|
|
129
|
-
.fn()
|
|
130
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
131
|
-
...overrides,
|
|
132
|
-
} as unknown as PermissionSession;
|
|
191
|
+
function makeHandlerForSession(
|
|
192
|
+
session: MockGateHandlerSession,
|
|
193
|
+
): PermissionGateHandler {
|
|
194
|
+
const events = makeEvents();
|
|
195
|
+
const reporter = new GateDecisionReporter(session.logger, events);
|
|
196
|
+
const runner = new GateRunner(session, session, session, reporter);
|
|
197
|
+
return new PermissionGateHandler(
|
|
198
|
+
session,
|
|
199
|
+
makeToolRegistry(),
|
|
200
|
+
new ToolCallGatePipeline(session),
|
|
201
|
+
new SkillInputGatePipeline(session),
|
|
202
|
+
runner,
|
|
203
|
+
);
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
function makeToolRegistry(): ToolRegistry {
|
|
@@ -152,11 +223,7 @@ describe("external-directory session dedup", () => {
|
|
|
152
223
|
describe("path-bearing tools (read, write, edit)", () => {
|
|
153
224
|
it("does not re-prompt for the same external path after session approval", async () => {
|
|
154
225
|
const session = makeStatefulSession();
|
|
155
|
-
const handler =
|
|
156
|
-
session,
|
|
157
|
-
makeEvents(),
|
|
158
|
-
makeToolRegistry(),
|
|
159
|
-
);
|
|
226
|
+
const handler = makeHandlerForSession(session);
|
|
160
227
|
const ctx = makeCtx();
|
|
161
228
|
const externalPath = "/outside/project/data.txt";
|
|
162
229
|
|
|
@@ -185,11 +252,7 @@ describe("external-directory session dedup", () => {
|
|
|
185
252
|
|
|
186
253
|
it("does not re-prompt for a different file in the same external directory", async () => {
|
|
187
254
|
const session = makeStatefulSession();
|
|
188
|
-
const handler =
|
|
189
|
-
session,
|
|
190
|
-
makeEvents(),
|
|
191
|
-
makeToolRegistry(),
|
|
192
|
-
);
|
|
255
|
+
const handler = makeHandlerForSession(session);
|
|
193
256
|
const ctx = makeCtx();
|
|
194
257
|
|
|
195
258
|
// First call — prompt for /outside/project/a.txt
|
|
@@ -215,11 +278,7 @@ describe("external-directory session dedup", () => {
|
|
|
215
278
|
|
|
216
279
|
it("does prompt for a file in a different external directory", async () => {
|
|
217
280
|
const session = makeStatefulSession();
|
|
218
|
-
const handler =
|
|
219
|
-
session,
|
|
220
|
-
makeEvents(),
|
|
221
|
-
makeToolRegistry(),
|
|
222
|
-
);
|
|
281
|
+
const handler = makeHandlerForSession(session);
|
|
223
282
|
const ctx = makeCtx();
|
|
224
283
|
|
|
225
284
|
// First call — /outside/alpha/file.txt
|
|
@@ -249,11 +308,7 @@ describe("external-directory session dedup", () => {
|
|
|
249
308
|
.fn()
|
|
250
309
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
251
310
|
});
|
|
252
|
-
const handler =
|
|
253
|
-
session,
|
|
254
|
-
makeEvents(),
|
|
255
|
-
makeToolRegistry(),
|
|
256
|
-
);
|
|
311
|
+
const handler = makeHandlerForSession(session);
|
|
257
312
|
const ctx = makeCtx();
|
|
258
313
|
const externalPath = "/outside/project/data.txt";
|
|
259
314
|
|
|
@@ -282,11 +337,7 @@ describe("external-directory session dedup", () => {
|
|
|
282
337
|
describe("bash commands with external paths", () => {
|
|
283
338
|
it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
|
|
284
339
|
const session = makeStatefulSession();
|
|
285
|
-
const handler =
|
|
286
|
-
session,
|
|
287
|
-
makeEvents(),
|
|
288
|
-
makeToolRegistry(),
|
|
289
|
-
);
|
|
340
|
+
const handler = makeHandlerForSession(session);
|
|
290
341
|
const ctx = makeCtx();
|
|
291
342
|
|
|
292
343
|
// First call — bash referencing /tmp/out.txt
|
|
@@ -314,11 +365,7 @@ describe("external-directory session dedup", () => {
|
|
|
314
365
|
|
|
315
366
|
it("does not re-prompt for read after bash already approved the same directory", async () => {
|
|
316
367
|
const session = makeStatefulSession();
|
|
317
|
-
const handler =
|
|
318
|
-
session,
|
|
319
|
-
makeEvents(),
|
|
320
|
-
makeToolRegistry(),
|
|
321
|
-
);
|
|
368
|
+
const handler = makeHandlerForSession(session);
|
|
322
369
|
const ctx = makeCtx();
|
|
323
370
|
|
|
324
371
|
// First call — bash writes to /tmp/out.txt
|