@gotgenes/pi-permission-system 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +92 -35
  3. package/config/config.example.json +6 -0
  4. package/package.json +1 -1
  5. package/schemas/permissions.schema.json +114 -16
  6. package/src/active-agent.ts +58 -0
  7. package/src/config-loader.ts +398 -0
  8. package/src/config-paths.ts +34 -0
  9. package/src/config-reporter.ts +16 -8
  10. package/src/external-directory.ts +113 -0
  11. package/src/forwarded-permissions/io.ts +328 -0
  12. package/src/forwarded-permissions/polling.ts +334 -0
  13. package/src/index.ts +153 -1095
  14. package/src/permission-manager.ts +25 -111
  15. package/src/permission-prompts.ts +131 -0
  16. package/src/subagent-context.ts +52 -0
  17. package/src/tool-input-preview.ts +206 -0
  18. package/tests/active-agent.test.ts +160 -0
  19. package/tests/bash-filter.test.ts +137 -0
  20. package/tests/common.test.ts +189 -0
  21. package/tests/config-loader.test.ts +364 -0
  22. package/tests/config-paths.test.ts +78 -0
  23. package/tests/config-reporter.test.ts +42 -33
  24. package/tests/extension-config.test.ts +51 -0
  25. package/tests/external-directory.test.ts +250 -0
  26. package/tests/permission-prompts.test.ts +301 -0
  27. package/tests/permission-system.test.ts +9 -26
  28. package/tests/session-start.test.ts +8 -33
  29. package/tests/skill-prompt-sanitizer.test.ts +244 -0
  30. package/tests/subagent-context.test.ts +124 -0
  31. package/tests/system-prompt-sanitizer.test.ts +186 -0
  32. package/tests/tool-input-preview.test.ts +452 -0
  33. package/tests/tool-registry.test.ts +155 -0
  34. package/tests/wildcard-matcher.test.ts +180 -0
  35. package/tests/yolo-mode.test.ts +110 -0
