@gotgenes/pi-permission-system 8.2.0 → 8.2.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.
@@ -1,13 +1,13 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
1
  import { describe, expect, it, vi } from "vitest";
3
- import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
2
+
3
+ import { getEventInput } from "#src/handlers/permission-gate-handler";
4
+
4
5
  import {
5
- getEventInput,
6
- PermissionGateHandler,
7
- } from "#src/handlers/permission-gate-handler";
8
- import type { PermissionSession } from "#src/permission-session";
9
- import type { ToolRegistry } from "#src/tool-registry";
10
- import type { PermissionCheckResult } from "#src/types";
6
+ makeCheckResult,
7
+ makeCtx,
8
+ makeHandler,
9
+ makeToolCallEvent,
10
+ } from "#test/helpers/handler-fixtures";
11
11
 
12
12
  // ── SDK stubs ──────────────────────────────────────────────────────────────
13
13
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -16,101 +16,6 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
16
16
  return { ...original };
17
17
  });
18
18
 
19
- // ── helpers ────────────────────────────────────────────────────────────────
20
-
21
- function makeCtx(
22
- overrides: Partial<ExtensionContext> & { cwd?: string } = {},
23
- ): ExtensionContext {
24
- return {
25
- cwd: "/test/project",
26
- hasUI: true,
27
- ui: {
28
- setStatus: vi.fn(),
29
- notify: vi.fn(),
30
- select: vi.fn(),
31
- input: vi.fn(),
32
- },
33
- sessionManager: {
34
- getEntries: vi.fn().mockReturnValue([]),
35
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
36
- addEntry: vi.fn(),
37
- },
38
- ...overrides,
39
- } as unknown as ExtensionContext;
40
- }
41
-
42
- function makeToolCallEvent(
43
- toolName: string,
44
- extraFields: Record<string, unknown> = {},
45
- ) {
46
- return {
47
- type: "tool_call",
48
- toolCallId: "tc-1",
49
- name: toolName,
50
- input: {},
51
- ...extraFields,
52
- };
53
- }
54
-
55
- function makePermissionResult(
56
- state: "allow" | "deny" | "ask",
57
- ): PermissionCheckResult {
58
- return { state, toolName: "read", source: "tool", origin: "builtin" };
59
- }
60
-
61
- function makeSession(
62
- overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
63
- ): PermissionSession {
64
- return {
65
- logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
66
- activate: vi.fn(),
67
- resolveAgentName: vi.fn().mockReturnValue(null),
68
- checkPermission: vi.fn().mockReturnValue(makePermissionResult("allow")),
69
- getToolPermission: vi.fn().mockReturnValue("allow"),
70
- getSessionRuleset: vi.fn().mockReturnValue([]),
71
- recordSessionApproval: vi.fn(),
72
- getActiveSkillEntries: vi.fn().mockReturnValue([]),
73
- getInfrastructureDirs: vi
74
- .fn()
75
- .mockReturnValue(["/test/agent", "/test/agent/git"]),
76
- getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
77
- config: DEFAULT_EXTENSION_CONFIG,
78
- canPrompt: vi.fn().mockReturnValue(true),
79
- prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
80
- ...overrides,
81
- } as unknown as PermissionSession;
82
- }
83
-
84
- function makeEvents() {
85
- return {
86
- emit: vi.fn(),
87
- on: vi.fn().mockReturnValue(() => undefined),
88
- };
89
- }
90
-
91
- function makeToolRegistry(overrides: Partial<ToolRegistry> = {}): ToolRegistry {
92
- return {
93
- getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
94
- setActive: vi.fn(),
95
- ...overrides,
96
- };
97
- }
98
-
99
- function makeHandler(overrides?: {
100
- session?: Partial<Record<keyof PermissionSession, unknown>>;
101
- toolRegistry?: Partial<ToolRegistry>;
102
- }): {
103
- handler: PermissionGateHandler;
104
- session: PermissionSession;
105
- toolRegistry: ToolRegistry;
106
- } {
107
- const session = makeSession(overrides?.session);
108
- const events = makeEvents();
109
- const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
110
- const handler = new PermissionGateHandler(session, events, toolRegistry);
111
- return { handler, session, toolRegistry };
112
- }
113
-
114
19
  // ── getEventInput ──────────────────────────────────────────────────────────
