@gotgenes/pi-permission-system 12.0.0 → 13.1.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 +40 -0
- package/README.md +7 -9
- package/config/config.example.json +2 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +28 -2
- package/src/common.ts +17 -1
- package/src/config-loader.ts +9 -5
- package/src/config-modal.ts +3 -7
- package/src/denial-messages.ts +2 -1
- package/src/extension-config.ts +11 -25
- package/src/handlers/gates/bash-path-extractor.ts +0 -12
- package/src/handlers/gates/bash-path.ts +12 -10
- package/src/handlers/gates/bash-program.ts +52 -11
- package/src/handlers/gates/bash-token-classification.ts +1 -1
- package/src/index.ts +4 -2
- package/src/input-normalizer.ts +17 -11
- package/src/normalize.ts +12 -2
- package/src/path-utils.ts +81 -0
- package/src/permission-manager.ts +82 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/rule.ts +63 -11
- package/src/types.ts +18 -3
- package/test/bash-external-directory.test.ts +1 -81
- package/test/common.test.ts +28 -0
- package/test/config-loader.test.ts +43 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -0
- package/test/denial-messages.test.ts +61 -0
- package/test/extension-config.test.ts +0 -58
- package/test/handlers/gates/bash-path.test.ts +45 -2
- package/test/handlers/gates/bash-program.test.ts +44 -11
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +1 -1
- package/test/helpers/gate-fixtures.ts +23 -3
- package/test/helpers/handler-fixtures.ts +14 -2
- package/test/helpers/session-fixtures.ts +14 -0
- package/test/input-normalizer.test.ts +52 -0
- package/test/normalize.test.ts +81 -0
- package/test/path-utils.test.ts +72 -0
- package/test/permission-manager-unified.test.ts +199 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/rule.test.ts +135 -1
package/test/path-utils.test.ts
CHANGED
|
@@ -23,12 +23,14 @@ vi.mock("node:fs", () => ({
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalNormalizePathForComparison,
|
|
25
25
|
getPathBearingToolPath,
|
|
26
|
+
getPathPolicyValues,
|
|
26
27
|
getToolInputPath,
|
|
27
28
|
isPathOutsideWorkingDirectory,
|
|
28
29
|
isPathWithinDirectory,
|
|
29
30
|
isPiInfrastructureRead,
|
|
30
31
|
isSafeSystemPath,
|
|
31
32
|
normalizePathForComparison,
|
|
33
|
+
normalizePathPolicyLiteral,
|
|
32
34
|
PATH_BEARING_TOOLS,
|
|
33
35
|
READ_ONLY_PATH_BEARING_TOOLS,
|
|
34
36
|
SAFE_SYSTEM_PATHS,
|
|
@@ -542,3 +544,73 @@ describe("isPiInfrastructureRead", () => {
|
|
|
542
544
|
).toBe(false);
|
|
543
545
|
});
|
|
544
546
|
});
|
|
547
|
+
|
|
548
|
+
describe("normalizePathPolicyLiteral", () => {
|
|
549
|
+
test("returns a relative token unchanged", () => {
|
|
550
|
+
expect(normalizePathPolicyLiteral("src/foo.ts")).toBe("src/foo.ts");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test("trims and strips simple wrapping quotes", () => {
|
|
554
|
+
expect(normalizePathPolicyLiteral(" 'src/foo.ts' ")).toBe("src/foo.ts");
|
|
555
|
+
expect(normalizePathPolicyLiteral('"a/b"')).toBe("a/b");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("strips a leading @ prefix", () => {
|
|
559
|
+
expect(normalizePathPolicyLiteral("@src/foo.ts")).toBe("src/foo.ts");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("expands ~ to the home directory", () => {
|
|
563
|
+
expect(normalizePathPolicyLiteral("~/docs/readme.md")).toBe(
|
|
564
|
+
join("/mock/home", "docs/readme.md"),
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("does not resolve a relative value against any cwd", () => {
|
|
569
|
+
expect(normalizePathPolicyLiteral("foo.ts")).toBe("foo.ts");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("returns empty string for blank input", () => {
|
|
573
|
+
expect(normalizePathPolicyLiteral(" ")).toBe("");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("preserves the surface catch-all", () => {
|
|
577
|
+
expect(normalizePathPolicyLiteral("*")).toBe("*");
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
describe("getPathPolicyValues", () => {
|
|
582
|
+
const cwd = "/projects/my-app";
|
|
583
|
+
|
|
584
|
+
test("returns only the literal when no base is available", () => {
|
|
585
|
+
expect(getPathPolicyValues("src/foo.ts")).toEqual(["src/foo.ts"]);
|
|
586
|
+
expect(getPathPolicyValues("src/foo.ts", {})).toEqual(["src/foo.ts"]);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
test("adds absolute and project-relative aliases for a relative token", () => {
|
|
590
|
+
expect(getPathPolicyValues("src/foo.ts", { cwd })).toEqual([
|
|
591
|
+
"/projects/my-app/src/foo.ts",
|
|
592
|
+
"src/foo.ts",
|
|
593
|
+
]);
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test("omits the relative alias for a token outside cwd", () => {
|
|
597
|
+
expect(getPathPolicyValues("/etc/hosts", { cwd })).toEqual(["/etc/hosts"]);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("resolves against resolveBase while aliasing relative to cwd", () => {
|
|
601
|
+
expect(
|
|
602
|
+
getPathPolicyValues("foo.txt", {
|
|
603
|
+
cwd,
|
|
604
|
+
resolveBase: "/projects/my-app/nested",
|
|
605
|
+
}),
|
|
606
|
+
).toEqual(["/projects/my-app/nested/foo.txt", "nested/foo.txt", "foo.txt"]);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
test("preserves the surface catch-all", () => {
|
|
610
|
+
expect(getPathPolicyValues("*", { cwd })).toEqual(["*"]);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("returns empty for blank input", () => {
|
|
614
|
+
expect(getPathPolicyValues(" ", { cwd })).toEqual([]);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
@@ -1245,6 +1245,71 @@ describe("cross-cutting path surface", () => {
|
|
|
1245
1245
|
}
|
|
1246
1246
|
});
|
|
1247
1247
|
|
|
1248
|
+
// ── Deny-with-reason ────────────────────────────────────────────────────
|
|
1249
|
+
|
|
1250
|
+
it("deny-with-reason: reason threads through to PermissionCheckResult", () => {
|
|
1251
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1252
|
+
bash: { "npm *": { action: "deny", reason: "Use pnpm instead" } },
|
|
1253
|
+
});
|
|
1254
|
+
try {
|
|
1255
|
+
const result = manager.checkPermission("bash", {
|
|
1256
|
+
command: "npm install",
|
|
1257
|
+
});
|
|
1258
|
+
expect(result.state).toBe("deny");
|
|
1259
|
+
expect(result.reason).toBe("Use pnpm instead");
|
|
1260
|
+
expect(result.matchedPattern).toBe("npm *");
|
|
1261
|
+
} finally {
|
|
1262
|
+
cleanup();
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("deny-without-reason: reason is undefined in PermissionCheckResult", () => {
|
|
1267
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1268
|
+
bash: { "rm -rf *": "deny" },
|
|
1269
|
+
});
|
|
1270
|
+
try {
|
|
1271
|
+
const result = manager.checkPermission("bash", { command: "rm -rf /" });
|
|
1272
|
+
expect(result.state).toBe("deny");
|
|
1273
|
+
expect(result.reason).toBeUndefined();
|
|
1274
|
+
} finally {
|
|
1275
|
+
cleanup();
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it("deny-with-reason on a non-bash surface", () => {
|
|
1280
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1281
|
+
read: {
|
|
1282
|
+
"*.env": {
|
|
1283
|
+
action: "deny",
|
|
1284
|
+
reason: "Environment files contain secrets",
|
|
1285
|
+
},
|
|
1286
|
+
},
|
|
1287
|
+
});
|
|
1288
|
+
try {
|
|
1289
|
+
const result = manager.checkPermission("read", { path: ".env" });
|
|
1290
|
+
expect(result.state).toBe("deny");
|
|
1291
|
+
expect(result.reason).toBe("Environment files contain secrets");
|
|
1292
|
+
expect(result.matchedPattern).toBe("*.env");
|
|
1293
|
+
} finally {
|
|
1294
|
+
cleanup();
|
|
1295
|
+
}
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
it("non-string reason falls through to the default (malformed config)", () => {
|
|
1299
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
1300
|
+
bash: { "npm *": { action: "deny", reason: 42 } },
|
|
1301
|
+
});
|
|
1302
|
+
try {
|
|
1303
|
+
const result = manager.checkPermission("bash", {
|
|
1304
|
+
command: "npm install",
|
|
1305
|
+
});
|
|
1306
|
+
expect(result.state).toBe("ask");
|
|
1307
|
+
expect(result.reason).toBeUndefined();
|
|
1308
|
+
} finally {
|
|
1309
|
+
cleanup();
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
|
|
1248
1313
|
// ── Last-match-wins ordering ────────────────────────────────────────────
|
|
1249
1314
|
|
|
1250
1315
|
it("last-match-wins: catch-all after deny overrides the deny", () => {
|
|
@@ -3081,3 +3146,137 @@ test("getResolvedPolicyPaths returns false for missing files and null for absent
|
|
|
3081
3146
|
rmSync(tempDir, { recursive: true, force: true });
|
|
3082
3147
|
}
|
|
3083
3148
|
});
|
|
3149
|
+
|
|
3150
|
+
describe("checkPermission — cwd-aware path policy values", () => {
|
|
3151
|
+
const cwd = "/workspace/project";
|
|
3152
|
+
|
|
3153
|
+
it("matches a relative read input against an absolute allowlist", () => {
|
|
3154
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3155
|
+
read: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3156
|
+
});
|
|
3157
|
+
try {
|
|
3158
|
+
manager.configureForCwd(cwd);
|
|
3159
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3160
|
+
expect(result.state).toBe("allow");
|
|
3161
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3162
|
+
} finally {
|
|
3163
|
+
cleanup();
|
|
3164
|
+
}
|
|
3165
|
+
});
|
|
3166
|
+
|
|
3167
|
+
it("keeps legacy relative path rules working after configureForCwd", () => {
|
|
3168
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3169
|
+
read: { "*": "allow", "src/*": "deny" },
|
|
3170
|
+
});
|
|
3171
|
+
try {
|
|
3172
|
+
manager.configureForCwd(cwd);
|
|
3173
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3174
|
+
expect(result.state).toBe("deny");
|
|
3175
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3176
|
+
} finally {
|
|
3177
|
+
cleanup();
|
|
3178
|
+
}
|
|
3179
|
+
});
|
|
3180
|
+
|
|
3181
|
+
it("preserves last-match-wins across absolute and relative aliases", () => {
|
|
3182
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3183
|
+
read: {
|
|
3184
|
+
"*": "ask",
|
|
3185
|
+
[`${cwd}/*`]: "allow",
|
|
3186
|
+
"src/*": "deny",
|
|
3187
|
+
},
|
|
3188
|
+
});
|
|
3189
|
+
try {
|
|
3190
|
+
manager.configureForCwd(cwd);
|
|
3191
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3192
|
+
// The later "src/*" deny wins over the earlier absolute allow.
|
|
3193
|
+
expect(result.state).toBe("deny");
|
|
3194
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3195
|
+
} finally {
|
|
3196
|
+
cleanup();
|
|
3197
|
+
}
|
|
3198
|
+
});
|
|
3199
|
+
|
|
3200
|
+
it("matches the cross-cutting path surface against absolute allowlists", () => {
|
|
3201
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3202
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3203
|
+
});
|
|
3204
|
+
try {
|
|
3205
|
+
manager.configureForCwd(cwd);
|
|
3206
|
+
const result = manager.checkPermission("path", { path: "src/App.jsx" });
|
|
3207
|
+
expect(result.state).toBe("allow");
|
|
3208
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3209
|
+
} finally {
|
|
3210
|
+
cleanup();
|
|
3211
|
+
}
|
|
3212
|
+
});
|
|
3213
|
+
});
|
|
3214
|
+
|
|
3215
|
+
describe("checkPathPolicy", () => {
|
|
3216
|
+
const cwd = "/workspace/project";
|
|
3217
|
+
|
|
3218
|
+
it("evaluates precomputed policy values against the path surface", () => {
|
|
3219
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3220
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3221
|
+
});
|
|
3222
|
+
try {
|
|
3223
|
+
const result = manager.checkPathPolicy([
|
|
3224
|
+
`${cwd}/src/App.jsx`,
|
|
3225
|
+
"src/App.jsx",
|
|
3226
|
+
]);
|
|
3227
|
+
expect(result.state).toBe("allow");
|
|
3228
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3229
|
+
expect(result.source).toBe("special");
|
|
3230
|
+
expect(result.toolName).toBe("path");
|
|
3231
|
+
} finally {
|
|
3232
|
+
cleanup();
|
|
3233
|
+
}
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
it("preserves last-match-wins across the provided aliases", () => {
|
|
3237
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3238
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
|
|
3239
|
+
});
|
|
3240
|
+
try {
|
|
3241
|
+
const result = manager.checkPathPolicy([
|
|
3242
|
+
`${cwd}/src/App.jsx`,
|
|
3243
|
+
"src/App.jsx",
|
|
3244
|
+
]);
|
|
3245
|
+
expect(result.state).toBe("deny");
|
|
3246
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3247
|
+
} finally {
|
|
3248
|
+
cleanup();
|
|
3249
|
+
}
|
|
3250
|
+
});
|
|
3251
|
+
|
|
3252
|
+
it("applies session rules over config", () => {
|
|
3253
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3254
|
+
path: { "*": "ask", "src/*": "deny" },
|
|
3255
|
+
});
|
|
3256
|
+
try {
|
|
3257
|
+
const sessionRules: Ruleset = [sessionAllow("path", "src/*")];
|
|
3258
|
+
const result = manager.checkPathPolicy(
|
|
3259
|
+
["src/App.jsx"],
|
|
3260
|
+
undefined,
|
|
3261
|
+
sessionRules,
|
|
3262
|
+
);
|
|
3263
|
+
expect(result.state).toBe("allow");
|
|
3264
|
+
expect(result.source).toBe("session");
|
|
3265
|
+
} finally {
|
|
3266
|
+
cleanup();
|
|
3267
|
+
}
|
|
3268
|
+
});
|
|
3269
|
+
|
|
3270
|
+
it("falls back to the catch-all for an empty value list", () => {
|
|
3271
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3272
|
+
path: { "*": "deny" },
|
|
3273
|
+
});
|
|
3274
|
+
try {
|
|
3275
|
+
const result = manager.checkPathPolicy([]);
|
|
3276
|
+
expect(result.state).toBe("deny");
|
|
3277
|
+
expect(result.matchedPattern).toBe("*");
|
|
3278
|
+
} finally {
|
|
3279
|
+
cleanup();
|
|
3280
|
+
}
|
|
3281
|
+
});
|
|
3282
|
+
});
|
|
@@ -24,6 +24,20 @@ function makePermissionManager() {
|
|
|
24
24
|
source: "tool",
|
|
25
25
|
origin: "builtin",
|
|
26
26
|
}),
|
|
27
|
+
checkPathPolicy: vi
|
|
28
|
+
.fn<
|
|
29
|
+
(
|
|
30
|
+
values: readonly string[],
|
|
31
|
+
agentName?: string,
|
|
32
|
+
sessionRules?: Ruleset,
|
|
33
|
+
) => PermissionCheckResult
|
|
34
|
+
>()
|
|
35
|
+
.mockReturnValue({
|
|
36
|
+
state: "allow",
|
|
37
|
+
toolName: "path",
|
|
38
|
+
source: "special",
|
|
39
|
+
origin: "builtin",
|
|
40
|
+
}),
|
|
27
41
|
getToolPermission: vi
|
|
28
42
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
29
43
|
.mockReturnValue("allow"),
|
|
@@ -119,6 +133,61 @@ describe("PermissionResolver", () => {
|
|
|
119
133
|
});
|
|
120
134
|
});
|
|
121
135
|
|
|
136
|
+
describe("resolvePathPolicy", () => {
|
|
137
|
+
it("forwards values and agentName with the current session ruleset", () => {
|
|
138
|
+
const { resolver, permissionManager } = makeResolver();
|
|
139
|
+
|
|
140
|
+
resolver.resolvePathPolicy(["/proj/src/a.ts", "src/a.ts"], "agent-x");
|
|
141
|
+
|
|
142
|
+
expect(permissionManager.checkPathPolicy).toHaveBeenCalledWith(
|
|
143
|
+
["/proj/src/a.ts", "src/a.ts"],
|
|
144
|
+
"agent-x",
|
|
145
|
+
[],
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("applies a recorded session approval on the next call", () => {
|
|
150
|
+
const pm = makePermissionManager();
|
|
151
|
+
const sessionRules = new SessionRules();
|
|
152
|
+
const { resolver } = makeResolver(pm, sessionRules);
|
|
153
|
+
|
|
154
|
+
sessionRules.recordSessionApproval(
|
|
155
|
+
SessionApproval.single("path", "src/*"),
|
|
156
|
+
);
|
|
157
|
+
resolver.resolvePathPolicy(["src/a.ts"]);
|
|
158
|
+
|
|
159
|
+
const passedRules = vi.mocked(pm.checkPathPolicy).mock.calls[0][2];
|
|
160
|
+
expect(passedRules).toHaveLength(1);
|
|
161
|
+
expect(passedRules?.[0]).toMatchObject({
|
|
162
|
+
surface: "path",
|
|
163
|
+
pattern: "src/*",
|
|
164
|
+
action: "allow",
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("returns the PermissionManager's check result", () => {
|
|
169
|
+
const pm = makePermissionManager();
|
|
170
|
+
vi.mocked(pm.checkPathPolicy).mockReturnValue({
|
|
171
|
+
state: "deny",
|
|
172
|
+
toolName: "path",
|
|
173
|
+
source: "special",
|
|
174
|
+
origin: "global",
|
|
175
|
+
matchedPattern: "src/*",
|
|
176
|
+
});
|
|
177
|
+
const { resolver } = makeResolver(pm);
|
|
178
|
+
|
|
179
|
+
const result = resolver.resolvePathPolicy(["src/a.ts"]);
|
|
180
|
+
|
|
181
|
+
expect(result).toEqual({
|
|
182
|
+
state: "deny",
|
|
183
|
+
toolName: "path",
|
|
184
|
+
source: "special",
|
|
185
|
+
origin: "global",
|
|
186
|
+
matchedPattern: "src/*",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
122
191
|
describe("checkPermission", () => {
|
|
123
192
|
it("delegates to permissionManager.checkPermission with the given args", () => {
|
|
124
193
|
const { resolver, permissionManager } = makeResolver();
|
package/test/rule.test.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import type { Rule, RuleOrigin, Ruleset } from "#src/rule";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
evaluate,
|
|
5
|
+
evaluateAnyValue,
|
|
6
|
+
evaluateFirst,
|
|
7
|
+
evaluateMostRestrictive,
|
|
8
|
+
} from "#src/rule";
|
|
4
9
|
|
|
5
10
|
describe("evaluate", () => {
|
|
6
11
|
const allowBashGit: Rule = {
|
|
@@ -211,6 +216,67 @@ describe("evaluate", () => {
|
|
|
211
216
|
expect(result.origin).toBe("builtin");
|
|
212
217
|
});
|
|
213
218
|
|
|
219
|
+
test("evaluate() propagates reason from the matched deny rule", () => {
|
|
220
|
+
const rule: Rule = {
|
|
221
|
+
surface: "bash",
|
|
222
|
+
pattern: "npm *",
|
|
223
|
+
action: "deny",
|
|
224
|
+
reason: "Use pnpm instead",
|
|
225
|
+
layer: "config",
|
|
226
|
+
origin: "global",
|
|
227
|
+
};
|
|
228
|
+
const result = evaluate("bash", "npm install", [rule]);
|
|
229
|
+
expect(result.action).toBe("deny");
|
|
230
|
+
expect(result.reason).toBe("Use pnpm instead");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("evaluate() carries reason through last-match-wins when deny wins", () => {
|
|
234
|
+
const allowAll: Rule = {
|
|
235
|
+
surface: "bash",
|
|
236
|
+
pattern: "*",
|
|
237
|
+
action: "allow",
|
|
238
|
+
layer: "config",
|
|
239
|
+
origin: "global",
|
|
240
|
+
};
|
|
241
|
+
const denyNpm: Rule = {
|
|
242
|
+
surface: "bash",
|
|
243
|
+
pattern: "npm *",
|
|
244
|
+
action: "deny",
|
|
245
|
+
reason: "Use pnpm",
|
|
246
|
+
layer: "config",
|
|
247
|
+
origin: "global",
|
|
248
|
+
};
|
|
249
|
+
const result = evaluate("bash", "npm install", [allowAll, denyNpm]);
|
|
250
|
+
expect(result.action).toBe("deny");
|
|
251
|
+
expect(result.reason).toBe("Use pnpm");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("evaluate() drops reason when a later allow overrides the deny", () => {
|
|
255
|
+
const denyNpm: Rule = {
|
|
256
|
+
surface: "bash",
|
|
257
|
+
pattern: "npm *",
|
|
258
|
+
action: "deny",
|
|
259
|
+
reason: "Use pnpm",
|
|
260
|
+
layer: "config",
|
|
261
|
+
origin: "global",
|
|
262
|
+
};
|
|
263
|
+
const allowInstall: Rule = {
|
|
264
|
+
surface: "bash",
|
|
265
|
+
pattern: "npm install",
|
|
266
|
+
action: "allow",
|
|
267
|
+
layer: "config",
|
|
268
|
+
origin: "global",
|
|
269
|
+
};
|
|
270
|
+
const result = evaluate("bash", "npm install", [denyNpm, allowInstall]);
|
|
271
|
+
expect(result.action).toBe("allow");
|
|
272
|
+
expect(result.reason).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("evaluate() synthetic fallback rule has no reason", () => {
|
|
276
|
+
const result = evaluate("bash", "npm install", []);
|
|
277
|
+
expect(result.reason).toBeUndefined();
|
|
278
|
+
});
|
|
279
|
+
|
|
214
280
|
test("RuleOrigin covers all seven provenance values", () => {
|
|
215
281
|
const origins: RuleOrigin[] = [
|
|
216
282
|
"global",
|
|
@@ -393,6 +459,74 @@ describe("evaluateFirst", () => {
|
|
|
393
459
|
});
|
|
394
460
|
});
|
|
395
461
|
|
|
462
|
+
describe("evaluateAnyValue", () => {
|
|
463
|
+
const catchAllAllow: Rule = {
|
|
464
|
+
surface: "path",
|
|
465
|
+
pattern: "*",
|
|
466
|
+
action: "allow",
|
|
467
|
+
layer: "config",
|
|
468
|
+
origin: "global",
|
|
469
|
+
};
|
|
470
|
+
const catchAllAsk: Rule = {
|
|
471
|
+
surface: "path",
|
|
472
|
+
pattern: "*",
|
|
473
|
+
action: "ask",
|
|
474
|
+
layer: "config",
|
|
475
|
+
origin: "global",
|
|
476
|
+
};
|
|
477
|
+
const relativeDeny: Rule = {
|
|
478
|
+
surface: "path",
|
|
479
|
+
pattern: "src/*",
|
|
480
|
+
action: "deny",
|
|
481
|
+
layer: "config",
|
|
482
|
+
origin: "global",
|
|
483
|
+
};
|
|
484
|
+
const absoluteAllow: Rule = {
|
|
485
|
+
surface: "path",
|
|
486
|
+
pattern: "/proj/*",
|
|
487
|
+
action: "allow",
|
|
488
|
+
layer: "config",
|
|
489
|
+
origin: "global",
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
test("a later relative rule wins over a catch-all matched by another alias", () => {
|
|
493
|
+
const rules: Ruleset = [catchAllAllow, relativeDeny];
|
|
494
|
+
const result = evaluateAnyValue(
|
|
495
|
+
"path",
|
|
496
|
+
["/proj/src/foo.ts", "src/foo.ts"],
|
|
497
|
+
rules,
|
|
498
|
+
);
|
|
499
|
+
expect(result.rule).toEqual(relativeDeny);
|
|
500
|
+
expect(result.value).toBe("src/foo.ts");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("uses an absolute alias when no later relative rule matches", () => {
|
|
504
|
+
const rules: Ruleset = [catchAllAsk, absoluteAllow];
|
|
505
|
+
const result = evaluateAnyValue(
|
|
506
|
+
"path",
|
|
507
|
+
["/proj/src/foo.ts", "src/foo.ts"],
|
|
508
|
+
rules,
|
|
509
|
+
);
|
|
510
|
+
expect(result.rule).toEqual(absoluteAllow);
|
|
511
|
+
expect(result.value).toBe("/proj/src/foo.ts");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("falls back to the first value's default when no rule matches", () => {
|
|
515
|
+
const result = evaluateAnyValue(
|
|
516
|
+
"path",
|
|
517
|
+
["/proj/src/foo.ts", "src/foo.ts"],
|
|
518
|
+
[],
|
|
519
|
+
);
|
|
520
|
+
expect(result.rule.action).toBe("ask");
|
|
521
|
+
expect(result.value).toBe("/proj/src/foo.ts");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("uses '*' as fallback value when values array is empty", () => {
|
|
525
|
+
const result = evaluateAnyValue("path", [], []);
|
|
526
|
+
expect(result.value).toBe("*");
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
396
530
|
describe("evaluateMostRestrictive", () => {
|
|
397
531
|
const denyEnv: Rule = {
|
|
398
532
|
surface: "path",
|