@gotgenes/pi-permission-system 8.0.0 → 8.2.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 (43) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/config/config.example.json +3 -0
  3. package/package.json +1 -1
  4. package/schemas/permissions.schema.json +12 -0
  5. package/src/extension-config.ts +23 -0
  6. package/src/handlers/gates/bash-external-directory.ts +2 -4
  7. package/src/handlers/gates/bash-path.ts +2 -4
  8. package/src/handlers/gates/descriptor.ts +6 -6
  9. package/src/handlers/gates/external-directory.ts +2 -4
  10. package/src/handlers/gates/helpers.ts +30 -1
  11. package/src/handlers/gates/path.ts +2 -4
  12. package/src/handlers/gates/runner.ts +29 -56
  13. package/src/handlers/gates/tool.ts +9 -6
  14. package/src/handlers/permission-gate-handler.ts +110 -141
  15. package/src/permission-manager.ts +6 -49
  16. package/src/permission-prompts.ts +5 -2
  17. package/src/permission-session.ts +3 -2
  18. package/src/scope-merge.ts +72 -0
  19. package/src/session-approval.ts +43 -0
  20. package/src/session-rules.ts +13 -0
  21. package/src/tool-input-preview.ts +0 -116
  22. package/src/tool-preview-formatter.ts +188 -0
  23. package/test/extension-config.test.ts +93 -0
  24. package/test/handlers/external-directory-integration.test.ts +3 -1
  25. package/test/handlers/external-directory-session-dedup.test.ts +17 -12
  26. package/test/handlers/gates/bash-external-directory.test.ts +11 -9
  27. package/test/handlers/gates/external-directory.test.ts +2 -5
  28. package/test/handlers/gates/helpers.test.ts +81 -0
  29. package/test/handlers/gates/path.test.ts +2 -2
  30. package/test/handlers/gates/runner.test.ts +18 -23
  31. package/test/handlers/gates/tool.test.ts +31 -4
  32. package/test/handlers/input-events.test.ts +1 -1
  33. package/test/handlers/input.test.ts +1 -1
  34. package/test/handlers/tool-call-events.test.ts +3 -2
  35. package/test/handlers/tool-call.test.ts +3 -2
  36. package/test/handlers/validate-requested-tool.test.ts +92 -0
  37. package/test/permission-prompts.test.ts +66 -38
  38. package/test/permission-session.test.ts +6 -3
  39. package/test/scope-merge.test.ts +116 -0
  40. package/test/session-approval.test.ts +75 -0
  41. package/test/session-rules.test.ts +49 -0
  42. package/test/tool-input-preview.test.ts +0 -244
  43. package/test/tool-preview-formatter.test.ts +385 -0
@@ -1,6 +1,5 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
2
  import { safeJsonStringify } from "./logging";
3
- import type { PermissionCheckResult } from "./types";
4
3
 
5
4
  export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
6
5
  export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
@@ -10,14 +9,6 @@ export function truncateInlineText(value: string, maxLength: number): string {
10
9
  return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
11
10
  }
12
11
 
