@gotgenes/pi-permission-system 10.0.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -3,15 +3,35 @@
3
3
  */
4
4
  import { vi } from "vitest";
5
5
 
6
- import type {
7
- GateDescriptor,
8
- GateRunnerDeps,
9
- } from "#src/handlers/gates/descriptor";
6
+ import type { DecisionReporter } from "#src/decision-reporter";
7
+ import type { GatePrompter } from "#src/gate-prompter";
8
+ import type { GateDescriptor } from "#src/handlers/gates/descriptor";
9
+ import { GateRunner } from "#src/handlers/gates/runner";
10
+ import type { SkillInputGateInputs } from "#src/handlers/gates/skill-input-gate-pipeline";
11
+ import type { ToolCallGateInputs } from "#src/handlers/gates/tool-call-gate-pipeline";
10
12
  import type { ToolCallContext } from "#src/handlers/gates/types";
13
+ import type { PermissionResolver } from "#src/permission-resolver";
14
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
15
+ import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
16
+ import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
11
17
  import type { PermissionCheckResult } from "#src/types";
12
18
 
13
19
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
14
20
 
21
+ /**
22
+ * Permission resolver mock with an optional default check result.
23
+ *
24
+ * Returns a plain object whose `resolve` is a `vi.fn` so callers retain full
25
+ * mock access (`mockReturnValue`, `mockImplementation`, `mock.calls`).
26
+ */
27
+ export function makeResolver(defaultCheck?: PermissionCheckResult) {
28
+ const resolve = vi.fn<PermissionResolver["resolve"]>();
29
+ if (defaultCheck) {
30
+ resolve.mockReturnValue(defaultCheck);
31
+ }
32
+ return { resolve };
33
+ }
34
+
15
35
  /**
16
36
  * Gate descriptor factory with runner-test defaults.
17
37
  *
@@ -48,25 +68,70 @@ export function makeDescriptor(
48
68
  };
49
69
  }
50
70
 
51
- export function makeRunnerDeps(
52
- overrides: Partial<GateRunnerDeps> = {},
53
- ): GateRunnerDeps {
71
+ /**
72
+ * Reporter mock with independently inspectable vi.fn() stubs.
73
+ */
74
+ export function makeReporter(
75
+ overrides: Partial<DecisionReporter> = {},
76
+ ): DecisionReporter {
54
77
  return {
55
- checkPermission: vi
56
- .fn()
57
- .mockReturnValue(makeCheckResult({ matchedPattern: "*" })),
58
- getSessionRuleset: vi.fn().mockReturnValue([]),
59
- recordSessionApproval: vi.fn(),
60
78
  writeReviewLog: vi.fn(),
61
79
  emitDecision: vi.fn(),
62
- canConfirm: vi.fn().mockReturnValue(true),
63
- promptPermission: vi
64
- .fn()
65
- .mockResolvedValue({ approved: true, state: "approved" }),
66
80
  ...overrides,
67
81
  };
68
82
  }
69
83
 
