@gotgenes/pi-permission-system 5.16.0 → 5.18.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.
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { GateDescriptor } from "../../../src/handlers/gates/descriptor";
4
+ import { isGateDescriptor } from "../../../src/handlers/gates/descriptor";
5
+ import { describePathGate } from "../../../src/handlers/gates/path";
6
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
7
+ import type { PermissionCheckResult } from "../../../src/types";
8
+
9
+ // ── helpers ────────────────────────────────────────────────────────────────
10
+
11
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
12
+ return {
13
+ toolName: "read",
14
+ agentName: null,
15
+ input: { path: ".env" },
16
+ toolCallId: "tc-1",
17
+ cwd: "/test/project",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ function makeCheckResult(
23
+ overrides: Partial<PermissionCheckResult> = {},
24
+ ): PermissionCheckResult {
25
+ return {
26
+ toolName: "path",
27
+ state: "allow",
28
+ source: "special",
29
+ origin: "global",
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ type CheckPermissionFn = (
35
+ surface: string,
36
+ input: unknown,
37
+ agentName?: string,
38
+ sessionRules?: unknown[],
39
+ ) => PermissionCheckResult;
40
+
41
+ // ── tests ──────────────────────────────────────────────────────────────────
42
+
43
+ describe("describePathGate", () => {
44
+ it("returns null for non-path-bearing tools", () => {
45
+ const checkPermission = vi.fn<CheckPermissionFn>();
46
+ const result = describePathGate(
47
+ makeTcc({ toolName: "bash", input: { command: "ls" } }),
48
+ checkPermission,
49
+ );
50
+ expect(result).toBeNull();
51
+ expect(checkPermission).not.toHaveBeenCalled();
52
+ });
53
+
54
+ it("returns null when tool has no extractable path", () => {
55
+ const checkPermission = vi.fn<CheckPermissionFn>();
56
+ const result = describePathGate(
57
+ makeTcc({ toolName: "read", input: {} }),
58
+ checkPermission,
59
+ );
60
+ expect(result).toBeNull();
61
+ });
62
+
63
+ it("returns null when path check result is allow", () => {
64
+ const checkPermission = vi
65
+ .fn<CheckPermissionFn>()
66
+ .mockReturnValue(makeCheckResult({ state: "allow" }));
67
+ const result = describePathGate(makeTcc(), checkPermission);
68
+ expect(result).toBeNull();
69
+ });
70
+
71
+ it("returns GateDescriptor when path check result is deny", () => {
72
+ const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
73
+ makeCheckResult({
74
+ state: "deny",
75
+ matchedPattern: "*.env",
76
+ }),
77
+ );
78
+ const result = describePathGate(makeTcc(), checkPermission);
79
+ expect(result).not.toBeNull();
80
+ expect(isGateDescriptor(result)).toBe(true);
81
+ const desc = result as GateDescriptor;
82
+ expect(desc.surface).toBe("path");
83
+ expect(desc.preCheck?.state).toBe("deny");
84
+ });
85
+
86
+ it("returns GateDescriptor when path check result is ask", () => {
87
+ const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
88
+ makeCheckResult({
89
+ state: "ask",
90
+ matchedPattern: "*.env",
91
+ }),
92
+ );
93
+ const result = describePathGate(makeTcc(), checkPermission);
94
+ expect(result).not.toBeNull();
95
+ expect(isGateDescriptor(result)).toBe(true);
96
+ const desc = result as GateDescriptor;
97
+ expect(desc.surface).toBe("path");
98
+ expect(desc.preCheck?.state).toBe("ask");
99
+ });
100
+
101
+ it("descriptor has correct session approval surface and pattern", () => {
102
+ const checkPermission = vi
103
+ .fn<CheckPermissionFn>()
104
+ .mockReturnValue(makeCheckResult({ state: "ask" }));
105
+ const result = describePathGate(
106
+ makeTcc({ input: { path: "/test/project/src/.env" } }),
107
+ checkPermission,
108
+ ) as GateDescriptor;
109
+ expect(result.sessionApproval).toBeDefined();
110
+ expect(result.sessionApproval).toHaveProperty("surface", "path");
111
+ expect(result.sessionApproval).toHaveProperty("pattern");
112
+ });
113
+
114
+ it("descriptor messages reference the file path", () => {
115
+ const checkPermission = vi
116
+ .fn<CheckPermissionFn>()
117
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
118
+ const result = describePathGate(
119
+ makeTcc(),
120
+ checkPermission,
121
+ ) as GateDescriptor;
122
+ expect(result.messages.denyReason).toContain(".env");
123
+ expect(result.messages.unavailableReason).toContain(".env");
124
+ });
125
+
126
+ it("descriptor decision uses surface 'path' and the file path as value", () => {
127
+ const checkPermission = vi
128
+ .fn<CheckPermissionFn>()
129
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
130
+ const result = describePathGate(
131
+ makeTcc(),
132
+ checkPermission,
133
+ ) as GateDescriptor;
134
+ expect(result.decision.surface).toBe("path");
135
+ expect(result.decision.value).toBe(".env");
136
+ });
137
+
138
+ it("passes agentName to checkPermission", () => {
139
+ const checkPermission = vi
140
+ .fn<CheckPermissionFn>()
141
+ .mockReturnValue(makeCheckResult({ state: "allow" }));
142
+ describePathGate(makeTcc({ agentName: "my-agent" }), checkPermission);
143
+ expect(checkPermission).toHaveBeenCalledWith(
144
+ "path",
145
+ { path: ".env" },
146
+ "my-agent",
147
+ );
148
+ });
149
+ });
@@ -297,3 +297,81 @@ describe("handleToolCall — bash external-directory gate", () => {
297
297
  expect(result).toMatchObject({ block: true });
298
298
  });
299
299
  });
