@gotgenes/pi-permission-system 8.3.2 → 9.0.1
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 +35 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-command.ts +55 -0
- package/src/handlers/gates/bash-external-directory.ts +2 -1
- package/src/handlers/gates/bash-path-extractor.ts +9 -618
- package/src/handlers/gates/bash-path.ts +13 -7
- package/src/handlers/gates/bash-program.ts +727 -0
- package/src/handlers/gates/candidate-check.ts +32 -0
- package/src/handlers/lifecycle.ts +9 -0
- package/src/handlers/permission-gate-handler.ts +21 -8
- package/src/index.ts +30 -11
- package/src/permission-events.ts +3 -2
- package/src/service.ts +17 -4
- package/src/subagent-context.ts +28 -9
- package/test/composition-root.test.ts +398 -0
- package/test/handlers/gates/bash-command.test.ts +167 -0
- package/test/handlers/gates/bash-program.test.ts +107 -0
- package/test/handlers/gates/candidate-check.test.ts +52 -0
- package/test/handlers/lifecycle.test.ts +15 -2
- package/test/handlers/tool-call.test.ts +73 -0
- package/test/helpers/make-fake-pi.ts +95 -0
- package/test/permission-events.test.ts +32 -2
- package/test/permission-system.test.ts +16 -34
- package/test/service.test.ts +25 -6
- package/test/subagent-context.test.ts +40 -0
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { dirname, join, resolve } from "node:path";
|
|
11
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { expect, test } from "vitest";
|
|
12
13
|
import {
|
|
13
14
|
createActiveToolsCacheKey,
|
|
@@ -46,23 +47,16 @@ import {
|
|
|
46
47
|
canResolveAskPermissionRequest,
|
|
47
48
|
shouldAutoApprovePermissionState,
|
|
48
49
|
} from "#src/yolo-mode";
|
|
50
|
+
import { type FakePi, makeFakePi } from "#test/helpers/make-fake-pi";
|
|
49
51
|
import {
|
|
50
52
|
type CreateManagerOptions,
|
|
51
53
|
createManager,
|
|
52
54
|
} from "#test/helpers/manager-harness";
|
|
53
55
|
|
|
54
|
-
type MockHandler = (
|
|
55
|
-
event: Record<string, unknown>,
|
|
56
|
-
ctx: Record<string, unknown>,
|
|
57
|
-
) =>
|
|
58
|
-
| Promise<Record<string, unknown> | undefined>
|
|
59
|
-
| Record<string, unknown>
|
|
60
|
-
| undefined;
|
|
61
|
-
|
|
62
56
|
type ExtensionHarness = {
|
|
63
57
|
baseDir: string;
|
|
64
58
|
cwd: string;
|
|
65
|
-
|
|
59
|
+
pi: FakePi;
|
|
66
60
|
prompts: string[];
|
|
67
61
|
cleanup: () => Promise<void>;
|
|
68
62
|
};
|
|
@@ -112,7 +106,6 @@ function createToolCallHarness(
|
|
|
112
106
|
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-runtime-"));
|
|
113
107
|
const cwd = options.cwd ?? baseDir;
|
|
114
108
|
const prompts: string[] = [];
|
|
115
|
-
const handlers: Record<string, MockHandler> = {};
|
|
116
109
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
117
110
|
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
118
111
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
@@ -124,22 +117,10 @@ function createToolCallHarness(
|
|
|
124
117
|
"utf8",
|
|
125
118
|
);
|
|
126
119
|
|
|
120
|
+
const pi = makeFakePi({ toolNames });
|
|
127
121
|
process.env.PI_CODING_AGENT_DIR = baseDir;
|
|
128
122
|
try {
|
|
129
|
-
piPermissionSystemExtension(
|
|
130
|
-
on: (name: string, handler: MockHandler): void => {
|
|
131
|
-
handlers[name] = handler;
|
|
132
|
-
},
|
|
133
|
-
registerCommand: (): void => {},
|
|
134
|
-
getAllTools: (): Array<{ name: string }> =>
|
|
135
|
-
toolNames.map((name) => ({ name })),
|
|
136
|
-
setActiveTools: (): void => {},
|
|
137
|
-
registerProvider: (): void => {},
|
|
138
|
-
events: {
|
|
139
|
-
emit: (): void => {},
|
|
140
|
-
on: (): (() => void) => () => undefined,
|
|
141
|
-
},
|
|
142
|
-
} as never);
|
|
123
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
143
124
|
} finally {
|
|
144
125
|
if (originalAgentDir === undefined) {
|
|
145
126
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
@@ -151,11 +132,13 @@ function createToolCallHarness(
|
|
|
151
132
|
return {
|
|
152
133
|
baseDir,
|
|
153
134
|
cwd,
|
|
154
|
-
|
|
135
|
+
pi,
|
|
155
136
|
prompts,
|
|
156
137
|
cleanup: async (): Promise<void> => {
|
|
157
|
-
await
|
|
158
|
-
|
|
138
|
+
await pi.fire(
|
|
139
|
+
"session_shutdown",
|
|
140
|
+
{},
|
|
141
|
+
createMockContext(cwd, prompts, options),
|
|
159
142
|
);
|
|
160
143
|
rmSync(baseDir, { recursive: true, force: true });
|
|
161
144
|
},
|
|
@@ -192,15 +175,14 @@ async function runToolCall(
|
|
|
192
175
|
event: Record<string, unknown>,
|
|
193
176
|
options: ExtensionHarnessOptions = {},
|
|
194
177
|
): Promise<Record<string, unknown>> {
|
|
195
|
-
const handler = harness.handlers.tool_call;
|
|
196
|
-
expect(handler).toBeTypeOf("function");
|
|
197
|
-
|
|
198
178
|
const result = await withIsolatedSubagentEnv(async () =>
|
|
199
|
-
|
|
200
|
-
|
|
179
|
+
harness.pi.fire(
|
|
180
|
+
"tool_call",
|
|
181
|
+
event,
|
|
182
|
+
createMockContext(harness.cwd, harness.prompts, options),
|
|
201
183
|
),
|
|
202
184
|
);
|
|
203
|
-
return result ?? {};
|
|
185
|
+
return (result as Record<string, unknown> | undefined) ?? {};
|
|
204
186
|
}
|
|
205
187
|
|
|
206
188
|
test("Yolo mode only auto-approves ask-state permissions", () => {
|
|
@@ -2335,7 +2317,7 @@ test("session approval: session_shutdown clears session approvals", async () =>
|
|
|
2335
2317
|
hasUI: true,
|
|
2336
2318
|
selectResponse: "Yes",
|
|
2337
2319
|
});
|
|
2338
|
-
await
|
|
2320
|
+
await harness.pi.fire("session_shutdown", {}, shutdownCtx);
|
|
2339
2321
|
|
|
2340
2322
|
// Access same path again — should prompt because cache was cleared
|
|
2341
2323
|
const result = await runToolCall(
|
package/test/service.test.ts
CHANGED
|
@@ -26,7 +26,10 @@ function makeService(
|
|
|
26
26
|
|
|
27
27
|
describe("globalThis accessor", () => {
|
|
28
28
|
afterEach(() => {
|
|
29
|
-
|
|
29
|
+
const current = getPermissionsService();
|
|
30
|
+
if (current) {
|
|
31
|
+
unpublishPermissionsService(current);
|
|
32
|
+
}
|
|
30
33
|
});
|
|
31
34
|
|
|
32
35
|
it("returns undefined when nothing has been published", () => {
|
|
@@ -47,15 +50,25 @@ describe("globalThis accessor", () => {
|
|
|
47
50
|
expect(getPermissionsService()).toBe(second);
|
|
48
51
|
});
|
|
49
52
|
|
|
50
|
-
it("
|
|
53
|
+
it("removes the slot when it still holds the given service", () => {
|
|
51
54
|
const service = makeService();
|
|
52
55
|
publishPermissionsService(service);
|
|
53
|
-
unpublishPermissionsService();
|
|
56
|
+
unpublishPermissionsService(service);
|
|
54
57
|
expect(getPermissionsService()).toBeUndefined();
|
|
55
58
|
});
|
|
56
59
|
|
|
60
|
+
it("does not remove the slot when a different service occupies it", () => {
|
|
61
|
+
const parent = makeService();
|
|
62
|
+
const child = makeService();
|
|
63
|
+
publishPermissionsService(parent);
|
|
64
|
+
// A child instance never published `parent`; unpublishing its own service
|
|
65
|
+
// must be a no-op that leaves the parent's slot intact.
|
|
66
|
+
unpublishPermissionsService(child);
|
|
67
|
+
expect(getPermissionsService()).toBe(parent);
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
it("unpublish is safe to call when nothing was published", () => {
|
|
58
|
-
expect(() => unpublishPermissionsService()).not.toThrow();
|
|
71
|
+
expect(() => unpublishPermissionsService(makeService())).not.toThrow();
|
|
59
72
|
expect(getPermissionsService()).toBeUndefined();
|
|
60
73
|
});
|
|
61
74
|
});
|
|
@@ -64,7 +77,10 @@ describe("globalThis accessor", () => {
|
|
|
64
77
|
|
|
65
78
|
describe("service adapter delegation", () => {
|
|
66
79
|
afterEach(() => {
|
|
67
|
-
|
|
80
|
+
const current = getPermissionsService();
|
|
81
|
+
if (current) {
|
|
82
|
+
unpublishPermissionsService(current);
|
|
83
|
+
}
|
|
68
84
|
});
|
|
69
85
|
|
|
70
86
|
const fakeResult: PermissionCheckResult = {
|
|
@@ -191,7 +207,10 @@ describe("service adapter delegation", () => {
|
|
|
191
207
|
|
|
192
208
|
describe("registerToolInputFormatter delegation", () => {
|
|
193
209
|
afterEach(() => {
|
|
194
|
-
|
|
210
|
+
const current = getPermissionsService();
|
|
211
|
+
if (current) {
|
|
212
|
+
unpublishPermissionsService(current);
|
|
213
|
+
}
|
|
195
214
|
});
|
|
196
215
|
|
|
197
216
|
it("delegates to the registry and returns its disposer", () => {
|
|
@@ -2,6 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
3
|
import { SUBAGENT_ENV_HINT_KEYS } from "#src/permission-forwarding";
|
|
4
4
|
import {
|
|
5
|
+
isRegisteredSubagentChild,
|
|
5
6
|
isSubagentExecutionContext,
|
|
6
7
|
normalizeFilesystemPath,
|
|
7
8
|
} from "#src/subagent-context";
|
|
@@ -24,6 +25,45 @@ function makeCtx(
|
|
|
24
25
|
} as unknown as ExtensionContext;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
describe("isRegisteredSubagentChild", () => {
|
|
29
|
+
const childSessionId = "child-session-abc";
|
|
30
|
+
|
|
31
|
+
test("returns true when the session id is registered", () => {
|
|
32
|
+
const registry = new SubagentSessionRegistry();
|
|
33
|
+
registry.register(childSessionId, {});
|
|
34
|
+
expect(
|
|
35
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("returns false when the session id is not registered", () => {
|
|
40
|
+
const registry = new SubagentSessionRegistry();
|
|
41
|
+
expect(
|
|
42
|
+
isRegisteredSubagentChild(makeCtx(null, childSessionId), registry),
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("returns false when the session id is empty", () => {
|
|
47
|
+
const registry = new SubagentSessionRegistry();
|
|
48
|
+
registry.register("", {});
|
|
49
|
+
expect(isRegisteredSubagentChild(makeCtx(null, ""), registry)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("returns false when getSessionId throws", () => {
|
|
53
|
+
const registry = new SubagentSessionRegistry();
|
|
54
|
+
registry.register(childSessionId, {});
|
|
55
|
+
const ctx = {
|
|
56
|
+
sessionManager: {
|
|
57
|
+
getSessionDir: vi.fn(() => null),
|
|
58
|
+
getSessionId: vi.fn(() => {
|
|
59
|
+
throw new Error("session id unavailable");
|
|
60
|
+
}),
|
|
61
|
+
},
|
|
62
|
+
} as unknown as ExtensionContext;
|
|
63
|
+
expect(isRegisteredSubagentChild(ctx, registry)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
27
67
|
describe("normalizeFilesystemPath", () => {
|
|
28
68
|
test("normalizes a simple absolute path", () => {
|
|
29
69
|
expect(normalizeFilesystemPath("/projects/my-app")).toBe(
|