13
- export function sanitizeInlineText(
14
- value: string,
15
- maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH,
16
- ): string {
17
- const normalized = value.replace(/\s+/g, " ").trim();
18
- return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
19
- }
20
-
21
12
  export function countTextLines(value: string): number {
22
13
  if (!value) {
23
14
  return 0;
@@ -95,30 +86,6 @@ export function formatReadInputForPrompt(
95
86
  return parts.length > 0 ? `for ${parts.join(", ")}` : "";
96
87
  }
97
88
 
98
- export function formatSearchInputForPrompt(
99
- toolName: string,
100
- input: Record<string, unknown>,
101
- ): string {
102
- const parts: string[] = [];
103
- const path = getPromptPath(input);
104
- const pattern = getNonEmptyString(input.pattern);
105
- const glob = getNonEmptyString(input.glob);
106
-
107
- if (pattern) {
108
- parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
109
- }
110
- if (glob) {
111
- parts.push(`glob '${sanitizeInlineText(glob)}'`);
112
- }
113
- if (path) {
114
- parts.push(`path '${path}'`);
115
- } else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
116
- parts.push("current working directory");
117
- }
118
-
119
- return parts.length > 0 ? `for ${parts.join(", ")}` : "";
120
- }
121
-
122
89
  export function serializeToolInputPreview(input: unknown): string {
123
90
  const serialized = safeJsonStringify(input);
124
91
  if (!serialized || serialized === "{}" || serialized === "null") {
@@ -127,86 +94,3 @@ export function serializeToolInputPreview(input: unknown): string {
127
94
 
128
95
  return serialized.replace(/\s+/g, " ").trim();
129
96
  }
130
-
131
- export function formatJsonInputForPrompt(input: unknown): string {
132
- const inline = serializeToolInputPreview(input);
133
- return inline
134
- ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}`
135
- : "";
136
- }
137
-
138
- export function formatToolInputForPrompt(
139
- toolName: string,
140
- input: unknown,
141
- ): string {
142
- const inputRecord = toRecord(input);
143
-
144
- switch (toolName) {
145
- case "edit":
146
- return formatEditInputForPrompt(inputRecord);
147
- case "write":
148
- return formatWriteInputForPrompt(inputRecord);
149
- case "read":
150
- return formatReadInputForPrompt(inputRecord);
151
- case "find":
152
- case "grep":
153
- case "ls":
154
- return formatSearchInputForPrompt(toolName, inputRecord);
155
- default:
156
- return formatJsonInputForPrompt(input);
157
- }
158
- }
159
-
160
- export function formatGenericToolInputForLog(
161
- input: unknown,
162
- ): string | undefined {
163
- const inline = serializeToolInputPreview(input);
164
- return inline
165
- ? `input ${truncateInlineText(inline, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)}`
166
- : undefined;
167
- }
168
-
169
- export function getToolInputPreviewForLog(
170
- result: PermissionCheckResult,
171
- input: unknown,
172
- pathBearingTools: ReadonlySet<string>,
173
- ): string | undefined {
174
- if (
175
- result.toolName === "bash" ||
176
- result.toolName === "mcp" ||
177
- result.source === "mcp"
178
- ) {
179
- return undefined;
180
- }
181
-
182
- if (pathBearingTools.has(result.toolName)) {
183
- const inputPreview = formatToolInputForPrompt(result.toolName, input);
184
- return inputPreview
185
- ? truncateInlineText(inputPreview, TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH)
186
- : undefined;
187
- }
188
-
189
- return formatGenericToolInputForLog(input);
190
- }
191
-
192
- export function getPermissionLogContext(
193
- result: PermissionCheckResult,
194
- input: unknown,
195
- pathBearingTools: ReadonlySet<string>,
196
- ): {
197
- command?: string;
198
- target?: string;
199
- toolInputPreview?: string;
200
- origin?: string;
201
- } {
202
- return {
203
- command: result.command,
204
- target: result.target,
205
- toolInputPreview: getToolInputPreviewForLog(
206
- result,
207
- input,
208
- pathBearingTools,
209
- ),
210
- origin: result.origin,
211
- };
212
- }
@@ -0,0 +1,188 @@
1
+ import { getNonEmptyString, toRecord } from "./common";
2
+ import type { PermissionSystemExtensionConfig } from "./extension-config";
3
+ import {
4
+ formatEditInputForPrompt,
5
+ formatReadInputForPrompt,
6
+ formatWriteInputForPrompt,
7
+ getPromptPath,
8
+ serializeToolInputPreview,
9
+ TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
10
+ TOOL_INPUT_PREVIEW_MAX_LENGTH,
11
+ TOOL_TEXT_SUMMARY_MAX_LENGTH,
12
+ truncateInlineText,
13
+ } from "./tool-input-preview";
14
+ import type { PermissionCheckResult } from "./types";
15
+
16
+ export interface ToolPreviewFormatterOptions {
17
+ toolInputPreviewMaxLength: number;
18
+ toolTextSummaryMaxLength: number;
19
+ toolInputLogPreviewMaxLength: number;
20
+ }
21
+
22
+ type ConfigurablePreviewLimits = Pick<
23
+ PermissionSystemExtensionConfig,
24
+ "toolInputPreviewMaxLength" | "toolTextSummaryMaxLength"
25
+ >;
26
+
27
+ /**
28
+ * Resolve `ToolPreviewFormatterOptions` from a config object, falling back to
29
+ * the built-in defaults for any field that is absent.
30
+ */
31
+ export function resolveToolPreviewLimits(
32
+ config: ConfigurablePreviewLimits,
33
+ ): ToolPreviewFormatterOptions {
34
+ return {
35
+ toolInputPreviewMaxLength:
36
+ config.toolInputPreviewMaxLength ?? TOOL_INPUT_PREVIEW_MAX_LENGTH,
37
+ toolTextSummaryMaxLength:
38
+ config.toolTextSummaryMaxLength ?? TOOL_TEXT_SUMMARY_MAX_LENGTH,
39
+ toolInputLogPreviewMaxLength: TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Formats tool inputs for permission prompts and review logs.
45
+ *
46
+ * Accepts configurable limits in its constructor — the single injection
47
+ * point for preview-length configuration (#266).
48
+ */
49
+ export class ToolPreviewFormatter {
50
+ constructor(private readonly options: ToolPreviewFormatterOptions) {}
51
+
52
+ // ── Prompt formatting ───────────────────────────────────────────────────
53
+
54
+ /**
55
+ * Collapse whitespace, trim, and truncate a string to fit inline.
56
+ * An explicit `maxLength` overrides the constructor default.
57
+ */
58
+ sanitizeInlineText(value: string, maxLength?: number): string {
59
+ const limit = maxLength ?? this.options.toolTextSummaryMaxLength;
60
+ const normalized = value.replace(/\s+/g, " ").trim();
61
+ return normalized ? truncateInlineText(normalized, limit) : "empty text";
62
+ }
63
+
64
+ /** Serialize `input` to inline JSON and truncate at `toolInputPreviewMaxLength`. */
65
+ formatJsonInputForPrompt(input: unknown): string {
66
+ const inline = serializeToolInputPreview(input);
67
+ return inline
68
+ ? `with input ${truncateInlineText(inline, this.options.toolInputPreviewMaxLength)}`
69
+ : "";
70
+ }
71
+
72
+ /** Format search-tool (grep/find/ls) input for a permission prompt. */
73
+ formatSearchInputForPrompt(
74
+ toolName: string,
75
+ input: Record<string, unknown>,
76
+ ): string {
77
+ const parts: string[] = [];
78
+ const path = getPromptPath(input);
79
+ const pattern = getNonEmptyString(input.pattern);
80
+ const glob = getNonEmptyString(input.glob);
81
+
82
+ if (pattern) {
83
+ parts.push(`pattern '${this.sanitizeInlineText(pattern)}'`);
84
+ }
85
+ if (glob) {
86
+ parts.push(`glob '${this.sanitizeInlineText(glob)}'`);
87
+ }
88
+ if (path) {
89
+ parts.push(`path '${path}'`);
90
+ } else if (
91
+ toolName === "find" ||
92
+ toolName === "grep" ||
93
+ toolName === "ls"
94
+ ) {
95
+ parts.push("current working directory");
96
+ }
97
+
98
+ return parts.length > 0 ? `for ${parts.join(", ")}` : "";
99
+ }
100
+
101
+ /**
102
+ * Format any tool input for display in a permission ask-prompt.
103
+ *
104
+ * Dispatches to the appropriate pure formatter for known tools
105
+ * and falls back to inline JSON for everything else.
106
+ */
107
+ formatToolInputForPrompt(toolName: string, input: unknown): string {
108
+ const inputRecord = toRecord(input);
109
+
110
+ switch (toolName) {
111
+ case "edit":
112
+ return formatEditInputForPrompt(inputRecord);
113
+ case "write":
114
+ return formatWriteInputForPrompt(inputRecord);
115
+ case "read":
116
+ return formatReadInputForPrompt(inputRecord);
117
+ case "find":
118
+ case "grep":
119
+ case "ls":
120
+ return this.formatSearchInputForPrompt(toolName, inputRecord);
121
+ default:
122
+ return this.formatJsonInputForPrompt(input);
123
+ }
124
+ }
125
+
126
+ // ── Log formatting ──────────────────────────────────────────────────────
127
+
128
+ /** Serialize `input` to inline JSON and truncate at `toolInputLogPreviewMaxLength`. */
129
+ formatGenericToolInputForLog(input: unknown): string | undefined {
130
+ const inline = serializeToolInputPreview(input);
131
+ return inline
132
+ ? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
133
+ : undefined;
134
+ }
135
+
136
+ /** Derive a loggable input preview string for the review log. */
137
+ getToolInputPreviewForLog(
138
+ result: PermissionCheckResult,
139
+ input: unknown,
140
+ pathBearingTools: ReadonlySet<string>,
141
+ ): string | undefined {
142
+ if (
143
+ result.toolName === "bash" ||
144
+ result.toolName === "mcp" ||
145
+ result.source === "mcp"
146
+ ) {
147
+ return undefined;
148
+ }
149
+
150
+ if (pathBearingTools.has(result.toolName)) {
151
+ const inputPreview = this.formatToolInputForPrompt(
152
+ result.toolName,
153
+ input,
154
+ );
155
+ return inputPreview
156
+ ? truncateInlineText(
157
+ inputPreview,
158
+ this.options.toolInputLogPreviewMaxLength,
159
+ )
160
+ : undefined;
161
+ }
162
+
163
+ return this.formatGenericToolInputForLog(input);
164
+ }
165
+
166
+ /** Build the structured log context object for a permission review log entry. */
167
+ getPermissionLogContext(
168
+ result: PermissionCheckResult,
169
+ input: unknown,
170
+ pathBearingTools: ReadonlySet<string>,
171
+ ): {
172
+ command?: string;
173
+ target?: string;
174
+ toolInputPreview?: string;
175
+ origin?: string;
176
+ } {
177
+ return {
178
+ command: result.command,
179
+ target: result.target,
180
+ toolInputPreview: this.getToolInputPreviewForLog(
181
+ result,
182
+ input,
183
+ pathBearingTools,
184
+ ),
185
+ origin: result.origin,
186
+ };
187
+ }
188
+ }
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
4
  detectMisplacedPermissionKeys,
5
+ normalizeOptionalPositiveInt,
5
6
  normalizePermissionSystemConfig,
6
7
  } from "#src/extension-config";
7
8
 
@@ -74,6 +75,36 @@ describe("detectMisplacedPermissionKeys", () => {
74
75
  });
75
76
  });
76
77
 
78
+ describe("normalizeOptionalPositiveInt", () => {
79
+ it("returns the value for a valid positive integer", () => {
80
+ expect(normalizeOptionalPositiveInt(1)).toBe(1);
81
+ expect(normalizeOptionalPositiveInt(200)).toBe(200);
82
+ expect(normalizeOptionalPositiveInt(9999)).toBe(9999);
83
+ });
84
+
85
+ it("returns undefined for zero", () => {
86
+ expect(normalizeOptionalPositiveInt(0)).toBeUndefined();
87
+ });
88
+
89
+ it("returns undefined for negative integers", () => {
90
+ expect(normalizeOptionalPositiveInt(-1)).toBeUndefined();
91
+ expect(normalizeOptionalPositiveInt(-100)).toBeUndefined();
92
+ });
93
+
94
+ it("returns undefined for non-integer numbers (floats)", () => {
95
+ expect(normalizeOptionalPositiveInt(400.5)).toBeUndefined();
96
+ expect(normalizeOptionalPositiveInt(1.1)).toBeUndefined();
97
+ });
98
+
99
+ it("returns undefined for non-number types", () => {
100
+ expect(normalizeOptionalPositiveInt("200")).toBeUndefined();
101
+ expect(normalizeOptionalPositiveInt(true)).toBeUndefined();
102
+ expect(normalizeOptionalPositiveInt(null)).toBeUndefined();
103
+ expect(normalizeOptionalPositiveInt(undefined)).toBeUndefined();
104
+ expect(normalizeOptionalPositiveInt({})).toBeUndefined();
105
+ });
106
+ });
107
+
77
108
  describe("normalizePermissionSystemConfig", () => {
78
109
  it("normalizes a valid config object", () => {
79
110
  const result = normalizePermissionSystemConfig({
@@ -122,4 +153,66 @@ describe("normalizePermissionSystemConfig", () => {
122
153
  yoloMode: false,
123
154
  });
124
155
  });
156
+
157
+ it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
158
+ const result = normalizePermissionSystemConfig({
159
+ toolInputPreviewMaxLength: 400,
160
+ });
161
+ expect(result.toolInputPreviewMaxLength).toBe(400);
162
+ });
163
+
164
+ it("omits toolInputPreviewMaxLength when absent", () => {
165
+ const result = normalizePermissionSystemConfig({});
166
+ expect("toolInputPreviewMaxLength" in result).toBe(false);
167
+ });
168
+
169
+ it("omits toolInputPreviewMaxLength for invalid values", () => {
170
+ expect(
171
+ normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
172
+ .toolInputPreviewMaxLength,
173
+ ).toBeUndefined();
174
+ expect(
175
+ normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
176
+ .toolInputPreviewMaxLength,
177
+ ).toBeUndefined();
178
+ expect(
179
+ normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
180
+ .toolInputPreviewMaxLength,
181
+ ).toBeUndefined();
182
+ expect(
183
+ normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
184
+ .toolInputPreviewMaxLength,
185
+ ).toBeUndefined();
186
+ });
187
+
188
+ it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
189
+ const result = normalizePermissionSystemConfig({
190
+ toolTextSummaryMaxLength: 120,
191
+ });
192
+ expect(result.toolTextSummaryMaxLength).toBe(120);
193
+ });
194
+
195
+ it("omits toolTextSummaryMaxLength when absent", () => {
196
+ const result = normalizePermissionSystemConfig({});
197
+ expect("toolTextSummaryMaxLength" in result).toBe(false);
198
+ });
199
+
200
+ it("omits toolTextSummaryMaxLength for invalid values", () => {
201
+ expect(
202
+ normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
203
+ .toolTextSummaryMaxLength,
204
+ ).toBeUndefined();
205
+ expect(
206
+ normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
207
+ .toolTextSummaryMaxLength,
208
+ ).toBeUndefined();
209
+ expect(
210
+ normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
211
+ .toolTextSummaryMaxLength,
212
+ ).toBeUndefined();
213
+ expect(
214
+ normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
215
+ .toolTextSummaryMaxLength,
216
+ ).toBeUndefined();
217
+ });
125
218
  });
@@ -12,6 +12,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { describe, expect, it, vi } from "vitest";
13
13
 
14
14
  import { EXTENSION_TAG } from "#src/denial-messages";
15
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
15
16
  import { formatExternalDirectoryAskPrompt } from "#src/handlers/gates/external-directory-messages";
16
17
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
17
18
  import {
@@ -112,10 +113,11 @@ function makeSession(
112
113
  checkPermission: makeCheckPermission("deny"),
113
114
  getToolPermission: vi.fn().mockReturnValue("allow"),
114
115
  getSessionRuleset: vi.fn().mockReturnValue([]),
115
- approveSessionRule: vi.fn(),
116
+ recordSessionApproval: vi.fn(),
116
117
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
117
118
  getInfrastructureDirs: vi.fn().mockReturnValue([]),
118
119
  getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
120
+ config: DEFAULT_EXTENSION_CONFIG,
119
121
  canPrompt: vi.fn().mockReturnValue(true),
120
122
  prompt: vi.fn().mockResolvedValue({ approved: true, state: "approved" }),
121
123
  ...overrides,
@@ -3,7 +3,7 @@
3
3
  * external path only prompt once — the session-approval recorded by the
4
4
  * first call covers the second.
5
5
  *
6
- * These tests use stateful mocks: `approveSessionRule` records rules,
6
+ * These tests use stateful mocks: `recordSessionApproval` records rules,
7
7
  * and `checkPermission` consults them via `getSessionRuleset`, mirroring
8
8
  * the real interaction between PermissionSession, SessionRules, and
9
9
  * PermissionManager.
@@ -11,9 +11,11 @@
11
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { describe, expect, it, vi } from "vitest";
13
13
 
14
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
14
15
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
15
16
  import type { PermissionSession } from "#src/permission-session";
16
17
  import type { Rule } from "#src/rule";
18
+ import type { SessionApproval } from "#src/session-approval";
17
19
  import type { ToolRegistry } from "#src/tool-registry";
18
20
  import type { PermissionCheckResult } from "#src/types";
19
21
  import { wildcardMatch } from "#src/wildcard-matcher";
@@ -52,7 +54,7 @@ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
52
54
  * Build a PermissionSession mock with stateful session-rule tracking.
53
55
  *
54
56
  * `checkPermission` returns "ask" for `external_directory` unless a
55
- * matching session rule exists (via `approveSessionRule`), in which case
57
+ * matching session rule exists (via `recordSessionApproval`), in which case
56
58
  * it returns "allow" with `source: "session"`. All other surfaces return
57
59
  * "allow" by default.
58
60
  */
@@ -114,16 +116,18 @@ function makeStatefulSession(
114
116
  },
115
117
  );
116
118
 
117
- const approveSessionRule = vi
119
+ const recordSessionApproval = vi
118
120
  .fn()
119
- .mockImplementation((surface: string, pattern: string) => {
120
- sessionRules.push({
121
- surface,
122
- pattern,
123
- action: "allow",
124
- layer: "session",
125
- origin: "session",
126
- });
121
+ .mockImplementation((approval: SessionApproval) => {
122
+ for (const pattern of approval.patterns) {
123
+ sessionRules.push({
124
+ surface: approval.surface,
125
+ pattern,
126
+ action: "allow",
127
+ layer: "session",
128
+ origin: "session",
129
+ });
130
+ }
127
131
  });
128
132
 
129
133
  const getSessionRuleset = vi.fn().mockImplementation(() => [...sessionRules]);
@@ -135,10 +139,11 @@ function makeStatefulSession(
135
139
  checkPermission,
136
140
  getToolPermission: vi.fn().mockReturnValue("allow"),
137
141
  getSessionRuleset,
138
- approveSessionRule,
142
+ recordSessionApproval,
139
143
  getActiveSkillEntries: vi.fn().mockReturnValue([]),
140
144
  getInfrastructureDirs: vi.fn().mockReturnValue([]),
141
145
  getInfrastructureReadPaths: vi.fn().mockReturnValue([]),
146
+ config: DEFAULT_EXTENSION_CONFIG,
142
147
  canPrompt: vi.fn().mockReturnValue(true),
143
148
  prompt: vi
144
149
  .fn()
@@ -93,9 +93,8 @@ describe("describeBashExternalDirectoryGate", () => {
93
93
  expect(isGateDescriptor(result)).toBe(true);
94
94
  const desc = result as GateDescriptor;
95
95
  expect(desc.sessionApproval).toBeDefined();
96
- expect(desc.sessionApproval).toHaveProperty("patterns");
97
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
98
- expect(patterns.length).toBeGreaterThan(0);
96
+ if (!desc.sessionApproval) return;
97
+ expect(desc.sessionApproval.patterns.length).toBeGreaterThan(0);
99
98
  });
100
99
 
101
100
  it("returns GateBypass when all external paths are config-level allowed", async () => {
@@ -211,8 +210,9 @@ describe("describeBashExternalDirectoryGate", () => {
211
210
  );
212
211
  expect(isGateDescriptor(result)).toBe(true);
213
212
  const desc = result as GateDescriptor;
214
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
215
- expect(patterns.length).toBe(1);
213
+ expect(desc.sessionApproval).toBeDefined();
214
+ if (!desc.sessionApproval) return;
215
+ expect(desc.sessionApproval.patterns.length).toBe(1);
216
216
  expect(desc.preCheck?.state).toBe("ask");
217
217
  });
218
218
 
@@ -236,8 +236,9 @@ describe("describeBashExternalDirectoryGate", () => {
236
236
  const desc = result as GateDescriptor;
237
237
  expect(desc.preCheck?.state).toBe("deny");
238
238
  // Both paths are uncovered (neither is allow), so both patterns are included.
239
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
240
- expect(patterns.length).toBe(2);
239
+ expect(desc.sessionApproval).toBeDefined();
240
+ if (!desc.sessionApproval) return;
241
+ expect(desc.sessionApproval.patterns.length).toBe(2);
241
242
  });
242
243
 
243
244
  it("only includes uncovered paths when some are session-covered", async () => {
@@ -259,7 +260,8 @@ describe("describeBashExternalDirectoryGate", () => {
259
260
  expect(isGateDescriptor(result)).toBe(true);
260
261
  const desc = result as GateDescriptor;
261
262
  // Should have patterns only for the uncovered path
262
- const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
263
- expect(patterns.length).toBe(1);
263
+ expect(desc.sessionApproval).toBeDefined();
264
+ if (!desc.sessionApproval) return;
265
+ expect(desc.sessionApproval.patterns.length).toBe(1);
264
266
  });
265
267
  });
@@ -126,11 +126,8 @@ describe("describeExternalDirectoryGate", () => {
126
126
  ["/test/agent"],
127
127
  ) as GateDescriptor;
128
128
  expect(result.sessionApproval).toBeDefined();
129
- expect(result.sessionApproval).toHaveProperty(
130
- "surface",
131
- "external_directory",
132
- );
133
- expect(result.sessionApproval).toHaveProperty("pattern");
129
+ expect(result.sessionApproval?.surface).toBe("external_directory");
130
+ expect(result.sessionApproval?.representativePattern).toBeDefined();
134
131
  });
135
132
 
136
133
  it("denialContext contains the external path and cwd", () => {
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
3
  import {
4
+ buildDecisionEvent,
4
5
  deriveDecisionValue,
5
6
  deriveResolution,
6
7
  } from "#src/handlers/gates/helpers";
8
+ import type { PermissionCheckResult } from "#src/types";
7
9
 
8
10
  describe("deriveDecisionValue", () => {
9
11
  it("returns command for bash", () => {
@@ -82,3 +84,82 @@ describe("deriveResolution", () => {
82
84
  );
83
85
  });
84
86
  });
87
+
88
+ describe("buildDecisionEvent", () => {
89
+ function makeCheck(
90
+ overrides: Partial<PermissionCheckResult> = {},
91
+ ): PermissionCheckResult {
92
+ return {
93
+ state: "allow",
94
+ toolName: "read",
95
+ source: "tool",
96
+ origin: "builtin",
97
+ matchedPattern: "*",
98
+ ...overrides,
99
+ };
100
+ }
101
+
102
+ it("builds a decision event with all fields populated", () => {
103
+ const event = buildDecisionEvent(
104
+ { surface: "read", value: "read" },
105
+ makeCheck({ origin: "global", matchedPattern: "read" }),
106
+ "test-agent",
107
+ "allow",
108
+ "policy_allow",
109
+ );
110
+ expect(event).toEqual({
111
+ surface: "read",
112
+ value: "read",
113
+ result: "allow",
114
+ resolution: "policy_allow",
115
+ origin: "global",
116
+ agentName: "test-agent",
117
+ matchedPattern: "read",
118
+ });
119
+ });
120
+
121
+ it("normalises undefined origin to null", () => {
122
+ const event = buildDecisionEvent(
123
+ { surface: "bash", value: "git status" },
124
+ makeCheck({ origin: undefined }),
125
+ null,
126
+ "allow",
127
+ "user_approved",
128
+ );
129
+ expect(event.origin).toBeNull();
130
+ });
131
+
132
+ it("normalises null agentName to null", () => {
133
+ const event = buildDecisionEvent(
134
+ { surface: "read", value: "read" },
135
+ makeCheck(),
136
+ null,
137
+ "deny",
138
+ "policy_deny",
139
+ );
140
+ expect(event.agentName).toBeNull();
141
+ });
142
+
143
+ it("normalises undefined matchedPattern to null", () => {
144
+ const event = buildDecisionEvent(
145
+ { surface: "read", value: "read" },
146
+ makeCheck({ matchedPattern: undefined }),
147
+ null,
148
+ "deny",
149
+ "policy_deny",
150
+ );
151
+ expect(event.matchedPattern).toBeNull();
152
+ });
153
+
154
+ it("passes result and resolution through", () => {
155
+ const event = buildDecisionEvent(
156
+ { surface: "bash", value: "rm -rf /" },
157
+ makeCheck(),
158
+ null,
159
+ "deny",
160
+ "user_denied",
161
+ );
162
+ expect(event.result).toBe("deny");
163
+ expect(event.resolution).toBe("user_denied");
164
+ });
165
+ });