@gotgenes/pi-permission-system 5.6.1 → 5.6.3
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.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/bash-path-extractor.ts +518 -0
- package/src/config-loader.ts +1 -29
- package/src/external-directory-messages.ts +54 -0
- package/src/external-directory.ts +24 -797
- package/src/handlers/gates/external-directory.ts +1 -1
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/node-modules-discovery.ts +76 -0
- package/src/path-utils.ts +155 -0
- package/src/permission-manager.ts +1 -29
- package/src/permission-merge.ts +30 -0
- package/src/skill-prompt-sanitizer.ts +5 -39
- package/tests/external-directory-messages.test.ts +137 -0
- package/tests/node-modules-discovery.test.ts +97 -0
- package/tests/{external-directory.test.ts → path-utils.test.ts} +124 -256
- package/tests/permission-merge.test.ts +61 -0
|
@@ -1,23 +1,5 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
// Hoisted stubs for mocks that reference them in vi.mock factories.
|
|
5
|
-
const { mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
|
|
6
|
-
mockSpawnSync: vi.fn(),
|
|
7
|
-
mockExistsSync: vi.fn(),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
// Mock node:child_process so tests don't spawn real subprocesses.
|
|
11
|
-
vi.mock("node:child_process", () => ({
|
|
12
|
-
spawnSync: mockSpawnSync,
|
|
13
|
-
default: { spawnSync: mockSpawnSync },
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
// Mock node:fs so existsSync is controllable.
|
|
17
|
-
vi.mock("node:fs", () => ({
|
|
18
|
-
existsSync: mockExistsSync,
|
|
19
|
-
default: { existsSync: mockExistsSync },
|
|
20
|
-
}));
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
21
3
|
|
|
22
4
|
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
23
5
|
vi.mock("node:os", () => {
|
|
@@ -29,79 +11,16 @@ vi.mock("node:os", () => {
|
|
|
29
11
|
});
|
|
30
12
|
|
|
31
13
|
import {
|
|
32
|
-
discoverGlobalNodeModulesRoot,
|
|
33
|
-
formatExternalDirectoryAskPrompt,
|
|
34
|
-
formatExternalDirectoryDenyReason,
|
|
35
|
-
formatExternalDirectoryHardStopHint,
|
|
36
|
-
formatExternalDirectoryUserDeniedReason,
|
|
37
14
|
getPathBearingToolPath,
|
|
38
15
|
isPathOutsideWorkingDirectory,
|
|
39
16
|
isPathWithinDirectory,
|
|
17
|
+
isPiInfrastructureRead,
|
|
40
18
|
isSafeSystemPath,
|
|
41
19
|
normalizePathForComparison,
|
|
42
20
|
PATH_BEARING_TOOLS,
|
|
21
|
+
READ_ONLY_PATH_BEARING_TOOLS,
|
|
43
22
|
SAFE_SYSTEM_PATHS,
|
|
44
|
-
} from "../src/
|
|
45
|
-
|
|
46
|
-
afterEach(() => {
|
|
47
|
-
vi.restoreAllMocks();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe("PATH_BEARING_TOOLS", () => {
|
|
51
|
-
test("contains the expected tool names", () => {
|
|
52
|
-
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
|
|
53
|
-
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("does not contain bash or mcp", () => {
|
|
58
|
-
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
|
|
59
|
-
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
describe("SAFE_SYSTEM_PATHS", () => {
|
|
64
|
-
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
|
|
65
|
-
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
|
|
66
|
-
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
|
|
67
|
-
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
|
|
68
|
-
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe("isSafeSystemPath", () => {
|
|
73
|
-
test("returns true for /dev/null", () => {
|
|
74
|
-
expect(isSafeSystemPath("/dev/null")).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("returns true for /dev/stdin", () => {
|
|
78
|
-
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("returns true for /dev/stdout", () => {
|
|
82
|
-
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("returns true for /dev/stderr", () => {
|
|
86
|
-
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("returns false for an arbitrary absolute path", () => {
|
|
90
|
-
expect(isSafeSystemPath("/etc/passwd")).toBe(false);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("returns false for a path prefixed with a safe system path", () => {
|
|
94
|
-
expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test("returns false for an empty string", () => {
|
|
98
|
-
expect(isSafeSystemPath("")).toBe(false);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("returns false for a relative path", () => {
|
|
102
|
-
expect(isSafeSystemPath("dev/null")).toBe(false);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
23
|
+
} from "../src/path-utils";
|
|
105
24
|
|
|
106
25
|
describe("normalizePathForComparison", () => {
|
|
107
26
|
const cwd = "/projects/my-app";
|
|
@@ -179,6 +98,75 @@ describe("isPathWithinDirectory", () => {
|
|
|
179
98
|
});
|
|
180
99
|
});
|
|
181
100
|
|
|
101
|
+
describe("PATH_BEARING_TOOLS", () => {
|
|
102
|
+
test("contains the expected tool names", () => {
|
|
103
|
+
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
|
|
104
|
+
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("does not contain bash or mcp", () => {
|
|
109
|
+
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
|
|
110
|
+
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("READ_ONLY_PATH_BEARING_TOOLS", () => {
|
|
115
|
+
test("contains read, find, grep, ls", () => {
|
|
116
|
+
for (const tool of ["read", "find", "grep", "ls"]) {
|
|
117
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("does not contain write or edit", () => {
|
|
122
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has("write")).toBe(false);
|
|
123
|
+
expect(READ_ONLY_PATH_BEARING_TOOLS.has("edit")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("SAFE_SYSTEM_PATHS", () => {
|
|
128
|
+
test("contains /dev/null, /dev/stdin, /dev/stdout, /dev/stderr", () => {
|
|
129
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/null")).toBe(true);
|
|
130
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdin")).toBe(true);
|
|
131
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stdout")).toBe(true);
|
|
132
|
+
expect(SAFE_SYSTEM_PATHS.has("/dev/stderr")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("isSafeSystemPath", () => {
|
|
137
|
+
test("returns true for /dev/null", () => {
|
|
138
|
+
expect(isSafeSystemPath("/dev/null")).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("returns true for /dev/stdin", () => {
|
|
142
|
+
expect(isSafeSystemPath("/dev/stdin")).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("returns true for /dev/stdout", () => {
|
|
146
|
+
expect(isSafeSystemPath("/dev/stdout")).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("returns true for /dev/stderr", () => {
|
|
150
|
+
expect(isSafeSystemPath("/dev/stderr")).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("returns false for an arbitrary absolute path", () => {
|
|
154
|
+
expect(isSafeSystemPath("/etc/passwd")).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("returns false for a path prefixed with a safe system path", () => {
|
|
158
|
+
expect(isSafeSystemPath("/dev/null/subdir")).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("returns false for an empty string", () => {
|
|
162
|
+
expect(isSafeSystemPath("")).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("returns false for a relative path", () => {
|
|
166
|
+
expect(isSafeSystemPath("dev/null")).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
182
170
|
describe("getPathBearingToolPath", () => {
|
|
183
171
|
test("returns path for a path-bearing tool", () => {
|
|
184
172
|
expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
|
|
@@ -249,177 +237,57 @@ describe("isPathOutsideWorkingDirectory", () => {
|
|
|
249
237
|
});
|
|
250
238
|
});
|
|
251
239
|
|
|
252
|
-
describe("
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
expect(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
"read",
|
|
304
|
-
"/etc",
|
|
305
|
-
"/projects",
|
|
306
|
-
);
|
|
307
|
-
expect(result).toContain("Current agent");
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
describe("formatExternalDirectoryUserDeniedReason", () => {
|
|
312
|
-
test("includes tool name and path", () => {
|
|
313
|
-
const result = formatExternalDirectoryUserDeniedReason(
|
|
314
|
-
"edit",
|
|
315
|
-
"/etc/hosts",
|
|
316
|
-
);
|
|
317
|
-
expect(result).toContain("edit");
|
|
318
|
-
expect(result).toContain("/etc/hosts");
|
|
319
|
-
expect(result).toContain("Hard stop");
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
test("appends denial reason when provided", () => {
|
|
323
|
-
const result = formatExternalDirectoryUserDeniedReason(
|
|
324
|
-
"edit",
|
|
325
|
-
"/etc/hosts",
|
|
326
|
-
"too risky",
|
|
327
|
-
);
|
|
328
|
-
expect(result).toContain("Reason: too risky");
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
test("omits reason suffix when not provided", () => {
|
|
332
|
-
const result = formatExternalDirectoryUserDeniedReason(
|
|
333
|
-
"edit",
|
|
334
|
-
"/etc/hosts",
|
|
335
|
-
);
|
|
336
|
-
expect(result).not.toContain("Reason:");
|
|
337
|
-
});
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
describe("discoverGlobalNodeModulesRoot", () => {
|
|
341
|
-
// The walk-up-from-self strategy uses import.meta.url which resolves to a
|
|
342
|
-
// path inside the source tree during tests — there is no node_modules
|
|
343
|
-
// ancestor. So the fallback path is exercised naturally here.
|
|
344
|
-
//
|
|
345
|
-
// For the "walk-up succeeds" case, we verify the subprocess is NOT called
|
|
346
|
-
// by confirming spawnSync call count stays at zero when the URL has a
|
|
347
|
-
// node_modules ancestor.
|
|
348
|
-
|
|
349
|
-
beforeEach(() => {
|
|
350
|
-
mockSpawnSync.mockReset();
|
|
351
|
-
mockExistsSync.mockReset();
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
test("returns node_modules root when URL is inside a node_modules tree", () => {
|
|
355
|
-
// Simulate a URL whose file path contains a node_modules ancestor.
|
|
356
|
-
const fakeUrl =
|
|
357
|
-
"file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
|
|
358
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
359
|
-
expect(result).toBe("/opt/homebrew/lib/node_modules");
|
|
360
|
-
// Subprocess should NOT have been invoked — walk-up succeeds.
|
|
361
|
-
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
362
|
-
});
|
|
363
|
-
|
|
364
|
-
test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
|
|
365
|
-
const npmRootPath = "/opt/homebrew/lib/node_modules";
|
|
366
|
-
mockSpawnSync.mockReturnValue({
|
|
367
|
-
status: 0,
|
|
368
|
-
stdout: `${npmRootPath}\n`,
|
|
369
|
-
});
|
|
370
|
-
mockExistsSync.mockReturnValue(true);
|
|
371
|
-
|
|
372
|
-
// Use a file URL with no node_modules ancestor.
|
|
373
|
-
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
374
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
375
|
-
|
|
376
|
-
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
377
|
-
"npm",
|
|
378
|
-
["root", "-g"],
|
|
379
|
-
expect.objectContaining({ encoding: "utf-8" }),
|
|
240
|
+
describe("isPiInfrastructureRead", () => {
|
|
241
|
+
const cwd = "/projects/my-app";
|
|
242
|
+
const infraDirs = ["/mock/home/.pi/agent"];
|
|
243
|
+
|
|
244
|
+
test("returns true for read-only tool reading from infra dir", () => {
|
|
245
|
+
expect(
|
|
246
|
+
isPiInfrastructureRead(
|
|
247
|
+
"read",
|
|
248
|
+
"/mock/home/.pi/agent/config.json",
|
|
249
|
+
infraDirs,
|
|
250
|
+
cwd,
|
|
251
|
+
),
|
|
252
|
+
).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("returns false for write tool even in infra dir", () => {
|
|
256
|
+
expect(
|
|
257
|
+
isPiInfrastructureRead(
|
|
258
|
+
"write",
|
|
259
|
+
"/mock/home/.pi/agent/config.json",
|
|
260
|
+
infraDirs,
|
|
261
|
+
cwd,
|
|
262
|
+
),
|
|
263
|
+
).toBe(false);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("returns true for read-only tool reading from project .pi/npm", () => {
|
|
267
|
+
expect(
|
|
268
|
+
isPiInfrastructureRead(
|
|
269
|
+
"read",
|
|
270
|
+
"/projects/my-app/.pi/npm/package.json",
|
|
271
|
+
[],
|
|
272
|
+
cwd,
|
|
273
|
+
),
|
|
274
|
+
).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("returns true for read-only tool reading from project .pi/git", () => {
|
|
278
|
+
expect(
|
|
279
|
+
isPiInfrastructureRead(
|
|
280
|
+
"grep",
|
|
281
|
+
"/projects/my-app/.pi/git/some-file",
|
|
282
|
+
[],
|
|
283
|
+
cwd,
|
|
284
|
+
),
|
|
285
|
+
).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("returns false for path outside all infra dirs and project dirs", () => {
|
|
289
|
+
expect(isPiInfrastructureRead("read", "/etc/passwd", infraDirs, cwd)).toBe(
|
|
290
|
+
false,
|
|
380
291
|
);
|
|
381
|
-
expect(result).toBe(npmRootPath);
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
|
|
385
|
-
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
|
386
|
-
|
|
387
|
-
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
388
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
389
|
-
|
|
390
|
-
expect(result).toBeNull();
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
test("returns null when walk-up fails and spawnSync throws", () => {
|
|
394
|
-
mockSpawnSync.mockImplementation(() => {
|
|
395
|
-
throw new Error("ENOENT");
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
399
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
400
|
-
|
|
401
|
-
expect(result).toBeNull();
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
|
|
405
|
-
mockSpawnSync.mockReturnValue({
|
|
406
|
-
status: 0,
|
|
407
|
-
stdout: "/some/nonexistent/node_modules\n",
|
|
408
|
-
});
|
|
409
|
-
mockExistsSync.mockReturnValue(false);
|
|
410
|
-
|
|
411
|
-
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
412
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
413
|
-
|
|
414
|
-
expect(result).toBeNull();
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
|
|
418
|
-
mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
|
|
419
|
-
|
|
420
|
-
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
421
|
-
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
422
|
-
|
|
423
|
-
expect(result).toBeNull();
|
|
424
292
|
});
|
|
425
293
|
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { mergeFlatPermissions } from "../src/permission-merge";
|
|
4
|
+
|
|
5
|
+
describe("mergeFlatPermissions", () => {
|
|
6
|
+
test("string replaces string", () => {
|
|
7
|
+
const result = mergeFlatPermissions({ tools: "ask" }, { tools: "allow" });
|
|
8
|
+
expect(result).toEqual({ tools: "allow" });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("both objects → shallow-merge pattern maps", () => {
|
|
12
|
+
const result = mergeFlatPermissions(
|
|
13
|
+
{ bash: { "rm *": "deny", "git *": "ask" } },
|
|
14
|
+
{ bash: { "rm *": "allow", "npm *": "allow" } },
|
|
15
|
+
);
|
|
16
|
+
expect(result).toEqual({
|
|
17
|
+
bash: { "rm *": "allow", "git *": "ask", "npm *": "allow" },
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("object replaces string", () => {
|
|
22
|
+
const result = mergeFlatPermissions(
|
|
23
|
+
{ tools: "ask" },
|
|
24
|
+
{ tools: { Write: "deny" } },
|
|
25
|
+
);
|
|
26
|
+
expect(result).toEqual({ tools: { Write: "deny" } });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("string replaces object", () => {
|
|
30
|
+
const result = mergeFlatPermissions(
|
|
31
|
+
{ tools: { Write: "deny" } },
|
|
32
|
+
{ tools: "allow" },
|
|
33
|
+
);
|
|
34
|
+
expect(result).toEqual({ tools: "allow" });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("empty override returns base unchanged", () => {
|
|
38
|
+
const base = { tools: "ask" as const, bash: { "rm *": "deny" as const } };
|
|
39
|
+
const result = mergeFlatPermissions(base, {});
|
|
40
|
+
expect(result).toEqual(base);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("empty base returns override", () => {
|
|
44
|
+
const override = { tools: "allow" as const };
|
|
45
|
+
const result = mergeFlatPermissions({}, override);
|
|
46
|
+
expect(result).toEqual(override);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("preserves keys only in base", () => {
|
|
50
|
+
const result = mergeFlatPermissions(
|
|
51
|
+
{ tools: "ask", bash: "deny" },
|
|
52
|
+
{ tools: "allow" },
|
|
53
|
+
);
|
|
54
|
+
expect(result).toEqual({ tools: "allow", bash: "deny" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("adds keys only in override", () => {
|
|
58
|
+
const result = mergeFlatPermissions({ tools: "ask" }, { bash: "allow" });
|
|
59
|
+
expect(result).toEqual({ tools: "ask", bash: "allow" });
|
|
60
|
+
});
|
|
61
|
+
});
|