@@ -0,0 +1,452 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ // Mock logging collaborator before importing the module under test.
4
+ vi.mock("../src/logging.js", () => ({
5
+ safeJsonStringify: vi.fn((value: unknown) => JSON.stringify(value)),
6
+ }));
7
+
8
+ import { safeJsonStringify } from "../src/logging.js";
9
+ import {
10
+ countTextLines,
11
+ formatCount,
12
+ formatEditInputForPrompt,
13
+ formatGenericToolInputForLog,
14
+ formatReadInputForPrompt,
15
+ formatSearchInputForPrompt,
16
+ formatToolInputForPrompt,
17
+ formatWriteInputForPrompt,
18
+ getPermissionLogContext,
19
+ getPromptPath,
20
+ getToolInputPreviewForLog,
21
+ sanitizeInlineText,
22
+ serializeToolInputPreview,
23
+ TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
24
+ TOOL_INPUT_PREVIEW_MAX_LENGTH,
25
+ TOOL_TEXT_SUMMARY_MAX_LENGTH,
26
+ truncateInlineText,
27
+ } from "../src/tool-input-preview.js";
28
+ import type { PermissionCheckResult } from "../src/types.js";
29
+
30
+ const mockedStringify = vi.mocked(safeJsonStringify);
31
+
32
+ afterEach(() => {
33
+ vi.clearAllMocks();
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ describe("constants", () => {
38
+ test("TOOL_INPUT_PREVIEW_MAX_LENGTH is 200", () => {
39
+ expect(TOOL_INPUT_PREVIEW_MAX_LENGTH).toBe(200);
40
+ });
41
+
42
+ test("TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH is 1000", () => {
43
+ expect(TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH).toBe(1000);
44
+ });
45
+
46
+ test("TOOL_TEXT_SUMMARY_MAX_LENGTH is 80", () => {
47
+ expect(TOOL_TEXT_SUMMARY_MAX_LENGTH).toBe(80);
48
+ });
49
+ });
50
+
51
+ describe("truncateInlineText", () => {
52
+ test("returns text unchanged when within maxLength", () => {
53
+ expect(truncateInlineText("hello", 10)).toBe("hello");
54
+ });
55
+
56
+ test("does not truncate when length equals maxLength", () => {
57
+ const text = "a".repeat(200);
58
+ expect(truncateInlineText(text, 200)).toBe(text);
59
+ });
60
+
61
+ test("truncates and appends ellipsis when length exceeds maxLength", () => {
62
+ const text = "a".repeat(201);
63
+ const result = truncateInlineText(text, 200);
64
+ expect(result).toBe(`${"a".repeat(200)}…`);
65
+ });
66
+
67
+ test("truncates long text and appends ellipsis", () => {
68
+ const result = truncateInlineText("abcdef", 3);
69
+ expect(result).toBe("abc…");
70
+ });
71
+ });
72
+
73
+ describe("sanitizeInlineText", () => {
74
+ test("collapses whitespace and trims", () => {
75
+ expect(sanitizeInlineText(" hello world ")).toBe("hello world");
76
+ });
77
+
78
+ test("returns 'empty text' for blank string", () => {
79
+ expect(sanitizeInlineText("")).toBe("empty text");
80
+ expect(sanitizeInlineText(" ")).toBe("empty text");
81
+ });
82
+
83
+ test("truncates to default TOOL_TEXT_SUMMARY_MAX_LENGTH", () => {
84
+ const long = "x".repeat(100);
85
+ const result = sanitizeInlineText(long);
86
+ expect(result.length).toBeLessThanOrEqual(TOOL_TEXT_SUMMARY_MAX_LENGTH + 1); // +1 for ellipsis char
87
+ expect(result).toContain("…");
88
+ });
89
+
90
+ test("respects custom maxLength", () => {
91
+ const result = sanitizeInlineText("hello world", 5);
92
+ expect(result).toBe("hello…");
93
+ });
94
+ });
95
+
96
+ describe("countTextLines", () => {
97
+ test("returns 0 for empty string", () => {
98
+ expect(countTextLines("")).toBe(0);
99
+ });
100
+
101
+ test("returns 1 for a single line with no newline", () => {
102
+ expect(countTextLines("hello")).toBe(1);
103
+ });
104
+
105
+ test("counts LF-separated lines", () => {
106
+ expect(countTextLines("line1\nline2\nline3")).toBe(3);
107
+ });
108
+
109
+ test("counts CRLF-separated lines", () => {
110
+ expect(countTextLines("line1\r\nline2")).toBe(2);
111
+ });
112
+
113
+ test("counts CR-separated lines", () => {
114
+ expect(countTextLines("line1\rline2")).toBe(2);
115
+ });
116
+ });
117
+
118
+ describe("formatCount", () => {
119
+ test("uses singular form for 1", () => {
120
+ expect(formatCount(1, "line", "lines")).toBe("1 line");
121
+ });
122
+
123
+ test("uses plural form for 0", () => {
124
+ expect(formatCount(0, "line", "lines")).toBe("0 lines");
125
+ });
126
+
127
+ test("uses plural form for 2+", () => {
128
+ expect(formatCount(3, "line", "lines")).toBe("3 lines");
129
+ });
130
+ });
131
+
132
+ describe("getPromptPath", () => {
133
+ test("returns path from 'path' key", () => {
134
+ expect(getPromptPath({ path: "/foo/bar" })).toBe("/foo/bar");
135
+ });
136
+
137
+ test("falls back to 'file_path' key", () => {
138
+ expect(getPromptPath({ file_path: "/baz" })).toBe("/baz");
139
+ });
140
+
141
+ test("returns null when neither key is present", () => {
142
+ expect(getPromptPath({})).toBeNull();
143
+ });
144
+
145
+ test("returns null when path is empty string", () => {
146
+ expect(getPromptPath({ path: "" })).toBeNull();
147
+ });
148
+ });
149
+
150
+ describe("formatEditInputForPrompt", () => {
151
+ test("returns path-only description when no edits provided", () => {
152
+ const result = formatEditInputForPrompt({ path: "/foo.ts" });
153
+ expect(result).toBe("for '/foo.ts' with edit input");
154
+ });
155
+
156
+ test("formats single replacement with line counts", () => {
157
+ const result = formatEditInputForPrompt({
158
+ path: "/foo.ts",
159
+ edits: [{ oldText: "line1\nline2", newText: "replaced" }],
160
+ });
161
+ expect(result).toContain("for '/foo.ts'");
162
+ expect(result).toContain("1 replacement");
163
+ expect(result).toContain("2 lines");
164
+ expect(result).toContain("1 line");
165
+ });
166
+
167
+ test("formats multiple replacements mentioning additional edits", () => {
168
+ const result = formatEditInputForPrompt({
169
+ path: "/foo.ts",
170
+ edits: [
171
+ { oldText: "a", newText: "b" },
172
+ { oldText: "c", newText: "d" },
173
+ { oldText: "e", newText: "f" },
174
+ ],
175
+ });
176
+ expect(result).toContain("3 replacements");
177
+ expect(result).toContain("2 additional edits");
178
+ });
179
+
180
+ test("falls back to oldText/newText when no edits array", () => {
181
+ const result = formatEditInputForPrompt({
182
+ path: "/bar.ts",
183
+ oldText: "old",
184
+ newText: "new",
185
+ });
186
+ expect(result).toContain("for '/bar.ts'");
187
+ expect(result).toContain("1 replacement");
188
+ });
189
+
190
+ test("works without a path", () => {
191
+ const result = formatEditInputForPrompt({
192
+ edits: [{ oldText: "x", newText: "y" }],
193
+ });
194
+ expect(result).not.toContain("for '");
195
+ expect(result).toContain("1 replacement");
196
+ });
197
+ });
198
+
199
+ describe("formatWriteInputForPrompt", () => {
200
+ test("includes path, line count, and character count", () => {
201
+ const result = formatWriteInputForPrompt({
202
+ path: "/out.ts",
203
+ content: "line1\nline2",
204
+ });
205
+ expect(result).toContain("for '/out.ts'");
206
+ expect(result).toContain("2 lines");
207
+ expect(result).toContain("11 characters");
208
+ });
209
+
210
+ test("handles missing content as empty", () => {
211
+ const result = formatWriteInputForPrompt({ path: "/out.ts" });
212
+ expect(result).toContain("0 lines");
213
+ expect(result).toContain("0 characters");
214
+ });
215
+ });
216
+
217
+ describe("formatReadInputForPrompt", () => {
218
+ test("includes path", () => {
219
+ expect(formatReadInputForPrompt({ path: "/src/foo.ts" })).toBe(
220
+ "for path '/src/foo.ts'",
221
+ );
222
+ });
223
+
224
+ test("includes offset and limit when present", () => {
225
+ const result = formatReadInputForPrompt({
226
+ path: "/x",
227
+ offset: 10,
228
+ limit: 50,
229
+ });
230
+ expect(result).toContain("offset 10");
231
+ expect(result).toContain("limit 50");
232
+ });
233
+
234
+ test("returns empty string when no path and no options", () => {
235
+ expect(formatReadInputForPrompt({})).toBe("");
236
+ });
237
+ });
238
+
239
+ describe("formatSearchInputForPrompt", () => {
240
+ test("includes pattern and path", () => {
241
+ const result = formatSearchInputForPrompt("grep", {
242
+ pattern: "TODO",
243
+ path: "/src",
244
+ });
245
+ expect(result).toContain("pattern 'TODO'");
246
+ expect(result).toContain("path '/src'");
247
+ });
248
+
249
+ test("includes glob when present", () => {
250
+ const result = formatSearchInputForPrompt("find", { glob: "*.ts" });
251
+ expect(result).toContain("glob '*.ts'");
252
+ });
253
+
254
+ test("uses 'current working directory' for find/grep/ls without path", () => {
255
+ for (const toolName of ["find", "grep", "ls"]) {
256
+ const result = formatSearchInputForPrompt(toolName, {});
257
+ expect(result).toContain("current working directory");
258
+ }
259
+ });
260
+
261
+ test("returns empty string for other tools with no input", () => {
262
+ expect(formatSearchInputForPrompt("other", {})).toBe("");
263
+ });
264
+ });
265
+
266
+ describe("serializeToolInputPreview", () => {
267
+ test("delegates serialization to safeJsonStringify", () => {
268
+ mockedStringify.mockReturnValue('{"key":"value"}');
269
+ const result = serializeToolInputPreview({ key: "value" });
270
+ expect(mockedStringify).toHaveBeenCalledWith({ key: "value" });
271
+ expect(result).toBe('{"key":"value"}');
272
+ });
273
+
274
+ test("returns empty string when safeJsonStringify returns undefined", () => {
275
+ mockedStringify.mockReturnValue(undefined);
276
+ expect(serializeToolInputPreview({})).toBe("");
277
+ });
278
+
279
+ test("returns empty string when serialized value is '{}'", () => {
280
+ mockedStringify.mockReturnValue("{}");
281
+ expect(serializeToolInputPreview({})).toBe("");
282
+ });
283
+
284
+ test("returns empty string when serialized value is 'null'", () => {
285
+ mockedStringify.mockReturnValue("null");
286
+ expect(serializeToolInputPreview(null)).toBe("");
287
+ });
288
+
289
+ test("collapses whitespace in serialized output", () => {
290
+ mockedStringify.mockReturnValue('{\n "key": "val"\n}');
291
+ const result = serializeToolInputPreview({});
292
+ expect(result).toBe('{ "key": "val" }');
293
+ });
294
+ });
295
+
296
+ describe("formatToolInputForPrompt", () => {
297
+ test("dispatches 'edit' to formatEditInputForPrompt", () => {
298
+ mockedStringify.mockReturnValue(undefined);
299
+ const result = formatToolInputForPrompt("edit", {
300
+ path: "/foo.ts",
301
+ edits: [],
302
+ });
303
+ expect(result).toContain("for '/foo.ts'");
304
+ });
305
+
306
+ test("dispatches 'write' to formatWriteInputForPrompt", () => {
307
+ const result = formatToolInputForPrompt("write", {
308
+ path: "/out.ts",
309
+ content: "hi",
310
+ });
311
+ expect(result).toContain("for '/out.ts'");
312
+ });
313
+
314
+ test("dispatches 'read' to formatReadInputForPrompt", () => {
315
+ const result = formatToolInputForPrompt("read", { path: "/src/x.ts" });
316
+ expect(result).toContain("path '/src/x.ts'");
317
+ });
318
+
319
+ test("dispatches 'find'/'grep'/'ls' to formatSearchInputForPrompt", () => {
320
+ for (const tool of ["find", "grep", "ls"]) {
321
+ const result = formatToolInputForPrompt(tool, {});
322
+ expect(result).toContain("current working directory");
323
+ }
324
+ });
325
+
326
+ test("falls back to JSON preview for unknown tools", () => {
327
+ mockedStringify.mockReturnValue('{"x":1}');
328
+ const result = formatToolInputForPrompt("unknown", { x: 1 });
329
+ expect(result).toContain('{"x":1}');
330
+ });
331
+ });
332
+
333
+ describe("formatGenericToolInputForLog", () => {
334
+ test("returns undefined when serialization yields empty string", () => {
335
+ mockedStringify.mockReturnValue(undefined);
336
+ expect(formatGenericToolInputForLog({})).toBeUndefined();
337
+ });
338
+
339
+ test("returns prefixed input preview", () => {
340
+ mockedStringify.mockReturnValue('{"k":"v"}');
341
+ expect(formatGenericToolInputForLog({ k: "v" })).toBe('input {"k":"v"}');
342
+ });
343
+
344
+ test("truncates to TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH", () => {
345
+ const longJson = `{"k":"${"x".repeat(2000)}"}`;
346
+ mockedStringify.mockReturnValue(longJson);
347
+ const result = formatGenericToolInputForLog({});
348
+ expect(result).toBeDefined();
349
+ // result is "input " + truncated, so total > TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH by "input ".length
350
+ const preview = result!.slice("input ".length);
351
+ expect(preview.length).toBeLessThanOrEqual(
352
+ TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH + 1,
353
+ );
354
+ });
355
+ });
356
+
357
+ describe("getToolInputPreviewForLog", () => {
358
+ const pathBearingTools = new Set(["read", "write", "edit"]);
359
+
360
+ test("returns undefined for bash tool", () => {
361
+ const result: PermissionCheckResult = {
362
+ toolName: "bash",
363
+ state: "allow",
364
+ source: "tool",
365
+ };
366
+ expect(
367
+ getToolInputPreviewForLog(result, { command: "ls" }, pathBearingTools),
368
+ ).toBeUndefined();
369
+ });
370
+
371
+ test("returns undefined for mcp tool", () => {
372
+ const result: PermissionCheckResult = {
373
+ toolName: "mcp",
374
+ state: "allow",
375
+ source: "tool",
376
+ };
377
+ expect(
378
+ getToolInputPreviewForLog(result, {}, pathBearingTools),
379
+ ).toBeUndefined();
380
+ });
381
+
382
+ test("returns undefined for mcp source", () => {
383
+ const result: PermissionCheckResult = {
384
+ toolName: "some-server:some-tool",
385
+ state: "allow",
386
+ source: "mcp",
387
+ };
388
+ expect(
389
+ getToolInputPreviewForLog(result, {}, pathBearingTools),
390
+ ).toBeUndefined();
391
+ });
392
+
393
+ test("returns path-based preview for path-bearing tools", () => {
394
+ const result: PermissionCheckResult = {
395
+ toolName: "read",
396
+ state: "allow",
397
+ source: "tool",
398
+ };
399
+ const preview = getToolInputPreviewForLog(
400
+ result,
401
+ { path: "/src/foo.ts" },
402
+ pathBearingTools,
403
+ );
404
+ expect(preview).toContain("/src/foo.ts");
405
+ });
406
+
407
+ test("returns generic JSON preview for non-path-bearing tools", () => {
408
+ mockedStringify.mockReturnValue('{"n":1}');
409
+ const result: PermissionCheckResult = {
410
+ toolName: "task",
411
+ state: "allow",
412
+ source: "tool",
413
+ };
414
+ const preview = getToolInputPreviewForLog(
415
+ result,
416
+ { n: 1 },
417
+ pathBearingTools,
418
+ );
419
+ expect(preview).toContain('{"n":1}');
420
+ });
421
+ });
422
+
423
+ describe("getPermissionLogContext", () => {
424
+ const pathBearingTools = new Set(["read", "write", "edit"]);
425
+
426
+ test("returns command, target, and toolInputPreview", () => {
427
+ const result: PermissionCheckResult = {
428
+ toolName: "bash",
429
+ state: "allow",
430
+ source: "tool",
431
+ command: "ls -la",
432
+ };
433
+ const ctx = getPermissionLogContext(result, {}, pathBearingTools);
434
+ expect(ctx.command).toBe("ls -la");
435
+ expect(ctx.target).toBeUndefined();
436
+ expect(ctx.toolInputPreview).toBeUndefined();
437
+ });
438
+
439
+ test("includes toolInputPreview for non-bash path-bearing tools", () => {
440
+ const result: PermissionCheckResult = {
441
+ toolName: "read",
442
+ state: "allow",
443
+ source: "tool",
444
+ };
445
+ const ctx = getPermissionLogContext(
446
+ result,
447
+ { path: "/foo.ts" },
448
+ pathBearingTools,
449
+ );
450
+ expect(ctx.toolInputPreview).toContain("/foo.ts");
451
+ });
452
+ });
@@ -0,0 +1,155 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+
3
+ import {
4
+ checkRequestedToolRegistration,
5
+ getToolNameFromValue,
6
+ } from "../src/tool-registry.js";
7
+
8
+ afterEach(() => {
9
+ vi.restoreAllMocks();
10
+ });
11
+
12
+ describe("getToolNameFromValue", () => {
13
+ test("returns string value directly", () => {
14
+ expect(getToolNameFromValue("read")).toBe("read");
15
+ });
16
+
17
+ test("returns null for empty string", () => {
18
+ expect(getToolNameFromValue("")).toBeNull();
19
+ });
20
+
21
+ test("returns null for whitespace-only string", () => {
22
+ expect(getToolNameFromValue(" ")).toBeNull();
23
+ });
24
+
25
+ test("returns null for null", () => {
26
+ expect(getToolNameFromValue(null)).toBeNull();
27
+ });
28
+
29
+ test("returns null for undefined", () => {
30
+ expect(getToolNameFromValue(undefined)).toBeNull();
31
+ });
32
+
33
+ test("extracts toolName from object", () => {
34
+ expect(getToolNameFromValue({ toolName: "write" })).toBe("write");
35
+ });
36
+
37
+ test("extracts name from object", () => {
38
+ expect(getToolNameFromValue({ name: "edit" })).toBe("edit");
39
+ });
40
+
41
+ test("extracts tool from object", () => {
42
+ expect(getToolNameFromValue({ tool: "bash" })).toBe("bash");
43
+ });
44
+
45
+ test("prefers toolName over name over tool", () => {
46
+ expect(
47
+ getToolNameFromValue({
48
+ toolName: "first",
49
+ name: "second",
50
+ tool: "third",
51
+ }),
52
+ ).toBe("first");
53
+ });
54
+
55
+ test("falls back to name when toolName is empty", () => {
56
+ expect(getToolNameFromValue({ toolName: "", name: "edit" })).toBe("edit");
57
+ });
58
+
59
+ test("returns null for object with no recognised keys", () => {
60
+ expect(getToolNameFromValue({ unknown: "read" })).toBeNull();
61
+ });
62
+
63
+ test("returns null for number input", () => {
64
+ expect(getToolNameFromValue(42)).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("checkRequestedToolRegistration", () => {
69
+ test("returns missing-tool-name for null requested name", () => {
70
+ const result = checkRequestedToolRegistration(null, []);
71
+ expect(result.status).toBe("missing-tool-name");
72
+ });
73
+
74
+ test("returns missing-tool-name for whitespace-only requested name", () => {
75
+ const result = checkRequestedToolRegistration(" ", []);
76
+ expect(result.status).toBe("missing-tool-name");
77
+ });
78
+
79
+ test("returns registered when tool name matches a string entry", () => {
80
+ const result = checkRequestedToolRegistration("read", ["read", "write"]);
81
+ expect(result.status).toBe("registered");
82
+ if (result.status === "registered") {
83
+ expect(result.requestedToolName).toBe("read");
84
+ expect(result.normalizedToolName).toBe("read");
85
+ }
86
+ });
87
+
88
+ test("returns registered when tool name matches an object entry by name", () => {
89
+ const result = checkRequestedToolRegistration("edit", [{ name: "edit" }]);
90
+ expect(result.status).toBe("registered");
91
+ });
92
+
93
+ test("returns registered when tool name matches an object entry by toolName", () => {
94
+ const result = checkRequestedToolRegistration("bash", [
95
+ { toolName: "bash" },
96
+ ]);
97
+ expect(result.status).toBe("registered");
98
+ });
99
+
100
+ test("returns unregistered when tool is not in the list", () => {
101
+ const result = checkRequestedToolRegistration("ghost", ["read", "write"]);
102
+ expect(result.status).toBe("unregistered");
103
+ if (result.status === "unregistered") {
104
+ expect(result.requestedToolName).toBe("ghost");
105
+ expect(result.availableToolNames).toContain("read");
106
+ expect(result.availableToolNames).toContain("write");
107
+ }
108
+ });
109
+
110
+ test("available tool names are sorted alphabetically", () => {
111
+ const result = checkRequestedToolRegistration("ghost", [
112
+ "write",
113
+ "read",
114
+ "edit",
115
+ ]);
116
+ if (result.status === "unregistered") {
117
+ expect(result.availableToolNames).toEqual(["edit", "read", "write"]);
118
+ }
119
+ });
120
+
121
+ test("resolves alias: requested alias maps to registered canonical name", () => {
122
+ const aliases = { Execute: "bash" };
123
+ const result = checkRequestedToolRegistration("Execute", ["bash"], aliases);
124
+ expect(result.status).toBe("registered");
125
+ if (result.status === "registered") {
126
+ expect(result.normalizedToolName).toBe("bash");
127
+ }
128
+ });
129
+
130
+ test("resolves alias: registered canonical is found via reverse alias lookup", () => {
131
+ // "bash" is registered; alias maps "Execute" → "bash"
132
+ // requesting "bash" directly should still resolve via the alias table
133
+ const aliases = { Execute: "bash" };
134
+ const result = checkRequestedToolRegistration("bash", ["bash"], aliases);
135
+ expect(result.status).toBe("registered");
136
+ });
137
+
138
+ test("returns unregistered with empty availableToolNames for empty tool list", () => {
139
+ const result = checkRequestedToolRegistration("read", []);
140
+ expect(result.status).toBe("unregistered");
141
+ if (result.status === "unregistered") {
142
+ expect(result.availableToolNames).toEqual([]);
143
+ }
144
+ });
145
+
146
+ test("skips tool list entries that yield no name", () => {
147
+ const result = checkRequestedToolRegistration("read", [
148
+ null,
149
+ {},
150
+ { unrelated: "x" },
151
+ "read",
152
+ ]);
153
+ expect(result.status).toBe("registered");
154
+ });
155
+ });