115
20
 
116
21
  describe("getEventInput", () => {
@@ -184,7 +89,9 @@ describe("handleToolCall", () => {
184
89
  it("blocks when tool is denied by policy", async () => {
185
90
  const { handler } = makeHandler({
186
91
  session: {
187
- checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
92
+ checkPermission: vi
93
+ .fn()
94
+ .mockReturnValue(makeCheckResult({ state: "deny" })),
188
95
  },
189
96
  });
190
97
  const result = await handler.handleToolCall(
@@ -259,7 +166,9 @@ describe("handleToolCall — external-directory gate", () => {
259
166
  it("blocks a read of a path outside cwd when policy is deny", async () => {
260
167
  const { handler } = makeHandler({
261
168
  session: {
262
- checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
169
+ checkPermission: vi
170
+ .fn()
171
+ .mockReturnValue(makeCheckResult({ state: "deny" })),
263
172
  },
264
173
  toolRegistry: {
265
174
  getAll: vi.fn().mockReturnValue([{ name: "read" }]),
@@ -282,7 +191,9 @@ describe("handleToolCall — bash external-directory gate", () => {
282
191
  it("blocks a bash command referencing an external path when policy is deny", async () => {
283
192
  const { handler } = makeHandler({
284
193
  session: {
285
- checkPermission: vi.fn().mockReturnValue(makePermissionResult("deny")),
194
+ checkPermission: vi
195
+ .fn()
196
+ .mockReturnValue(makeCheckResult({ state: "deny" })),
286
197
  },
287
198
  toolRegistry: {
288
199
  getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
@@ -308,9 +219,9 @@ describe("handleToolCall — path gate (tools)", () => {
308
219
  .mockImplementation(
309
220
  (surface: string, _input: unknown, _agentName?: string) => {
310
221
  if (surface === "path") {
311
- return { ...makePermissionResult("deny"), matchedPattern: "*.env" };
222
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
312
223
  }
313
- return makePermissionResult("allow");
224
+ return makeCheckResult();
314
225
  },
315
226
  );
316
227
  const { handler } = makeHandler({
@@ -355,9 +266,9 @@ describe("handleToolCall — bash path gate", () => {
355
266
  .mockImplementation(
356
267
  (surface: string, _input: unknown, _agentName?: string) => {
357
268
  if (surface === "path") {
358
- return { ...makePermissionResult("deny"), matchedPattern: "*.env" };
269
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
359
270
  }
360
- return makePermissionResult("allow");
271
+ return makeCheckResult();
361
272
  },
362
273
  );
363
274
  const { handler } = makeHandler({
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Shared gate-level test fixtures for gate descriptor and runner tests.
3
+ */
4
+ import { vi } from "vitest";
5
+
6
+ import type {
7
+ GateDescriptor,
8
+ GateRunnerDeps,
9
+ } from "#src/handlers/gates/descriptor";
10
+ import type { ToolCallContext } from "#src/handlers/gates/types";
11
+ import type { PermissionCheckResult } from "#src/types";
12
+
13
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
14
+
15
+ /**
16
+ * Gate descriptor factory with runner-test defaults.
17
+ *
18
+ * Uses deny as the default `denialContext` check result so tests that
19
+ * verify block paths don't need to override the surface check.
20
+ */
21
+ export function makeDescriptor(
22
+ overrides: Partial<GateDescriptor> = {},
23
+ ): GateDescriptor {
24
+ return {
25
+ surface: "read",
26
+ input: {},
27
+ denialContext: {
28
+ kind: "tool",
29
+ check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
30
+ },
31
+ promptDetails: {
32
+ source: "tool_call",
33
+ agentName: null,
34
+ message: "Allow tool 'read'?",
35
+ toolCallId: "tc-1",
36
+ toolName: "read",
37
+ },
38
+ logContext: {
39
+ source: "tool_call",
40
+ toolCallId: "tc-1",
41
+ toolName: "read",
42
+ },
43
+ decision: {
44
+ surface: "read",
45
+ value: "read",
46
+ },
47
+ ...overrides,
48
+ };
49
+ }
50
+
51
+ export function makeRunnerDeps(
52
+ overrides: Partial<GateRunnerDeps> = {},
53
+ ): GateRunnerDeps {
54
+ return {
55
+ checkPermission: vi
56
+ .fn()
57
+ .mockReturnValue(makeCheckResult({ matchedPattern: "*" })),
58
+ getSessionRuleset: vi.fn().mockReturnValue([]),
59
+ recordSessionApproval: vi.fn(),
60
+ writeReviewLog: vi.fn(),
61
+ emitDecision: vi.fn(),
62
+ canConfirm: vi.fn().mockReturnValue(true),
63
+ promptPermission: vi
64
+ .fn()
65
+ .mockResolvedValue({ approved: true, state: "approved" }),
66
+ ...overrides,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Tool-call context factory with bash defaults.
72
+ *
73
+ * path.test.ts uses different defaults (toolName "read", path input) and
74
+ * keeps a local wrapper; bash-path.test.ts uses this factory directly.
75
+ */
76
+ export function makeTcc(
77
+ overrides: Partial<ToolCallContext> = {},
78
+ ): ToolCallContext {
79
+ return {
80
+ toolName: "bash",
81
+ agentName: null,
82
+ input: { command: "cat .env" },
83
+ toolCallId: "tc-1",
84
+ cwd: "/test/project",
85
+ ...overrides,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Path-surface check result factory.
91
+ *
92
+ * Shared between bash-path.test.ts and path.test.ts; both use
93
+ * toolName "path", source "special", origin "global" as defaults.
94
+ */
95
+ export function makeGateCheckResult(
96
+ overrides: Partial<PermissionCheckResult> = {},
97
+ ): PermissionCheckResult {
98
+ return {
99
+ toolName: "path",
100
+ state: "allow",
101
+ source: "special",
102
+ origin: "global",
103
+ ...overrides,
104
+ };
105
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Shared handler-level test fixtures for PermissionGateHandler tests.
3
+ *
4
+ * All factories use override bags so callers can specialize any field
5
+ * without constructing the full object from scratch.
6
+ */
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { vi } from "vitest";
9
+
10
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
11
+ import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
12
+ import type { PermissionDecisionEvent } from "#src/permission-events";
13
+ import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
14
+ import type { PermissionSession } from "#src/permission-session";
15
+ import type { ToolRegistry } from "#src/tool-registry";
16
+ import type { PermissionCheckResult } from "#src/types";
17
+
18
+ export function makeEvents() {
19
+ return {
20
+ emit: vi.fn(),
21
+ on: vi.fn().mockReturnValue(() => undefined),
22
+ };
23
+ }
24
+
25
+ export function makeCtx(
26
+ overrides: Partial<ExtensionContext> = {},
27
+ ): ExtensionContext {
28
+ return {
29
+ cwd: "/test/project",
30
+ hasUI: true,
31
+ ui: {
32
+ setStatus: vi.fn(),
33
+ notify: vi.fn(),
34
+ select: vi.fn(),
35
+ input: vi.fn(),
36
+ },
37
+ sessionManager: {
38
+ getEntries: vi.fn().mockReturnValue([]),
39
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
40
+ addEntry: vi.fn(),
41
+ },
42
+ ...overrides,
43
+ } as unknown as ExtensionContext;
44
+ }
45
+
46
+ export function makeToolCallEvent(
47
+ toolName: string,
48
+ extraFields: Record<string, unknown> = {},
49
+ ) {
50
+ return {
51
+ type: "tool_call",
52
+ toolCallId: "tc-1",
53
+ name: toolName,
54
+ input: {},
55
+ ...extraFields,
56
+ };
57
+ }
58
+
59
+ /**
60
+ * Neutral-default check-result builder.
61
+ *
62
+ * Pass exactly the fields the original fixture hard-coded so divergent
63
+ * defaults across test files are preserved at their call sites.
64
+ */
65
+ export function makeCheckResult(
66
+ overrides: Partial<PermissionCheckResult> = {},
67
+ ): PermissionCheckResult {
68
+ return {
69
+ state: "allow",
70
+ toolName: "read",
71
+ source: "tool",
72
+ origin: "builtin",
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Full-union session stub.
79
+ *
80
+ * Includes every method mocked across handler test files so each file
81
+ * only needs to override the fields that differ from the defaults.
82
+ */
83
+ export function makeSession(
84
+ overrides: Partial<Record<keyof PermissionSession, unknown>> = {},
85
+ ): PermissionSession {
86
+ return {
87
+ logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
88
+ activate: vi.fn(),
89
+ resolveAgentName: vi.fn().mockReturnValue(null),
90
+ checkPermission: vi.fn().mockReturnValue(makeCheckResult()),
91
+ getToolPermission: vi.fn().mockReturnValue("allow"),
92
+ getSessionRuleset: vi.fn().mockReturnValue([]),
93
+ recordSessionApproval: vi.fn(),
94
+ getActiveSkillEntries: vi.fn().mockReturnValue([]),
95
+ getInfrastructureDirs: vi
96
+ .fn()
97
+ .mockReturnValue(["/test/agent", "/test/agent/git"]),
98
+ getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
99
+ config: DEFAULT_EXTENSION_CONFIG,
100
+ canPrompt: vi.fn().mockReturnValue(true),
101
+ prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
102
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
103
+ ...overrides,
104
+ } as unknown as PermissionSession;
105
+ }
106
+
107
+ export function makeToolRegistry(
108
+ overrides: Partial<ToolRegistry> = {},
109
+ ): ToolRegistry {
110
+ return {
111
+ getAll: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
112
+ setActive: vi.fn(),
113
+ ...overrides,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Constructs a PermissionGateHandler with mocked collaborators.
119
+ *
120
+ * Returns all collaborators so each test file can destructure only what
121
+ * it needs — handler, events, session, and toolRegistry are all available.
122
+ */
123
+ export function makeHandler(overrides?: {
124
+ session?: Partial<Record<keyof PermissionSession, unknown>>;
125
+ toolRegistry?: Partial<ToolRegistry>;
126
+ }) {
127
+ const session = makeSession(overrides?.session);
128
+ const events = makeEvents();
129
+ const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
130
+ const handler = new PermissionGateHandler(session, events, toolRegistry);
131
+ return { handler, events, session, toolRegistry };
132
+ }
133
+
134
+ /** Extract all permissions:decision payloads from the events.emit mock. */
135
+ export function getDecisionEvents(
136
+ events: ReturnType<typeof makeEvents>,
137
+ ): PermissionDecisionEvent[] {
138
+ return events.emit.mock.calls
139
+ .filter(([channel]) => channel === PERMISSIONS_DECISION_CHANNEL)
140
+ .map(([, payload]) => payload as PermissionDecisionEvent);
141
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Filesystem-backed PermissionManager harness for integration tests.
3
+ *
4
+ * Writes a real config file and agents directory to a temp directory so
5
+ * PermissionManager can load them without mocking the file system.
6
+ */
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ import { PermissionManager } from "#src/permission-manager";
12
+ import type { ScopeConfig } from "#src/types";
13
+
14
+ export type CreateManagerOptions = {
15
+ mcpServerNames?: readonly string[];
16
+ };
17
+
18
+ export function createManager(
19
+ config: ScopeConfig,
20
+ agentFiles: Record<string, string> = {},
21
+ options: CreateManagerOptions = {},
22
+ ) {
23
+ const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
24
+ const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
25
+ const agentsDir = join(baseDir, "agents");
26
+
27
+ mkdirSync(agentsDir, { recursive: true });
28
+ writeFileSync(
29
+ globalConfigPath,
30
+ `${JSON.stringify(config, null, 2)}\n`,
31
+ "utf8",
32
+ );
33
+
34
+ for (const [name, content] of Object.entries(agentFiles)) {
35
+ writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
36
+ }
37
+
38
+ const manager = new PermissionManager({
39
+ globalConfigPath,
40
+ agentsDir,
41
+ mcpServerNames: options.mcpServerNames,
42
+ });
43
+
44
+ return {
45
+ manager,
46
+ globalConfigPath,
47
+ cleanup: (): void => {
48
+ rmSync(baseDir, { recursive: true, force: true });
49
+ },
50
+ };
51
+ }
@@ -39,6 +39,7 @@ import {
39
39
  import { SessionApproval } from "#src/session-approval";
40
40
  import type { SessionLogger } from "#src/session-logger";
41
41
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
42
+ import { makeCtx } from "#test/helpers/handler-fixtures";
42
43
 
43
44
  function makeSkillEntry(
44
45
  name: string,
@@ -94,25 +95,6 @@ function makeForwarding(): ForwardingController {
94
95
  };
95
96
  }
96
97
 
97
- function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
98
- return {
99
- cwd: "/test/project",
100
- hasUI: true,
101
- ui: {
102
- setStatus: vi.fn(),
103
- notify: vi.fn(),
104
- select: vi.fn(),
105
- input: vi.fn(),
106
- },
107
- sessionManager: {
108
- getEntries: vi.fn().mockReturnValue([]),
109
- getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
110
- addEntry: vi.fn(),
111
- },
112
- ...overrides,
113
- } as unknown as ExtensionContext;
114
- }
115
-
116
98
  function makePermissionManager(
117
99
  overrides: Partial<PermissionManager> = {},
118
100
  ): PermissionManager {
@@ -9,7 +9,6 @@ import {
9
9
  import { homedir, tmpdir } from "node:os";
10
10
  import { dirname, join, resolve } from "node:path";
11
11
  import { expect, test } from "vitest";
12
-
13
12
  import {
14
13
  createActiveToolsCacheKey,
15
14
  createBeforeAgentStartPromptStateKey,
@@ -47,45 +46,10 @@ import {
47
46
  canResolveAskPermissionRequest,
48
47
  shouldAutoApprovePermissionState,
49
48
  } from "#src/yolo-mode";
50
-
51
- type CreateManagerOptions = {
52
- mcpServerNames?: readonly string[];
53
- };
54
-
55
- function createManager(
56
- config: ScopeConfig,
57
- agentFiles: Record<string, string> = {},
58
- options: CreateManagerOptions = {},
59
- ) {
60
- const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-test-"));
61
- const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
62
- const agentsDir = join(baseDir, "agents");
63
-
64
- mkdirSync(agentsDir, { recursive: true });
65
- writeFileSync(
66
- globalConfigPath,
67
- `${JSON.stringify(config, null, 2)}\n`,
68
- "utf8",
69
- );
70
-
71
- for (const [name, content] of Object.entries(agentFiles)) {
72
- writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
73
- }
74
-
75
- const manager = new PermissionManager({
76
- globalConfigPath,
77
- agentsDir,
78
- mcpServerNames: options.mcpServerNames,
79
- });
80
-
81
- return {
82
- manager,
83
- globalConfigPath,
84
- cleanup: (): void => {
85
- rmSync(baseDir, { recursive: true, force: true });
86
- },
87
- };
88
- }
49
+ import {
50
+ type CreateManagerOptions,
51
+ createManager,
52
+ } from "#test/helpers/manager-harness";
89
53
 
90
54
  type MockHandler = (
91
55
  event: Record<string, unknown>,