@gotgenes/pi-permission-system 5.16.0 → 5.17.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.
- package/CHANGELOG.md +20 -0
- package/README.md +8 -3
- package/config/config.example.json +3 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +4 -2
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/input-normalizer.ts +1 -1
- package/src/permission-manager.ts +1 -1
- package/src/rule.ts +27 -0
- package/tests/bash-external-directory.test.ts +81 -1
- package/tests/handlers/external-directory-integration.test.ts +84 -3
- package/tests/handlers/gates/bash-path.test.ts +260 -0
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +24 -0
- package/tests/permission-manager-unified.test.ts +210 -0
- package/tests/rule.test.ts +77 -1
|
@@ -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
|
+
});
|
package/tests/rule.test.ts
CHANGED
|
@@ -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
|
+
});
|