300
+
301
+ // ── path gate (tools) ─────────────────────────────────────────────────────
302
+
303
+ describe("handleToolCall — path gate (tools)", () => {
304
+ it("blocks a read of .env when path surface denies *.env", async () => {
305
+ const checkPermission = vi
306
+ .fn()
307
+ .mockImplementation(
308
+ (surface: string, _input: unknown, _agentName?: string) => {
309
+ if (surface === "path") {
310
+ return makePermissionResult("deny");
311
+ }
312
+ return makePermissionResult("allow");
313
+ },
314
+ );
315
+ const { handler } = makeHandler({
316
+ session: { checkPermission },
317
+ toolRegistry: {
318
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
319
+ },
320
+ });
321
+ const event = {
322
+ type: "tool_call",
323
+ toolCallId: "tc-path",
324
+ name: "read",
325
+ input: { path: ".env" },
326
+ };
327
+ const result = await handler.handleToolCall(event, makeCtx());
328
+ expect(result).toMatchObject({ block: true });
329
+ });
330
+
331
+ it("allows a read when path surface allows", async () => {
332
+ const { handler } = makeHandler({
333
+ toolRegistry: {
334
+ getAll: vi.fn().mockReturnValue([{ name: "read" }]),
335
+ },
336
+ });
337
+ const event = {
338
+ type: "tool_call",
339
+ toolCallId: "tc-path-ok",
340
+ name: "read",
341
+ input: { path: "src/index.ts" },
342
+ };
343
+ const result = await handler.handleToolCall(event, makeCtx());
344
+ expect(result).toEqual({});
345
+ });
346
+ });
347
+
348
+ // ── bash path gate ────────────────────────────────────────────────────────
349
+
350
+ describe("handleToolCall — bash path gate", () => {
351
+ it("blocks a bash command accessing .env when path surface denies", async () => {
352
+ const checkPermission = vi
353
+ .fn()
354
+ .mockImplementation(
355
+ (surface: string, _input: unknown, _agentName?: string) => {
356
+ if (surface === "path") {
357
+ return makePermissionResult("deny");
358
+ }
359
+ return makePermissionResult("allow");
360
+ },
361
+ );
362
+ const { handler } = makeHandler({
363
+ session: { checkPermission },
364
+ toolRegistry: {
365
+ getAll: vi.fn().mockReturnValue([{ name: "bash" }]),
366
+ },
367
+ });
368
+ const event = {
369
+ type: "tool_call",
370
+ toolCallId: "tc-bash-path",
371
+ name: "bash",
372
+ input: { command: "cat .env" },
373
+ };
374
+ const result = await handler.handleToolCall(event, makeCtx());
375
+ expect(result).toMatchObject({ block: true });
376
+ });
377
+ });
@@ -3,6 +3,30 @@ import { normalizeInput } from "../src/input-normalizer";
3
3
  import { createMcpPermissionTargets } from "../src/mcp-targets";
