@gotgenes/pi-permission-system 5.15.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 +35 -0
- package/README.md +14 -0
- package/config/config.example.json +7 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +8 -1
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/helpers.ts +4 -1
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/gates/tool.ts +25 -9
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/input-normalizer.ts +13 -2
- package/src/pattern-suggest.ts +12 -2
- 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/helpers.test.ts +15 -2
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +65 -4
- package/tests/pattern-suggest.test.ts +40 -12
- package/tests/permission-manager-unified.test.ts +341 -0
- package/tests/rule.test.ts +77 -1
|
@@ -980,3 +980,344 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
|
|
|
980
980
|
});
|
|
981
981
|
});
|
|
982
982
|
});
|
|
983
|
+
|
|
984
|
+
// ---------------------------------------------------------------------------
|
|
985
|
+
// Per-tool path patterns (#147)
|
|
986
|
+
// ---------------------------------------------------------------------------
|
|
987
|
+
|
|
988
|
+
describe("checkPermission — per-tool path patterns", () => {
|
|
989
|
+
it("denies read of .env when path pattern matches", () => {
|
|
990
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
991
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
992
|
+
});
|
|
993
|
+
try {
|
|
994
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
995
|
+
expect(result.state).toBe("deny");
|
|
996
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
997
|
+
} finally {
|
|
998
|
+
cleanup();
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("allows read of non-.env file when .env is denied", () => {
|
|
1003
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1004
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1005
|
+
});
|
|
1006
|
+
try {
|
|
1007
|
+
const result = manager.checkPermission("read", {
|
|
1008
|
+
path: "src/main.ts",
|
|
1009
|
+
});
|
|
1010
|
+
expect(result.state).toBe("allow");
|
|
1011
|
+
} finally {
|
|
1012
|
+
cleanup();
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it("allows write to src/ when only src/ is allowed", () => {
|
|
1017
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1018
|
+
write: { "*": "deny", "src/*": "allow" },
|
|
1019
|
+
});
|
|
1020
|
+
try {
|
|
1021
|
+
const result = manager.checkPermission("write", {
|
|
1022
|
+
path: "src/main.ts",
|
|
1023
|
+
});
|
|
1024
|
+
expect(result.state).toBe("allow");
|
|
1025
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
1026
|
+
} finally {
|
|
1027
|
+
cleanup();
|
|
1028
|
+
}
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("denies write outside src/ when only src/ is allowed", () => {
|
|
1032
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1033
|
+
write: { "*": "deny", "src/*": "allow" },
|
|
1034
|
+
});
|
|
1035
|
+
try {
|
|
1036
|
+
const result = manager.checkPermission("write", {
|
|
1037
|
+
path: "vendor/lib.ts",
|
|
1038
|
+
});
|
|
1039
|
+
expect(result.state).toBe("deny");
|
|
1040
|
+
} finally {
|
|
1041
|
+
cleanup();
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it("backward compat: 'read': 'allow' allows read of any path", () => {
|
|
1046
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1047
|
+
read: "allow",
|
|
1048
|
+
});
|
|
1049
|
+
try {
|
|
1050
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1051
|
+
expect(result.state).toBe("allow");
|
|
1052
|
+
} finally {
|
|
1053
|
+
cleanup();
|
|
1054
|
+
}
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it("backward compat: 'read': 'deny' denies read of any path", () => {
|
|
1058
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1059
|
+
read: "deny",
|
|
1060
|
+
});
|
|
1061
|
+
try {
|
|
1062
|
+
const result = manager.checkPermission("read", {
|
|
1063
|
+
path: "src/main.ts",
|
|
1064
|
+
});
|
|
1065
|
+
expect(result.state).toBe("deny");
|
|
1066
|
+
} finally {
|
|
1067
|
+
cleanup();
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it("session rule for specific path overrides config deny", () => {
|
|
1072
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1073
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1074
|
+
});
|
|
1075
|
+
try {
|
|
1076
|
+
const sessionRules: Ruleset = [sessionAllow("read", ".env")];
|
|
1077
|
+
const result = manager.checkPermission(
|
|
1078
|
+
"read",
|
|
1079
|
+
{ path: ".env" },
|
|
1080
|
+
undefined,
|
|
1081
|
+
sessionRules,
|
|
1082
|
+
);
|
|
1083
|
+
expect(result.state).toBe("allow");
|
|
1084
|
+
expect(result.source).toBe("session");
|
|
1085
|
+
} finally {
|
|
1086
|
+
cleanup();
|
|
1087
|
+
}
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("falls back to '*' when input.path is missing", () => {
|
|
1091
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1092
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1093
|
+
});
|
|
1094
|
+
try {
|
|
1095
|
+
const result = manager.checkPermission("read", {});
|
|
1096
|
+
expect(result.state).toBe("allow");
|
|
1097
|
+
} finally {
|
|
1098
|
+
cleanup();
|
|
1099
|
+
}
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it("getToolPermission still returns surface-level state (not path-specific)", () => {
|
|
1103
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1104
|
+
read: { "*": "allow", "*.env": "deny" },
|
|
1105
|
+
});
|
|
1106
|
+
try {
|
|
1107
|
+
const toolState = manager.getToolPermission("read");
|
|
1108
|
+
expect(toolState).toBe("allow");
|
|
1109
|
+
} finally {
|
|
1110
|
+
cleanup();
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
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
|
+
});
|