84
+ /**
85
+ * Gate runner factory for `GateRunner` unit tests.
86
+ *
87
+ * Builds one `GateRunner` from four role mocks and returns `{ runner, deps }`
88
+ * so tests can both invoke `runner.run(...)` and assert on the individual
89
+ * mock call records (`deps.reporter.*`, `deps.resolve`, etc.).
90
+ */
91
+ export function makeGateRunner(
92
+ overrides: {
93
+ resolve?: PermissionResolver["resolve"];
94
+ recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
95
+ canConfirm?: GatePrompter["canConfirm"];
96
+ promptPermission?: GatePrompter["promptPermission"];
97
+ reporter?: Partial<DecisionReporter>;
98
+ } = {},
99
+ ) {
100
+ const reporter = makeReporter(overrides.reporter);
101
+ const resolve =
102
+ overrides.resolve ??
103
+ vi
104
+ .fn<PermissionResolver["resolve"]>()
105
+ .mockReturnValue(makeCheckResult({ matchedPattern: "*" }));
106
+ const recordSessionApproval =
107
+ overrides.recordSessionApproval ??
108
+ (vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
109
+ const canConfirm =
110
+ overrides.canConfirm ??
111
+ (vi.fn().mockReturnValue(true) as GatePrompter["canConfirm"]);
112
+ const promptPermission =
113
+ overrides.promptPermission ??
114
+ vi
115
+ .fn<GatePrompter["promptPermission"]>()
116
+ .mockResolvedValue({ approved: true, state: "approved" });
117
+ const runner = new GateRunner(
118
+ { resolve },
119
+ { recordSessionApproval },
120
+ { canConfirm, promptPermission },
121
+ reporter,
122
+ );
123
+ return {
124
+ runner,
125
+ deps: {
126
+ resolve,
127
+ recordSessionApproval,
128
+ canConfirm,
129
+ promptPermission,
130
+ reporter,
131
+ },
132
+ };
133
+ }
134
+
70
135
  /**
71
136
  * Tool-call context factory with bash defaults.
72
137
  *
@@ -103,3 +168,69 @@ export function makeGateCheckResult(
103
168
  ...overrides,
104
169
  };
105
170
  }
171
+
172
+ /**
173
+ * Mock of `ToolCallGateInputs` for `ToolCallGatePipeline` unit tests.
174
+ *
175
+ * Each method is a `vi.fn()` stub so callers retain full mock access
176
+ * (`mock.calls`, `mockReturnValue`, etc.) on the returned object.
177
+ * Pass `overrides` to replace individual stubs without rebuilding the whole
178
+ * mock from scratch.
179
+ */
180
+ export function makeGateInputs(
181
+ overrides: {
182
+ resolve?: PermissionResolver["resolve"];
183
+ getActiveSkillEntries?: () => SkillPromptEntry[];
184
+ getInfrastructureReadDirs?: () => string[];
185
+ getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
186
+ } = {},
187
+ ): ToolCallGateInputs {
188
+ return {
189
+ resolve:
190
+ overrides.resolve ??
191
+ vi.fn<PermissionResolver["resolve"]>().mockReturnValue(makeCheckResult()),
192
+ getActiveSkillEntries:
193
+ overrides.getActiveSkillEntries ??
194
+ vi.fn<() => SkillPromptEntry[]>(() => []),
195
+ getInfrastructureReadDirs:
196
+ overrides.getInfrastructureReadDirs ?? vi.fn<() => string[]>(() => []),
197
+ getToolPreviewLimits:
198
+ overrides.getToolPreviewLimits ??
199
+ vi.fn<() => ToolPreviewFormatterOptions>(() => ({
200
+ toolInputPreviewMaxLength: 500,
201
+ toolTextSummaryMaxLength: 100,
202
+ toolInputLogPreviewMaxLength: 200,
203
+ })),
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Mock of `SkillInputGateInputs` for `SkillInputGatePipeline` unit tests.
209
+ *
210
+ * Returns a plain object with a `checkPermission` `vi.fn()` stub so callers
211
+ * retain full mock access (`mockReturnValue`, `mock.calls`, etc.).
212
+ */
213
+ export function makeSkillInputInputs(
214
+ overrides: { checkPermission?: SkillInputGateInputs["checkPermission"] } = {},
215
+ ): SkillInputGateInputs {
216
+ return {
217
+ checkPermission:
218
+ overrides.checkPermission ??
219
+ vi
220
+ .fn<SkillInputGateInputs["checkPermission"]>()
221
+ .mockReturnValue(makeCheckResult()),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Mock `GateNotifier` for `SkillInputGatePipeline` unit tests.
227
+ *
228
+ * Return type is intentionally unannotated so callers retain full `vi.fn()`
229
+ * mock access (`mock.calls`, `toHaveBeenCalledWith`, etc.) — annotating with
230
+ * `GateNotifier` would erase `Mock<...>` methods from the inferred type.
231
+ */
232
+ export function makeNotifier() {
233
+ return {
234
+ warn: vi.fn<(message: string) => void>(),
235
+ };
236
+ }
@@ -7,14 +7,67 @@
7
7
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { vi } from "vitest";
9
9
 
10
+ import { GateDecisionReporter } from "#src/decision-reporter";
10
11
  import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
12
+ import type { GateHandlerSession } from "#src/gate-handler-session";
13
+ import type { GatePrompter } from "#src/gate-prompter";
14
+ import { GateRunner } from "#src/handlers/gates/runner";
15
+ import {
16
+ type SkillInputGateInputs,
17
+ SkillInputGatePipeline,
18
+ } from "#src/handlers/gates/skill-input-gate-pipeline";
19
+ import {
20
+ type ToolCallGateInputs,
21
+ ToolCallGatePipeline,
22
+ } from "#src/handlers/gates/tool-call-gate-pipeline";
11
23
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
24
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
12
25
  import type { PermissionDecisionEvent } from "#src/permission-events";
13
26
  import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
14
- import type { PermissionSession } from "#src/permission-session";
27
+ import type { PromptPermissionDetails } from "#src/permission-prompter";
28
+ import type { Rule } from "#src/rule";
29
+ import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
30
+ import type { SessionLogger } from "#src/session-logger";
31
+ import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
15
32
  import type { ToolRegistry } from "#src/tool-registry";
16
33
  import type { PermissionCheckResult } from "#src/types";
17
34
 
35
+ /**
36
+ * Precise mock boundary for PermissionGateHandler integration tests.
37
+ *
38
+ * Intersection of every role the handler and its collaborators require,
39
+ * plus the context-bound prompting helpers that GatePrompter delegates to.
40
+ * Without a cast, TypeScript enforces this at the call sites where the
41
+ * mock is passed to GateRunner / ToolCallGatePipeline / PermissionGateHandler.
42
+ *
43
+ * The 4-arg `checkPermission` overrides the 3-arg version from
44
+ * GateHandlerSession so the `resolve` delegation can forward session rules.
45
+ */
46
+ export type MockGateHandlerSession = ToolCallGateInputs &
47
+ SkillInputGateInputs &
48
+ SessionApprovalRecorder &
49
+ GatePrompter &
50
+ GateHandlerSession & {
51
+ /** Logger source for the reporter the fixture builds. */
52
+ logger: SessionLogger;
53
+ /** Session-rule accessor — used by the resolve delegation. */
54
+ getSessionRuleset(): Rule[];
55
+ /** 4-arg form so the resolve delegation can pass rules. */
56
+ checkPermission(
57
+ surface: string,
58
+ input: unknown,
59
+ agentName?: string,
60
+ rules?: Rule[],
61
+ ): PermissionCheckResult;
62
+ /** Context-bound canPrompt — overriding this steers canConfirm. */
63
+ canPrompt(ctx: ExtensionContext): boolean;
64
+ /** Context-bound prompt — overriding this steers promptPermission. */
65
+ prompt(
66
+ ctx: ExtensionContext,
67
+ details: PromptPermissionDetails,
68
+ ): Promise<PermissionPromptDecision>;
69
+ };
70
+
18
71
  export function makeEvents() {
19
72
  return {
20
73
  emit: vi.fn(),
@@ -75,33 +128,86 @@ export function makeCheckResult(
75
128
  }
76
129
 
77
130
  /**
78
- * Full-union session stub.
131
+ * Full-intersection session stub.
79
132
  *
80
- * Includes every method mocked across handler test files so each file
81
- * only needs to override the fields that differ from the defaults.
133
+ * Uses per-field `??` selection (no spread) so TypeScript verifies every
134
+ * field against `MockGateHandlerSession` individually a missing field fails
135
+ * `pnpm run check` instead of failing silently at runtime.
136
+ *
137
+ * The `resolve`, `canConfirm`, and `promptPermission` delegations are inlined
138
+ * as closures that read `session` at call time, so overriding `checkPermission`,
139
+ * `canPrompt`, or `prompt` automatically steers them without extra guards.
82
140
  */
83
141
  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;
142
+ overrides: Partial<MockGateHandlerSession> = {},
143
+ ): MockGateHandlerSession {
144
+ const session: MockGateHandlerSession = {
145
+ logger: overrides.logger ?? {
146
+ debug: vi.fn(),
147
+ review: vi.fn(),
148
+ warn: vi.fn(),
149
+ },
150
+ activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
151
+ resolveAgentName:
152
+ overrides.resolveAgentName ??
153
+ vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
154
+ checkPermission:
155
+ overrides.checkPermission ??
156
+ vi
157
+ .fn<MockGateHandlerSession["checkPermission"]>()
158
+ .mockReturnValue(makeCheckResult()),
159
+ getSessionRuleset:
160
+ overrides.getSessionRuleset ??
161
+ vi.fn<MockGateHandlerSession["getSessionRuleset"]>().mockReturnValue([]),
162
+ recordSessionApproval:
163
+ overrides.recordSessionApproval ??
164
+ vi.fn<MockGateHandlerSession["recordSessionApproval"]>(),
165
+ getActiveSkillEntries:
166
+ overrides.getActiveSkillEntries ??
167
+ vi
168
+ .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
169
+ .mockReturnValue([]),
170
+ getInfrastructureReadDirs:
171
+ overrides.getInfrastructureReadDirs ??
172
+ vi
173
+ .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
174
+ .mockReturnValue(["/test/agent", "/test/agent/git"]),
175
+ getToolPreviewLimits:
176
+ overrides.getToolPreviewLimits ??
177
+ vi
178
+ .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
179
+ .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
180
+ canPrompt:
181
+ overrides.canPrompt ??
182
+ vi.fn<MockGateHandlerSession["canPrompt"]>().mockReturnValue(true),
183
+ prompt:
184
+ overrides.prompt ??
185
+ vi
186
+ .fn<MockGateHandlerSession["prompt"]>()
187
+ .mockResolvedValue({ approved: true, state: "approved" }),
188
+ // Delegations — closures read `session` at call time so overrides win.
189
+ resolve:
190
+ overrides.resolve ??
191
+ vi.fn<MockGateHandlerSession["resolve"]>((surface, input, agentName) =>
192
+ session.checkPermission(
193
+ surface,
194
+ input,
195
+ agentName,
196
+ session.getSessionRuleset(),
197
+ ),
198
+ ),
199
+ canConfirm:
200
+ overrides.canConfirm ??
201
+ vi.fn<MockGateHandlerSession["canConfirm"]>(() =>
202
+ session.canPrompt(undefined as unknown as ExtensionContext),
203
+ ),
204
+ promptPermission:
205
+ overrides.promptPermission ??
206
+ vi.fn<MockGateHandlerSession["promptPermission"]>((details) =>
207
+ session.prompt(undefined as unknown as ExtensionContext, details),
208
+ ),
209
+ };
210
+ return session;
105
211
  }
106
212
 
107
213
  export function makeToolRegistry(
@@ -121,13 +227,23 @@ export function makeToolRegistry(
121
227
  * it needs — handler, events, session, and toolRegistry are all available.
122
228
  */
123
229
  export function makeHandler(overrides?: {
124
- session?: Partial<Record<keyof PermissionSession, unknown>>;
230
+ session?: Partial<MockGateHandlerSession>;
125
231
  toolRegistry?: Partial<ToolRegistry>;
126
232
  }) {
127
233
  const session = makeSession(overrides?.session);
128
234
  const events = makeEvents();
129
235
  const toolRegistry = makeToolRegistry(overrides?.toolRegistry);
130
- const handler = new PermissionGateHandler(session, events, toolRegistry);
236
+ const pipeline = new ToolCallGatePipeline(session);
237
+ const skillInputPipeline = new SkillInputGatePipeline(session);
238
+ const reporter = new GateDecisionReporter(session.logger, events);
239
+ const runner = new GateRunner(session, session, session, reporter);
240
+ const handler = new PermissionGateHandler(
241
+ session,
242
+ toolRegistry,
243
+ pipeline,
244
+ skillInputPipeline,
245
+ runner,
246
+ );
131
247
  return { handler, events, session, toolRegistry };
132
248
  }
133
249
 
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  createMcpPermissionTargets,
4
+ McpTargetList,
4
5
  parseQualifiedMcpToolName,
5
6
  } from "#src/mcp-targets";
6
7
 
@@ -176,3 +177,57 @@ describe("createMcpPermissionTargets", () => {
176
177
  });
177
178
  });
178
179
  });
180
+
181
+ describe("McpTargetList", () => {
182
+ describe("add", () => {
183
+ it("ignores null", () => {
184
+ const list = new McpTargetList();
185
+ list.add(null);
186
+ expect(list.toArray()).toEqual([]);
187
+ });
188
+
189
+ it("ignores empty string", () => {
190
+ const list = new McpTargetList();
191
+ list.add("");
192
+ expect(list.toArray()).toEqual([]);
193
+ });
194
+
195
+ it("appends a new value", () => {
196
+ const list = new McpTargetList();
197
+ list.add("exa");
198
+ expect(list.toArray()).toEqual(["exa"]);
199
+ });
200
+
201
+ it("dedups repeated values", () => {
202
+ const list = new McpTargetList();
203
+ list.add("exa");
204
+ list.add("exa");
205
+ expect(list.toArray()).toEqual(["exa"]);
206
+ });
207
+
208
+ it("preserves first-insertion order across a mix of values", () => {
209
+ const list = new McpTargetList();
210
+ list.add("exa_search");
211
+ list.add("exa:search");
212
+ list.add("exa");
213
+ list.add("exa_search"); // duplicate — must not change order
214
+ list.add("mcp_call");
215
+ expect(list.toArray()).toEqual([
216
+ "exa_search",
217
+ "exa:search",
218
+ "exa",
219
+ "mcp_call",
220
+ ]);
221
+ });
222
+ });
223
+
224
+ describe("toArray", () => {
225
+ it("returns an independent copy that does not mutate the list", () => {
226
+ const list = new McpTargetList();
227
+ list.add("exa");
228
+ const first = list.toArray();
229
+ first.push("mutated");
230
+ expect(list.toArray()).toEqual(["exa"]);
231
+ });
232
+ });
233
+ });