4
4
 
5
5
  describe("normalizeInput — non-MCP surfaces", () => {
6
+ describe("special / path", () => {
7
+ it("uses path from input as the lookup value", () => {
8
+ const result = normalizeInput("path", { path: ".env" }, []);
9
+ expect(result.surface).toBe("path");
10
+ expect(result.values).toEqual([".env"]);
11
+ expect(result.resultExtras).toEqual({});
12
+ });
13
+
14
+ it("falls back to '*' when path is missing", () => {
15
+ const result = normalizeInput("path", {}, []);
16
+ expect(result.values).toEqual(["*"]);
17
+ });
18
+
19
+ it("falls back to '*' when path is not a string", () => {
20
+ const result = normalizeInput("path", { path: 42 }, []);
21
+ expect(result.values).toEqual(["*"]);
22
+ });
23
+
24
+ it("handles null input", () => {
25
+ const result = normalizeInput("path", null, []);
26
+ expect(result.values).toEqual(["*"]);
27
+ });
28
+ });
29
+
6
30
  describe("special / external_directory", () => {
7
31
  it("uses path from input as the lookup value", () => {
8
32
  const result = normalizeInput(
@@ -1111,3 +1111,213 @@ describe("checkPermission — per-tool path patterns", () => {
1111
1111
  }
1112
1112
  });
1113
1113
  });
1114
+
1115
+ // ---------------------------------------------------------------------------
1116
+ // Cross-cutting path surface (#148)
1117
+ // ---------------------------------------------------------------------------
1118
+
1119
+ describe("cross-cutting path surface", () => {
1120
+ it("denies .env via the path surface", () => {
1121
+ const { manager, cleanup } = makeManagerWithConfig({
1122
+ path: { "*": "allow", "*.env": "deny" },
1123
+ read: "allow",
1124
+ });
1125
+ try {
1126
+ const result = manager.checkPermission("path", { path: ".env" });
1127
+ expect(result.state).toBe("deny");
1128
+ expect(result.matchedPattern).toBe("*.env");
1129
+ } finally {
1130
+ cleanup();
1131
+ }
1132
+ });
1133
+
1134
+ it("allows non-matching paths via the path surface", () => {
1135
+ const { manager, cleanup } = makeManagerWithConfig({
1136
+ path: { "*": "allow", "*.env": "deny" },
1137
+ read: "allow",
1138
+ });
1139
+ try {
1140
+ const result = manager.checkPermission("path", { path: "README.md" });
1141
+ expect(result.state).toBe("allow");
1142
+ } finally {
1143
+ cleanup();
1144
+ }
1145
+ });
1146
+
1147
+ it("path surface does not interfere with per-tool rules", () => {
1148
+ const { manager, cleanup } = makeManagerWithConfig({
1149
+ path: { "*": "allow" },
1150
+ read: { "*": "allow", "*.secret": "deny" },
1151
+ });
1152
+ try {
1153
+ // path surface allows, per-tool denies
1154
+ const readResult = manager.checkPermission("read", {
1155
+ path: "data.secret",
1156
+ });
1157
+ expect(readResult.state).toBe("deny");
1158
+ // path surface also allows
1159
+ const pathResult = manager.checkPermission("path", {
1160
+ path: "data.secret",
1161
+ });
1162
+ expect(pathResult.state).toBe("allow");
1163
+ } finally {
1164
+ cleanup();
1165
+ }
1166
+ });
1167
+
1168
+ it("getToolPermission('path') returns catch-all action", () => {
1169
+ const { manager, cleanup } = makeManagerWithConfig({
1170
+ path: { "*": "allow", "*.env": "deny" },
1171
+ });
1172
+ try {
1173
+ const toolState = manager.getToolPermission("path");
1174
+ expect(toolState).toBe("allow");
1175
+ } finally {
1176
+ cleanup();
1177
+ }
1178
+ });
1179
+
1180
+ it("session approval on path surface overrides config deny", () => {
1181
+ const { manager, cleanup } = makeManagerWithConfig({
1182
+ path: { "*": "allow", "*.env": "deny" },
1183
+ });
1184
+ try {
1185
+ const sessionRules: Ruleset = [sessionAllow("path", "/project/.env")];
1186
+ const result = manager.checkPermission(
1187
+ "path",
1188
+ { path: "/project/.env" },
1189
+ undefined,
1190
+ sessionRules,
1191
+ );
1192
+ expect(result.state).toBe("allow");
1193
+ expect(result.source).toBe("session");
1194
+ } finally {
1195
+ cleanup();
1196
+ }
1197
+ });
1198
+
1199
+ it("configs without path key behave identically (no path gate fires)", () => {
1200
+ const { manager, cleanup } = makeManagerWithConfig({
1201
+ read: "allow",
1202
+ });
1203
+ try {
1204
+ // path surface falls through to universal default
1205
+ const result = manager.checkPermission("path", { path: ".env" });
1206
+ expect(result.state).toBe("ask");
1207
+ } finally {
1208
+ cleanup();
1209
+ }
1210
+ });
1211
+
1212
+ // ── Last-match-wins ordering ────────────────────────────────────────────
1213
+
1214
+ it("last-match-wins: catch-all after deny overrides the deny", () => {
1215
+ // Classic misconfiguration: deny is before allow, so allow wins.
1216
+ const { manager, cleanup } = makeManagerWithConfig({
1217
+ path: { "*.env": "deny", "*": "allow" },
1218
+ });
1219
+ try {
1220
+ const result = manager.checkPermission("path", { path: ".env" });
1221
+ // "*" is last and matches .env → allow (the deny is shadowed)
1222
+ expect(result.state).toBe("allow");
1223
+ } finally {
1224
+ cleanup();
1225
+ }
1226
+ });
1227
+
1228
+ it("last-match-wins: deny after catch-all blocks the path", () => {
1229
+ // Correct ordering: catch-all first, specific deny after.
1230
+ const { manager, cleanup } = makeManagerWithConfig({
1231
+ path: { "*": "allow", "*.env": "deny" },
1232
+ });
1233
+ try {
1234
+ const result = manager.checkPermission("path", { path: ".env" });
1235
+ expect(result.state).toBe("deny");
1236
+ } finally {
1237
+ cleanup();
1238
+ }
1239
+ });
1240
+
1241
+ // ── .env.example override recipe ────────────────────────────────────────
1242
+
1243
+ it(".env.example override: denies .env and .env.local, allows .env.example", () => {
1244
+ const { manager, cleanup } = makeManagerWithConfig({
1245
+ path: {
1246
+ "*": "allow",
1247
+ "*.env": "deny",
1248
+ "*.env.*": "deny",
1249
+ "*.env.example": "allow",
1250
+ },
1251
+ });
1252
+ try {
1253
+ expect(manager.checkPermission("path", { path: ".env" }).state).toBe(
1254
+ "deny",
1255
+ );
1256
+ expect(
1257
+ manager.checkPermission("path", { path: ".env.local" }).state,
1258
+ ).toBe("deny");
1259
+ expect(
1260
+ manager.checkPermission("path", { path: ".env.production" }).state,
1261
+ ).toBe("deny");
1262
+ expect(manager.checkPermission("path", { path: "src/.env" }).state).toBe(
1263
+ "deny",
1264
+ );
1265
+ expect(
1266
+ manager.checkPermission("path", { path: ".env.example" }).state,
1267
+ ).toBe("allow");
1268
+ expect(manager.checkPermission("path", { path: "README.md" }).state).toBe(
1269
+ "allow",
1270
+ );
1271
+ } finally {
1272
+ cleanup();
1273
+ }
1274
+ });
1275
+
1276
+ // ── Universal fallback interaction ──────────────────────────────────────
1277
+
1278
+ it("universal '*': 'allow' with no path key makes the path gate transparent", () => {
1279
+ const { manager, cleanup } = makeManagerWithConfig({
1280
+ "*": "allow",
1281
+ });
1282
+ try {
1283
+ const result = manager.checkPermission("path", { path: ".env" });
1284
+ expect(result.state).toBe("allow");
1285
+ } finally {
1286
+ cleanup();
1287
+ }
1288
+ });
1289
+
1290
+ it("universal '*': 'deny' with no path key denies via path surface too", () => {
1291
+ const { manager, cleanup } = makeManagerWithConfig({
1292
+ "*": "deny",
1293
+ });
1294
+ try {
1295
+ const result = manager.checkPermission("path", { path: ".env" });
1296
+ expect(result.state).toBe("deny");
1297
+ } finally {
1298
+ cleanup();
1299
+ }
1300
+ });
1301
+
1302
+ // ── Composition: path allows, per-tool denies ──────────────────────────
1303
+
1304
+ it("per-tool deny still blocks even when path surface allows", () => {
1305
+ const { manager, cleanup } = makeManagerWithConfig({
1306
+ path: { "*": "allow" },
1307
+ read: "deny",
1308
+ });
1309
+ try {
1310
+ // path gate passes (allow), but tool gate denies
1311
+ const pathResult = manager.checkPermission("path", {
1312
+ path: "secret.txt",
1313
+ });
1314
+ expect(pathResult.state).toBe("allow");
1315
+ const readResult = manager.checkPermission("read", {
1316
+ path: "secret.txt",
1317
+ });
1318
+ expect(readResult.state).toBe("deny");
1319
+ } finally {
1320
+ cleanup();
1321
+ }
1322
+ });
1323
+ });
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import type { Rule, RuleOrigin, Ruleset } from "../src/rule";
3
- import { evaluate, evaluateFirst } from "../src/rule";
3
+ import { evaluate, evaluateFirst, evaluateMostRestrictive } from "../src/rule";
4
4
 
5
5
  describe("evaluate", () => {
6
6
  const allowBashGit: Rule = {
@@ -317,3 +317,79 @@ describe("evaluateFirst", () => {
317
317
  expect(result.value).toBe("*");
318
318
  });
319
319
  });
320
+
321
+ describe("evaluateMostRestrictive", () => {
322
+ const denyEnv: Rule = {
323
+ surface: "path",
324
+ pattern: "*.env",
325
+ action: "deny",
326
+ layer: "config",
327
+ origin: "global",
328
+ };
329
+ const askSsh: Rule = {
330
+ surface: "path",
331
+ pattern: "/home/user/.ssh/*",
332
+ action: "ask",
333
+ layer: "config",
334
+ origin: "global",
335
+ };
336
+ const allowAll: Rule = {
337
+ surface: "path",
338
+ pattern: "*",
339
+ action: "allow",
340
+ layer: "config",
341
+ origin: "global",
342
+ };
343
+
344
+ test("deny short-circuits: returns immediately without evaluating remaining values", () => {
345
+ const rules: Ruleset = [allowAll, denyEnv];
346
+ const result = evaluateMostRestrictive(
347
+ "path",
348
+ [".env", "README.md"],
349
+ rules,
350
+ );
351
+ expect(result).not.toBeNull();
352
+ expect(result!.rule.action).toBe("deny");
353
+ expect(result!.value).toBe(".env");
354
+ });
355
+
356
+ test("ask accumulates: returns first ask when no deny found", () => {
357
+ const rules: Ruleset = [allowAll, askSsh];
358
+ const result = evaluateMostRestrictive(
359
+ "path",
360
+ ["/home/user/.ssh/id_rsa", "README.md"],
361
+ rules,
362
+ );
363
+ expect(result).not.toBeNull();
364
+ expect(result!.rule.action).toBe("ask");
365
+ expect(result!.value).toBe("/home/user/.ssh/id_rsa");
366
+ });
367
+
368
+ test("all allow: returns null", () => {
369
+ const rules: Ruleset = [allowAll];
370
+ const result = evaluateMostRestrictive(
371
+ "path",
372
+ ["README.md", "src/index.ts"],
373
+ rules,
374
+ );
375
+ expect(result).toBeNull();
376
+ });
377
+
378
+ test("empty values: returns null", () => {
379
+ const rules: Ruleset = [allowAll, denyEnv];
380
+ const result = evaluateMostRestrictive("path", [], rules);
381
+ expect(result).toBeNull();
382
+ });
383
+
384
+ test("deny wins over ask", () => {
385
+ const rules: Ruleset = [allowAll, askSsh, denyEnv];
386
+ const result = evaluateMostRestrictive(
387
+ "path",
388
+ ["/home/user/.ssh/id_rsa", ".env"],
389
+ rules,
390
+ );
391
+ expect(result).not.toBeNull();
392
+ expect(result!.rule.action).toBe("deny");
393
+ expect(result!.value).toBe(".env");
394
+ });
395
+ });