@komarspn/pi-permission-system 16.0.2

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 (203) hide show
  1. package/CHANGELOG.md +2234 -0
  2. package/LICENSE +21 -0
  3. package/README.md +158 -0
  4. package/config/config.example.json +39 -0
  5. package/package.json +82 -0
  6. package/schemas/permissions.schema.json +158 -0
  7. package/src/active-agent.ts +72 -0
  8. package/src/async-cache.ts +21 -0
  9. package/src/bash-arity.ts +210 -0
  10. package/src/builtin-tool-input-formatters.ts +82 -0
  11. package/src/canonicalize-path.ts +30 -0
  12. package/src/common.ts +121 -0
  13. package/src/config-loader.ts +432 -0
  14. package/src/config-modal.ts +259 -0
  15. package/src/config-paths.ts +47 -0
  16. package/src/config-reporter.ts +34 -0
  17. package/src/config-store.ts +222 -0
  18. package/src/decision-audit.ts +75 -0
  19. package/src/decision-reporter.ts +41 -0
  20. package/src/denial-messages.ts +232 -0
  21. package/src/expand-home.ts +28 -0
  22. package/src/extension-config.ts +79 -0
  23. package/src/extension-paths.ts +66 -0
  24. package/src/forwarded-permissions/io.ts +404 -0
  25. package/src/forwarded-permissions/permission-forwarder.ts +580 -0
  26. package/src/forwarding-manager.ts +74 -0
  27. package/src/gate-prompter.ts +12 -0
  28. package/src/handlers/before-agent-start.ts +94 -0
  29. package/src/handlers/gates/bash-command.ts +75 -0
  30. package/src/handlers/gates/bash-external-directory.ts +127 -0
  31. package/src/handlers/gates/bash-path-extractor.ts +15 -0
  32. package/src/handlers/gates/bash-path.ts +152 -0
  33. package/src/handlers/gates/bash-program.ts +1143 -0
  34. package/src/handlers/gates/bash-token-classification.ts +105 -0
  35. package/src/handlers/gates/candidate-check.ts +32 -0
  36. package/src/handlers/gates/descriptor.ts +81 -0
  37. package/src/handlers/gates/external-directory-messages.ts +20 -0
  38. package/src/handlers/gates/external-directory.ts +133 -0
  39. package/src/handlers/gates/helpers.ts +76 -0
  40. package/src/handlers/gates/path.ts +91 -0
  41. package/src/handlers/gates/runner.ts +186 -0
  42. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  43. package/src/handlers/gates/skill-input.ts +46 -0
  44. package/src/handlers/gates/skill-read.ts +87 -0
  45. package/src/handlers/gates/tool-call-gate-pipeline.ts +129 -0
  46. package/src/handlers/gates/tool.ts +102 -0
  47. package/src/handlers/gates/types.ts +13 -0
  48. package/src/handlers/index.ts +3 -0
  49. package/src/handlers/lifecycle.ts +95 -0
  50. package/src/handlers/permission-gate-handler.ts +190 -0
  51. package/src/handlers/tool-call-boundary.ts +91 -0
  52. package/src/index.ts +225 -0
  53. package/src/input-normalizer.ts +157 -0
  54. package/src/logging.ts +113 -0
  55. package/src/mcp-targets.ts +170 -0
  56. package/src/node-modules-discovery.ts +76 -0
  57. package/src/normalize.ts +43 -0
  58. package/src/path-utils.ts +355 -0
  59. package/src/pattern-suggest.ts +132 -0
  60. package/src/permission-dialog.ts +138 -0
  61. package/src/permission-event-rpc.ts +223 -0
  62. package/src/permission-events.ts +266 -0
  63. package/src/permission-forwarding.ts +188 -0
  64. package/src/permission-gate.ts +94 -0
  65. package/src/permission-manager.ts +392 -0
  66. package/src/permission-merge.ts +32 -0
  67. package/src/permission-prompter.ts +142 -0
  68. package/src/permission-prompts.ts +93 -0
  69. package/src/permission-resolver.ts +109 -0
  70. package/src/permission-session.ts +189 -0
  71. package/src/permission-ui-prompt.ts +127 -0
  72. package/src/permissions-service.ts +63 -0
  73. package/src/persistent-approval-recorder.ts +139 -0
  74. package/src/policy-loader.ts +350 -0
  75. package/src/prompting-gateway.ts +104 -0
  76. package/src/rule.ts +188 -0
  77. package/src/scope-merge.ts +72 -0
  78. package/src/service-lifecycle.ts +49 -0
  79. package/src/service.ts +163 -0
  80. package/src/session-approval-recorder.ts +6 -0
  81. package/src/session-approval.ts +43 -0
  82. package/src/session-logger.ts +91 -0
  83. package/src/session-rules.ts +79 -0
  84. package/src/skill-prompt-sanitizer.ts +292 -0
  85. package/src/status.ts +35 -0
  86. package/src/subagent-context.ts +104 -0
  87. package/src/subagent-lifecycle-events.ts +72 -0
  88. package/src/subagent-registry.ts +105 -0
  89. package/src/synthesize.ts +92 -0
  90. package/src/system-prompt-sanitizer.ts +274 -0
  91. package/src/tool-access-extractor-registry.ts +68 -0
  92. package/src/tool-input-formatter-registry.ts +67 -0
  93. package/src/tool-input-preview.ts +34 -0
  94. package/src/tool-input-prompt-formatters.ts +63 -0
  95. package/src/tool-preview-formatter.ts +207 -0
  96. package/src/tool-registry.ts +148 -0
  97. package/src/types.ts +64 -0
  98. package/src/wildcard-matcher.ts +120 -0
  99. package/src/yolo-mode.ts +30 -0
  100. package/test/active-agent.test.ts +155 -0
  101. package/test/async-cache.test.ts +48 -0
  102. package/test/bash-arity.test.ts +144 -0
  103. package/test/bash-external-directory.test.ts +956 -0
  104. package/test/builtin-tool-input-formatters.test.ts +109 -0
  105. package/test/canonicalize-path.test.ts +93 -0
  106. package/test/common.test.ts +287 -0
  107. package/test/composition-root.test.ts +603 -0
  108. package/test/config-loader.test.ts +740 -0
  109. package/test/config-modal.test.ts +320 -0
  110. package/test/config-paths.test.ts +83 -0
  111. package/test/config-pipeline.test.ts +90 -0
  112. package/test/config-reporter.test.ts +147 -0
  113. package/test/config-store.test.ts +466 -0
  114. package/test/decision-audit.test.ts +72 -0
  115. package/test/decision-reporter.test.ts +112 -0
  116. package/test/denial-messages.test.ts +656 -0
  117. package/test/detect-permissive-bash-fallback.test.ts +56 -0
  118. package/test/expand-home.test.ts +93 -0
  119. package/test/extension-config.test.ts +129 -0
  120. package/test/extension-paths.test.ts +108 -0
  121. package/test/forwarded-permissions/io.test.ts +251 -0
  122. package/test/forwarding-manager.test.ts +194 -0
  123. package/test/handlers/before-agent-start.test.ts +317 -0
  124. package/test/handlers/external-directory-integration.test.ts +623 -0
  125. package/test/handlers/external-directory-session-dedup.test.ts +430 -0
  126. package/test/handlers/external-directory-symlink-acceptance.test.ts +149 -0
  127. package/test/handlers/gates/bash-command-metamorphic.test.ts +83 -0
  128. package/test/handlers/gates/bash-command.test.ts +191 -0
  129. package/test/handlers/gates/bash-external-directory.test.ts +269 -0
  130. package/test/handlers/gates/bash-path.test.ts +337 -0
  131. package/test/handlers/gates/bash-program.test.ts +410 -0
  132. package/test/handlers/gates/bash-token-classification.test.ts +241 -0
  133. package/test/handlers/gates/candidate-check.test.ts +52 -0
  134. package/test/handlers/gates/external-directory-messages.test.ts +61 -0
  135. package/test/handlers/gates/external-directory.test.ts +259 -0
  136. package/test/handlers/gates/helpers.test.ts +177 -0
  137. package/test/handlers/gates/path.test.ts +294 -0
  138. package/test/handlers/gates/runner.test.ts +447 -0
  139. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  140. package/test/handlers/gates/skill-input.test.ts +131 -0
  141. package/test/handlers/gates/skill-read.test.ts +158 -0
  142. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +252 -0
  143. package/test/handlers/gates/tool.test.ts +223 -0
  144. package/test/handlers/input-events.test.ts +168 -0
  145. package/test/handlers/input.test.ts +199 -0
  146. package/test/handlers/lifecycle.test.ts +221 -0
  147. package/test/handlers/tool-call-boundary.test.ts +145 -0
  148. package/test/handlers/tool-call-events.test.ts +277 -0
  149. package/test/handlers/tool-call.test.ts +395 -0
  150. package/test/handlers/validate-requested-tool.test.ts +92 -0
  151. package/test/helpers/gate-fixtures.ts +323 -0
  152. package/test/helpers/handler-fixtures.ts +335 -0
  153. package/test/helpers/make-fake-pi.ts +100 -0
  154. package/test/helpers/manager-harness.ts +112 -0
  155. package/test/helpers/session-fixtures.ts +204 -0
  156. package/test/input-normalizer.test.ts +367 -0
  157. package/test/logging.test.ts +51 -0
  158. package/test/mcp-targets.test.ts +233 -0
  159. package/test/node-modules-discovery.test.ts +97 -0
  160. package/test/normalize.test.ts +247 -0
  161. package/test/path-utils.test.ts +650 -0
  162. package/test/pattern-suggest.test.ts +248 -0
  163. package/test/permission-dialog.test.ts +241 -0
  164. package/test/permission-event-rpc.test.ts +541 -0
  165. package/test/permission-events.test.ts +402 -0
  166. package/test/permission-forwarder.test.ts +369 -0
  167. package/test/permission-forwarding.test.ts +315 -0
  168. package/test/permission-gate.test.ts +305 -0
  169. package/test/permission-manager-unified.test.ts +3368 -0
  170. package/test/permission-merge.test.ts +61 -0
  171. package/test/permission-prompter.test.ts +518 -0
  172. package/test/permission-prompts.test.ts +363 -0
  173. package/test/permission-resolver.test.ts +265 -0
  174. package/test/permission-session.test.ts +363 -0
  175. package/test/permission-ui-prompt.test.ts +146 -0
  176. package/test/permissions-service.test.ts +177 -0
  177. package/test/persistent-approval-recorder.test.ts +133 -0
  178. package/test/pi-infrastructure-read.test.ts +369 -0
  179. package/test/policy-loader.test.ts +561 -0
  180. package/test/prompting-gateway.test.ts +230 -0
  181. package/test/rule.test.ts +604 -0
  182. package/test/scope-merge.test.ts +116 -0
  183. package/test/service-lifecycle.test.ts +163 -0
  184. package/test/service.test.ts +308 -0
  185. package/test/session-approval.test.ts +75 -0
  186. package/test/session-logger.test.ts +200 -0
  187. package/test/session-rules.test.ts +304 -0
  188. package/test/session-start.test.ts +112 -0
  189. package/test/skill-prompt-sanitizer.test.ts +374 -0
  190. package/test/status.test.ts +10 -0
  191. package/test/subagent-context.test.ts +326 -0
  192. package/test/subagent-lifecycle-events.test.ts +132 -0
  193. package/test/subagent-registry.test.ts +145 -0
  194. package/test/synthesize.test.ts +300 -0
  195. package/test/system-prompt-sanitizer.test.ts +382 -0
  196. package/test/tool-access-extractor-registry.test.ts +77 -0
  197. package/test/tool-input-formatter-registry.test.ts +75 -0
  198. package/test/tool-input-preview.test.ts +129 -0
  199. package/test/tool-input-prompt-formatters.test.ts +115 -0
  200. package/test/tool-preview-formatter.test.ts +458 -0
  201. package/test/tool-registry.test.ts +197 -0
  202. package/test/wildcard-matcher.test.ts +424 -0
  203. package/test/yolo-mode.test.ts +188 -0
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Integration tests verifying that sequential tool calls to the same
3
+ * external path only prompt once — the session-approval recorded by the
4
+ * first call covers the second.
5
+ *
6
+ * Uses real PermissionSession + PermissionResolver + SessionRules so the
7
+ * stateful approval-tracking path is exercised end-to-end.
8
+ */
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import { GateDecisionReporter } from "#src/decision-reporter";
13
+ import type { GatePrompter } from "#src/gate-prompter";
14
+ import { GateRunner } from "#src/handlers/gates/runner";
15
+ import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
16
+ import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
17
+ import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
18
+ import type { PermissionCheckResult } from "#src/types";
19
+ import { wildcardMatch } from "#src/wildcard-matcher";
20
+
21
+ import {
22
+ makeCtx,
23
+ makeEvents,
24
+ makeToolRegistry,
25
+ } from "#test/helpers/handler-fixtures";
26
+ import {
27
+ makeRealResolver,
28
+ makeRealSession,
29
+ } from "#test/helpers/session-fixtures";
30
+
31
+ // ── SDK stub ───────────────────────────────────────────────────────────────
32
+ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
33
+ const original =
34
+ await importOriginal<typeof import("@earendil-works/pi-coding-agent")>();
35
+ return { ...original };
36
+ });
37
+
38
+ // ── helpers ────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Build a fully wired PermissionGateHandler for external-directory dedup
42
+ * tests.
43
+ *
44
+ * `permissionManager.checkPermission` is configured so that:
45
+ * - `external_directory` surface returns "ask" on first call
46
+ * - On subsequent calls it checks the shared `sessionRules` store; if a
47
+ * matching rule was recorded by the runner, it returns "allow" with
48
+ * `source: "session"`.
49
+ * - All other surfaces return "allow".
50
+ */
51
+ function makeDeduplicatingHandler(prompter?: GatePrompter): {
52
+ handler: PermissionGateHandler;
53
+ prompter: GatePrompter;
54
+ } {
55
+ const { session, permissionManager, sessionRules, logger } =
56
+ makeRealSession();
57
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
58
+
59
+ // Configure checkPermission to simulate config-level "ask" for external_directory
60
+ // but return "allow/session" when a session rule has been recorded.
61
+ vi.mocked(permissionManager.checkPermission).mockImplementation(
62
+ (surface, input, _agentName, rules): PermissionCheckResult => {
63
+ if (surface === "external_directory") {
64
+ const record = (input ?? {}) as Record<string, unknown>;
65
+ const pathValue = typeof record.path === "string" ? record.path : null;
66
+
67
+ if (pathValue && rules && rules.length > 0) {
68
+ const match = rules.findLast(
69
+ (r) =>
70
+ r.surface === "external_directory" &&
71
+ wildcardMatch(r.pattern, pathValue),
72
+ );
73
+ if (match) {
74
+ return {
75
+ state: "allow",
76
+ toolName: surface,
77
+ source: "session",
78
+ origin: "session",
79
+ matchedPattern: match.pattern,
80
+ };
81
+ }
82
+ }
83
+
84
+ return {
85
+ state: "ask",
86
+ toolName: surface,
87
+ source: "special",
88
+ origin: "global",
89
+ };
90
+ }
91
+
92
+ return {
93
+ state: "allow",
94
+ toolName: surface,
95
+ source: "tool",
96
+ origin: "builtin",
97
+ };
98
+ },
99
+ );
100
+
101
+ // The external-directory gates resolve through checkPathPolicy (#418); route
102
+ // it through the same configured checkPermission so session-approval dedup
103
+ // applies to the typed path alias.
104
+ vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
105
+ (values, agentName, rules, surface = "path") =>
106
+ permissionManager.checkPermission(
107
+ surface,
108
+ { path: values[0] ?? "*" },
109
+ agentName,
110
+ rules,
111
+ ),
112
+ );
113
+
114
+ const events = makeEvents();
115
+ const reporter = new GateDecisionReporter(logger, events);
116
+ const resolvedPrompter: GatePrompter = prompter ?? {
117
+ canConfirm: vi.fn().mockReturnValue(true),
118
+ prompt: vi
119
+ .fn<GatePrompter["prompt"]>()
120
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
121
+ };
122
+ const runner = new GateRunner(
123
+ resolver,
124
+ sessionRules,
125
+ resolvedPrompter,
126
+ reporter,
127
+ { recordApproval: vi.fn() } as never,
128
+ );
129
+ const handler = new PermissionGateHandler(
130
+ session,
131
+ makeToolRegistry({
132
+ getAll: vi
133
+ .fn()
134
+ .mockReturnValue([
135
+ { name: "read" },
136
+ { name: "write" },
137
+ { name: "edit" },
138
+ { name: "bash" },
139
+ ]),
140
+ }),
141
+ new ToolCallGatePipeline(resolver, session),
142
+ new SkillInputGatePipeline(resolver),
143
+ runner,
144
+ );
145
+ return { handler, prompter: resolvedPrompter };
146
+ }
147
+
148
+ // ── tests ──────────────────────────────────────────────────────────────────
149
+
150
+ describe("external-directory session dedup", () => {
151
+ describe("path-bearing tools (read, write, edit)", () => {
152
+ it("does not re-prompt for the same external path after session approval", async () => {
153
+ const { handler, prompter } = makeDeduplicatingHandler();
154
+ const ctx = makeCtx();
155
+ const externalPath = "/outside/project/data.txt";
156
+
157
+ // First call — should prompt
158
+ const event1 = {
159
+ type: "tool_call",
160
+ toolCallId: "tc-1",
161
+ toolName: "read",
162
+ input: { path: externalPath },
163
+ };
164
+ const result1 = await handler.handleToolCall(event1, ctx);
165
+ expect(result1).toEqual({ action: "allow" });
166
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
167
+
168
+ // Second call — same path, should hit session rule, no prompt
169
+ const event2 = {
170
+ type: "tool_call",
171
+ toolCallId: "tc-2",
172
+ toolName: "read",
173
+ input: { path: externalPath },
174
+ };
175
+ const result2 = await handler.handleToolCall(event2, ctx);
176
+ expect(result2).toEqual({ action: "allow" });
177
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
178
+ });
179
+
180
+ it("does not re-prompt for a different file in the same external directory", async () => {
181
+ const { handler, prompter } = makeDeduplicatingHandler();
182
+ const ctx = makeCtx();
183
+
184
+ // First call — prompt for /outside/project/a.txt
185
+ const event1 = {
186
+ type: "tool_call",
187
+ toolCallId: "tc-1",
188
+ toolName: "read",
189
+ input: { path: "/outside/project/a.txt" },
190
+ };
191
+ await handler.handleToolCall(event1, ctx);
192
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
193
+
194
+ // Second call — /outside/project/b.txt is in the same directory
195
+ const event2 = {
196
+ type: "tool_call",
197
+ toolCallId: "tc-2",
198
+ toolName: "read",
199
+ input: { path: "/outside/project/b.txt" },
200
+ };
201
+ await handler.handleToolCall(event2, ctx);
202
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
203
+ });
204
+
205
+ it("does prompt for a file in a different external directory", async () => {
206
+ const { handler, prompter } = makeDeduplicatingHandler();
207
+ const ctx = makeCtx();
208
+
209
+ // First call — /outside/alpha/file.txt
210
+ const event1 = {
211
+ type: "tool_call",
212
+ toolCallId: "tc-1",
213
+ toolName: "read",
214
+ input: { path: "/outside/alpha/file.txt" },
215
+ };
216
+ await handler.handleToolCall(event1, ctx);
217
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
218
+
219
+ // Second call — /outside/beta/file.txt is a different directory
220
+ const event2 = {
221
+ type: "tool_call",
222
+ toolCallId: "tc-2",
223
+ toolName: "read",
224
+ input: { path: "/outside/beta/file.txt" },
225
+ };
226
+ await handler.handleToolCall(event2, ctx);
227
+ expect(prompter.prompt).toHaveBeenCalledTimes(2);
228
+ });
229
+
230
+ it("re-prompts when user approved once (not for session)", async () => {
231
+ const approveOnce: GatePrompter = {
232
+ canConfirm: vi.fn().mockReturnValue(true),
233
+ prompt: vi
234
+ .fn<GatePrompter["prompt"]>()
235
+ .mockResolvedValue({ approved: true, state: "approved" }),
236
+ };
237
+ const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
238
+ const ctx = makeCtx();
239
+ const externalPath = "/outside/project/data.txt";
240
+
241
+ // First call — prompt, approved once
242
+ const event1 = {
243
+ type: "tool_call",
244
+ toolCallId: "tc-1",
245
+ toolName: "read",
246
+ input: { path: externalPath },
247
+ };
248
+ await handler.handleToolCall(event1, ctx);
249
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
250
+
251
+ // Second call — no session rule recorded, should prompt again
252
+ const event2 = {
253
+ type: "tool_call",
254
+ toolCallId: "tc-2",
255
+ toolName: "read",
256
+ input: { path: externalPath },
257
+ };
258
+ await handler.handleToolCall(event2, ctx);
259
+ expect(prompter.prompt).toHaveBeenCalledTimes(2);
260
+ });
261
+ });
262
+
263
+ describe("bash commands with external paths", () => {
264
+ it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
265
+ const { handler, prompter } = makeDeduplicatingHandler();
266
+ const ctx = makeCtx();
267
+
268
+ // First call — bash referencing /tmp/out.txt
269
+ const event1 = {
270
+ type: "tool_call",
271
+ toolCallId: "tc-1",
272
+ toolName: "bash",
273
+ input: { command: "echo hello > /tmp/out.txt" },
274
+ };
275
+ const result1 = await handler.handleToolCall(event1, ctx);
276
+ expect(result1).toEqual({ action: "allow" });
277
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
278
+
279
+ // Second call — different bash command, same external path
280
+ const event2 = {
281
+ type: "tool_call",
282
+ toolCallId: "tc-2",
283
+ toolName: "bash",
284
+ input: { command: "cat /tmp/out.txt" },
285
+ };
286
+ const result2 = await handler.handleToolCall(event2, ctx);
287
+ expect(result2).toEqual({ action: "allow" });
288
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
289
+ });
290
+
291
+ it("does not re-prompt for read after bash already approved the same directory", async () => {
292
+ const { handler, prompter } = makeDeduplicatingHandler();
293
+ const ctx = makeCtx();
294
+
295
+ // First call — bash writes to /tmp/out.txt
296
+ const event1 = {
297
+ type: "tool_call",
298
+ toolCallId: "tc-1",
299
+ toolName: "bash",
300
+ input: { command: "echo hello > /tmp/out.txt" },
301
+ };
302
+ await handler.handleToolCall(event1, ctx);
303
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
304
+
305
+ // Second call — read from /tmp/out.txt (same directory, different tool)
306
+ const event2 = {
307
+ type: "tool_call",
308
+ toolCallId: "tc-2",
309
+ toolName: "read",
310
+ input: { path: "/tmp/out.txt" },
311
+ };
312
+ await handler.handleToolCall(event2, ctx);
313
+ expect(prompter.prompt).toHaveBeenCalledTimes(1);
314
+ });
315
+ });
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Moved from permission-system.test.ts catch-all (#342)
320
+ // ---------------------------------------------------------------------------
321
+
322
+ describe("session shutdown clears external-directory approvals", () => {
323
+ it("re-prompts for the same path after session shutdown", async () => {
324
+ // Build a fully wired handler inline so we can access session directly.
325
+ const { session, permissionManager, sessionRules, logger } =
326
+ makeRealSession();
327
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
328
+
329
+ // external_directory=ask; session-covered paths return allow/session.
330
+ vi.mocked(permissionManager.checkPermission).mockImplementation(
331
+ (surface, input, _agentName, rules): PermissionCheckResult => {
332
+ if (surface === "external_directory") {
333
+ const record = (input ?? {}) as Record<string, unknown>;
334
+ const pathValue =
335
+ typeof record.path === "string" ? record.path : null;
336
+ if (pathValue && rules && rules.length > 0) {
337
+ const match = rules.findLast(
338
+ (r) =>
339
+ r.surface === "external_directory" &&
340
+ wildcardMatch(r.pattern, pathValue),
341
+ );
342
+ if (match) {
343
+ return {
344
+ state: "allow",
345
+ toolName: surface,
346
+ source: "session",
347
+ origin: "session",
348
+ matchedPattern: match.pattern,
349
+ };
350
+ }
351
+ }
352
+ return {
353
+ state: "ask",
354
+ toolName: surface,
355
+ source: "special",
356
+ origin: "global",
357
+ };
358
+ }
359
+ return {
360
+ state: "allow",
361
+ toolName: surface,
362
+ source: "tool",
363
+ origin: "builtin",
364
+ };
365
+ },
366
+ );
367
+
368
+ // The external-directory tool gate resolves through checkPathPolicy (#418);
369
+ // route it through the same configured checkPermission.
370
+ vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
371
+ (values, agentName, rules, surface = "path") =>
372
+ permissionManager.checkPermission(
373
+ surface,
374
+ { path: values[0] ?? "*" },
375
+ agentName,
376
+ rules,
377
+ ),
378
+ );
379
+
380
+ const events = makeEvents();
381
+ const reporter = new GateDecisionReporter(logger, events);
382
+ const prompter: GatePrompter = {
383
+ canConfirm: vi.fn().mockReturnValue(true),
384
+ // Simulate "Yes, for this session" on first call, "Yes" on subsequent.
385
+ prompt: vi
386
+ .fn<GatePrompter["prompt"]>()
387
+ .mockResolvedValue({ approved: true, state: "approved_for_session" }),
388
+ };
389
+ const runner = new GateRunner(
390
+ resolver,
391
+ sessionRules,
392
+ prompter,
393
+ reporter,
394
+ { recordApproval: vi.fn() } as never,
395
+ );
396
+ const handler = new PermissionGateHandler(
397
+ session,
398
+ makeToolRegistry({
399
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
400
+ }),
401
+ new ToolCallGatePipeline(resolver, session),
402
+ new SkillInputGatePipeline(resolver),
403
+ runner,
404
+ );
405
+
406
+ const externalPath = "/tmp/sibling/foo.ts";
407
+ const ctx = makeCtx();
408
+ const event = {
409
+ type: "tool_call",
410
+ toolCallId: "tc-1",
411
+ toolName: "read",
412
+ input: { path: externalPath },
413
+ };
414
+
415
+ // First access: prompt fires and records session approval.
416
+ await handler.handleToolCall(event, ctx);
417
+ expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
418
+
419
+ // Second access: covered by session approval — no re-prompt.
420
+ await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
421
+ expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
422
+
423
+ // Shutdown clears session approvals.
424
+ session.shutdown();
425
+
426
+ // Third access: session rules cleared — must re-prompt.
427
+ await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
428
+ expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(2);
429
+ });
430
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Acceptance test for issue #418.
3
+ *
4
+ * Reproduces the reported bug with a real symlink (no `realpathSync` mock):
5
+ * an `external_directory` allow configured for the path as the user types it
6
+ * (`<link>/*`) must allow access even though the OS resolves `<link>` to a
7
+ * different canonical directory. Exercised end-to-end through the real
8
+ * `PermissionManager` + `PermissionResolver` for both a path-bearing tool and
9
+ * a bash command, and for an allow keyed on the symlink-resolved form too.
10
+ */
11
+
12
+ import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
+
17
+ import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
18
+ import { BashProgram } from "#src/handlers/gates/bash-program";
19
+ import {
20
+ type GateDescriptor,
21
+ isGateBypass,
22
+ isGateDescriptor,
23
+ } from "#src/handlers/gates/descriptor";
24
+ import { describeExternalDirectoryGate } from "#src/handlers/gates/external-directory";
25
+ import type { ToolCallContext } from "#src/handlers/gates/types";
26
+ import { PermissionResolver } from "#src/permission-resolver";
27
+ import { SessionRules } from "#src/session-rules";
28
+ import type { ScopeConfig } from "#src/types";
29
+
30
+ import { createManager } from "#test/helpers/manager-harness";
31
+
32
+ // ── real symlink fixture ─────────────────────────────────────────────────────
33
+
34
+ let realDir: string;
35
+ let linkDir: string;
36
+ let cwd: string;
37
+ const tempRoots: string[] = [];
38
+
39
+ function mkTemp(prefix: string): string {
40
+ const dir = mkdtempSync(join(tmpdir(), prefix));
41
+ tempRoots.push(dir);
42
+ return dir;
43
+ }
44
+
45
+ beforeEach(() => {
46
+ realDir = mkTemp("ext-real-");
47
+ const linkParent = mkTemp("ext-link-");
48
+ linkDir = join(linkParent, "link");
49
+ symlinkSync(realDir, linkDir);
50
+ cwd = mkTemp("ext-cwd-");
51
+ });
52
+
53
+ afterEach(() => {
54
+ while (tempRoots.length > 0) {
55
+ const dir = tempRoots.pop();
56
+ if (dir) rmSync(dir, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ function makeResolver(config: ScopeConfig) {
61
+ const { manager, cleanup } = createManager(config);
62
+ manager.configureForCwd(cwd);
63
+ const resolver = new PermissionResolver(manager, new SessionRules());
64
+ return { resolver, cleanup };
65
+ }
66
+
67
+ function readTcc(): ToolCallContext {
68
+ return {
69
+ toolName: "read",
70
+ agentName: null,
71
+ input: { path: join(linkDir, "file.ts") },
72
+ toolCallId: "tc-1",
73
+ cwd,
74
+ };
75
+ }
76
+
77
+ // ── tests ────────────────────────────────────────────────────────────────────
78
+
79
+ describe("external_directory symlink acceptance (#418)", () => {
80
+ it("allows a path-bearing tool when the allow is keyed on the typed (symlinked) path", () => {
81
+ const { resolver, cleanup } = makeResolver({
82
+ permission: {
83
+ external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
84
+ },
85
+ });
86
+ try {
87
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
88
+ expect(isGateDescriptor(result)).toBe(true);
89
+ expect((result as GateDescriptor).preCheck?.state).toBe("allow");
90
+ } finally {
91
+ cleanup();
92
+ }
93
+ });
94
+
95
+ it("allows a path-bearing tool when the allow is keyed on the resolved path", () => {
96
+ // Key the allow on the fully symlink-resolved directory (on macOS the
97
+ // tmpdir root itself is a symlink, e.g. /var -> /private/var).
98
+ const resolved = realpathSync(realDir);
99
+ const { resolver, cleanup } = makeResolver({
100
+ permission: {
101
+ external_directory: { "*": "ask", [`${resolved}/*`]: "allow" },
102
+ },
103
+ });
104
+ try {
105
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
106
+ expect(isGateDescriptor(result)).toBe(true);
107
+ expect((result as GateDescriptor).preCheck?.state).toBe("allow");
108
+ } finally {
109
+ cleanup();
110
+ }
111
+ });
112
+
113
+ it("still prompts (ask) when no external_directory allow matches", () => {
114
+ const { resolver, cleanup } = makeResolver({
115
+ permission: { external_directory: { "*": "ask" } },
116
+ });
117
+ try {
118
+ const result = describeExternalDirectoryGate(readTcc(), [], resolver);
119
+ expect(isGateDescriptor(result)).toBe(true);
120
+ expect((result as GateDescriptor).preCheck?.state).toBe("ask");
121
+ } finally {
122
+ cleanup();
123
+ }
124
+ });
125
+
126
+ it("allows a bash command referencing the typed (symlinked) path", async () => {
127
+ const { resolver, cleanup } = makeResolver({
128
+ permission: {
129
+ external_directory: { "*": "ask", [`${linkDir}/*`]: "allow" },
130
+ },
131
+ });
132
+ try {
133
+ const command = `cat ${join(linkDir, "file.ts")}`;
134
+ const tcc: ToolCallContext = {
135
+ toolName: "bash",
136
+ agentName: null,
137
+ input: { command },
138
+ toolCallId: "tc-2",
139
+ cwd,
140
+ };
141
+ const program = await BashProgram.parse(command);
142
+ const result = describeBashExternalDirectoryGate(tcc, program, resolver);
143
+ // All external paths are covered by the allow → bypass, no prompt.
144
+ expect(isGateBypass(result)).toBe(true);
145
+ } finally {
146
+ cleanup();
147
+ }
148
+ });
149
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Metamorphic totality property for the bash command gate (#452, A3).
3
+ *
4
+ * Wrapping any `ask`/`deny` command in `cd /x && <cmd>` must not weaken the
5
+ * decision — the chain decomposition + most-restrictive-wins, combined with the
6
+ * fail-closed empty-parse fallback, guarantees a `cd …` prefix can never let a
7
+ * gated command ride a permissive top-level `*`.
8
+ *
9
+ * A focused parametrized table over the real tree-sitter parse + resolve, not a
10
+ * full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.
11
+ */
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
15
+ import { BashProgram } from "#src/handlers/gates/bash-program";
16
+ import type { ScopedPermissionResolver } from "#src/permission-resolver";
17
+ import type { PermissionState } from "#src/types";
18
+
19
+ import { makeCheckResult } from "#test/helpers/handler-fixtures";
20
+
21
+ /** Decision strength ordering: deny (2) > ask (1) > allow (0). */
22
+ const STRENGTH: Record<PermissionState, number> = {
23
+ allow: 0,
24
+ ask: 1,
25
+ deny: 2,
26
+ };
27
+
28
+ /**
29
+ * Resolver whose decision keys on a command substring → state map. A command
30
+ * matching no entry resolves to allow (the permissive top-level `*`).
31
+ */
32
+ function makeKeyedResolver(
33
+ rules: { match: string; state: PermissionState }[],
34
+ ): ScopedPermissionResolver {
35
+ return {
36
+ resolve: (_surface: string, input: { command?: string }) => {
37
+ const command = input.command ?? "";
38
+ const rule = rules.find((r) => command.includes(r.match));
39
+ const state: PermissionState = rule?.state ?? "allow";
40
+ return makeCheckResult({ state, source: "bash", command });
41
+ },
42
+ resolvePathPolicy: () => makeCheckResult(),
43
+ };
44
+ }
45
+
46
+ async function decide(
47
+ command: string,
48
+ resolver: ScopedPermissionResolver,
49
+ ): Promise<PermissionState> {
50
+ const program = await BashProgram.parse(command);
51
+ return resolveBashCommandCheck(
52
+ command,
53
+ program.commands(),
54
+ undefined,
55
+ resolver,
56
+ ).state;
57
+ }
58
+
59
+ describe("bash command gate — metamorphic totality", () => {
60
+ const cases: { bare: string; state: PermissionState }[] = [
61
+ { bare: "git push", state: "ask" },
62
+ { bare: "git commit -m wip", state: "ask" },
63
+ { bare: "rm -rf build", state: "deny" },
64
+ { bare: "npm install pkg", state: "deny" },
65
+ { bare: "gh pr create", state: "ask" },
66
+ ];
67
+
68
+ for (const { bare, state } of cases) {
69
+ it(`wrapping "${bare}" in a cd prefix does not weaken its ${state} decision`, async () => {
70
+ const resolver = makeKeyedResolver([
71
+ { match: bare.split(" ")[0] ?? bare, state },
72
+ ]);
73
+
74
+ const bareDecision = await decide(bare, resolver);
75
+ const wrappedDecision = await decide(`cd /repo && ${bare}`, resolver);
76
+
77
+ expect(STRENGTH[wrappedDecision]).toBeGreaterThanOrEqual(
78
+ STRENGTH[bareDecision],
79
+ );
80
+ expect(wrappedDecision).toBe(state);
81
+ });
82
+ }
83
+ });