@gotgenes/pi-permission-system 18.0.1 → 18.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 +24 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +16 -0
- package/src/access-intent/bash/bash-path-resolver.ts +30 -10
- package/src/access-intent/bash/program.ts +9 -0
- package/src/access-intent/bash/token-classification.ts +39 -9
- package/src/denial-messages.ts +28 -5
- package/src/handlers/gates/bash-external-directory.ts +7 -2
- package/src/handlers/gates/external-directory-messages.ts +11 -3
- package/src/handlers/gates/external-directory.ts +3 -0
- package/src/handlers/gates/tool-call-gate-pipeline.ts +13 -2
- package/src/permission-manager.ts +51 -1
- package/src/permission-session.ts +12 -0
- package/src/rule.ts +1 -1
- package/src/types.ts +11 -0
- package/test/access-intent/access-path.test.ts +61 -0
- package/test/access-intent/bash/program.test.ts +57 -0
- package/test/access-intent/bash/token-classification.test.ts +47 -0
- package/test/bash-external-directory.test.ts +18 -4
- package/test/composition-root.test.ts +73 -0
- package/test/denial-messages.test.ts +63 -5
- package/test/handlers/external-directory-integration.test.ts +6 -1
- package/test/handlers/gates/external-directory-messages.test.ts +26 -2
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +26 -0
- package/test/helpers/gate-fixtures.ts +7 -1
- package/test/helpers/session-fixtures.ts +8 -1
- package/test/permission-manager-unified.test.ts +81 -0
- package/test/permission-resolver.test.ts +8 -1
|
@@ -91,6 +91,63 @@ describe("BashProgram", () => {
|
|
|
91
91
|
expect(fileCandidate?.path.matchValues()).toEqual(["src/foo.ts"]);
|
|
92
92
|
expect(fileCandidate?.path.boundaryValue()).toBe("");
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
describe("rule-driven bare-token promotion (#509)", () => {
|
|
96
|
+
it("promotes a bare token when the matcher says it is promotable", async () => {
|
|
97
|
+
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
98
|
+
const program = await BashProgram.parse(
|
|
99
|
+
"cat id_rsa",
|
|
100
|
+
normalizer,
|
|
101
|
+
isPromotable,
|
|
102
|
+
);
|
|
103
|
+
const candidates = program.pathRuleCandidates();
|
|
104
|
+
expect(candidates.map(({ token }) => token)).toEqual(["id_rsa"]);
|
|
105
|
+
expect(candidates[0].path.matchValues()).toEqual([
|
|
106
|
+
"/projects/my-app/id_rsa",
|
|
107
|
+
"id_rsa",
|
|
108
|
+
]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("does not promote a bare token the matcher rejects", async () => {
|
|
112
|
+
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
113
|
+
const program = await BashProgram.parse(
|
|
114
|
+
"git status",
|
|
115
|
+
normalizer,
|
|
116
|
+
isPromotable,
|
|
117
|
+
);
|
|
118
|
+
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("does not promote any bare token with the default no-op matcher", async () => {
|
|
122
|
+
const program = await BashProgram.parse("cat id_rsa", normalizer);
|
|
123
|
+
expect(program.pathRuleCandidates()).toHaveLength(0);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("keeps a promoted token literal-only after an unknown cd (#393)", async () => {
|
|
127
|
+
const isPromotable = (token: string): boolean => token === "id_rsa";
|
|
128
|
+
const program = await BashProgram.parse(
|
|
129
|
+
'cd "$DIR" && cat id_rsa',
|
|
130
|
+
normalizer,
|
|
131
|
+
isPromotable,
|
|
132
|
+
);
|
|
133
|
+
const candidate = program
|
|
134
|
+
.pathRuleCandidates()
|
|
135
|
+
.find((c) => c.token === "id_rsa");
|
|
136
|
+
expect(candidate?.path.matchValues()).toEqual(["id_rsa"]);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("does not double-promote a token the shape gate already accepts", async () => {
|
|
140
|
+
// ./id_rsa already passes classifyTokenAsRuleCandidate; the promoted
|
|
141
|
+
// fallback must not run (and must not duplicate the candidate).
|
|
142
|
+
const isPromotable = (): boolean => true;
|
|
143
|
+
const program = await BashProgram.parse(
|
|
144
|
+
"cat ./id_rsa",
|
|
145
|
+
normalizer,
|
|
146
|
+
isPromotable,
|
|
147
|
+
);
|
|
148
|
+
expect(program.pathRuleCandidates()).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
94
151
|
});
|
|
95
152
|
|
|
96
153
|
describe("externalPaths", () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
classifyPromotedRuleCandidate,
|
|
4
5
|
classifyTokenAsPathCandidate,
|
|
5
6
|
classifyTokenAsRuleCandidate,
|
|
6
7
|
} from "#src/access-intent/bash/token-classification";
|
|
@@ -314,3 +315,49 @@ describe("classifyTokenAsRuleCandidate", () => {
|
|
|
314
315
|
}
|
|
315
316
|
});
|
|
316
317
|
});
|
|
318
|
+
|
|
319
|
+
describe("classifyPromotedRuleCandidate", () => {
|
|
320
|
+
test("shape-eligible bare token promoted when predicate returns true", () => {
|
|
321
|
+
expect(classifyPromotedRuleCandidate("id_rsa", () => true)).toBe("id_rsa");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("shape-eligible bare token rejected when predicate returns false", () => {
|
|
325
|
+
expect(classifyPromotedRuleCandidate("id_rsa", () => false)).toBeNull();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("predicate receives the raw token", () => {
|
|
329
|
+
const isPromotable = (token: string): boolean => token === "key.pem";
|
|
330
|
+
expect(classifyPromotedRuleCandidate("key.pem", isPromotable)).toBe(
|
|
331
|
+
"key.pem",
|
|
332
|
+
);
|
|
333
|
+
expect(classifyPromotedRuleCandidate("other.pem", isPromotable)).toBeNull();
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("shared rejection still applies regardless of the predicate", () => {
|
|
337
|
+
test("flag (leading dash) → null", () => {
|
|
338
|
+
expect(classifyPromotedRuleCandidate("-r", () => true)).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("env assignment → null", () => {
|
|
342
|
+
expect(classifyPromotedRuleCandidate("FOO=/bar", () => true)).toBeNull();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("URL → null", () => {
|
|
346
|
+
expect(
|
|
347
|
+
classifyPromotedRuleCandidate("https://example.com", () => true),
|
|
348
|
+
).toBeNull();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test("@scope/package → null", () => {
|
|
352
|
+
expect(classifyPromotedRuleCandidate("@foo/bar", () => true)).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("regex metacharacters → null", () => {
|
|
356
|
+
expect(classifyPromotedRuleCandidate("foo.*", () => true)).toBeNull();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("empty string → null", () => {
|
|
360
|
+
expect(classifyPromotedRuleCandidate("", () => true)).toBeNull();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|
|
@@ -924,7 +924,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
924
924
|
test("includes command, external paths, and CWD", () => {
|
|
925
925
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
926
926
|
"cat /etc/hosts",
|
|
927
|
-
["/etc/hosts"],
|
|
927
|
+
[{ path: "/etc/hosts" }],
|
|
928
928
|
"/projects/my-app",
|
|
929
929
|
);
|
|
930
930
|
expect(result).toContain("cat /etc/hosts");
|
|
@@ -935,7 +935,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
935
935
|
test("includes agent name when provided", () => {
|
|
936
936
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
937
937
|
"cat /etc/hosts",
|
|
938
|
-
["/etc/hosts"],
|
|
938
|
+
[{ path: "/etc/hosts" }],
|
|
939
939
|
"/projects/my-app",
|
|
940
940
|
"my-agent",
|
|
941
941
|
);
|
|
@@ -945,12 +945,26 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
945
945
|
test("shows multiple external paths", () => {
|
|
946
946
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
947
947
|
"diff /etc/hosts /var/log/syslog",
|
|
948
|
-
["/etc/hosts", "/var/log/syslog"],
|
|
948
|
+
[{ path: "/etc/hosts" }, { path: "/var/log/syslog" }],
|
|
949
949
|
"/projects/my-app",
|
|
950
950
|
);
|
|
951
951
|
expect(result).toContain("/etc/hosts");
|
|
952
952
|
expect(result).toContain("/var/log/syslog");
|
|
953
953
|
});
|
|
954
|
+
|
|
955
|
+
test("discloses the resolved target per path when it differs", () => {
|
|
956
|
+
const result = formatBashExternalDirectoryAskPrompt(
|
|
957
|
+
"cat demo-symlink-passwd /etc/hosts",
|
|
958
|
+
[
|
|
959
|
+
{ path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
|
|
960
|
+
{ path: "/etc/hosts" },
|
|
961
|
+
],
|
|
962
|
+
"/projects/my-app",
|
|
963
|
+
);
|
|
964
|
+
expect(result).toBe(
|
|
965
|
+
"Current agent requested bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/projects/my-app': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts. Allow this external directory access?",
|
|
966
|
+
);
|
|
967
|
+
});
|
|
954
968
|
});
|
|
955
969
|
|
|
956
970
|
describe("Windows drive-letter paths (win32 semantics)", () => {
|
|
@@ -996,7 +1010,7 @@ describe("bash external-directory denial messages (centralized)", () => {
|
|
|
996
1010
|
const result = formatDenyReason({
|
|
997
1011
|
kind: "bash_external_directory",
|
|
998
1012
|
command: "cat /etc/hosts",
|
|
999
|
-
externalPaths: ["/etc/hosts"],
|
|
1013
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
1000
1014
|
cwd: "/projects/my-app",
|
|
1001
1015
|
});
|
|
1002
1016
|
expect(result).toContain("cat /etc/hosts");
|
|
@@ -501,6 +501,79 @@ describe("service path queries evaluate the supplied path (#503)", () => {
|
|
|
501
501
|
});
|
|
502
502
|
});
|
|
503
503
|
|
|
504
|
+
describe("bash bare-filename path gating (#509)", () => {
|
|
505
|
+
// Before #509 a bash bare-filename argument (`cat id_rsa`) bypassed the
|
|
506
|
+
// `path` surface entirely: the broad classifier only accepted tokens
|
|
507
|
+
// starting with `.`, containing `/`, containing `..`, or a Windows
|
|
508
|
+
// drive-letter absolute path. The same file accessed via a prefixed path
|
|
509
|
+
// (`cat ./id_rsa`) or the `read` tool was already gated. Rule-driven
|
|
510
|
+
// promotion closes the gap for a bare token matching a specific, non-`*`
|
|
511
|
+
// `path` deny/ask rule — the literal repro from the issue.
|
|
512
|
+
|
|
513
|
+
async function fireBashToolCall(
|
|
514
|
+
pi: ReturnType<typeof makeFakePi>,
|
|
515
|
+
ctx: unknown,
|
|
516
|
+
command: string,
|
|
517
|
+
): Promise<{ block?: true; reason?: string }> {
|
|
518
|
+
return (await pi.fire(
|
|
519
|
+
"tool_call",
|
|
520
|
+
{ name: "bash", input: { command }, toolCallId: "tc-1" },
|
|
521
|
+
ctx,
|
|
522
|
+
)) as { block?: true; reason?: string };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
it("denies a bare filename matching a specific path deny rule", async () => {
|
|
526
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
|
|
527
|
+
writeGlobalConfig({
|
|
528
|
+
permission: { "*": "allow", path: { id_rsa: "deny" } },
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const pi = makeFakePi({ events: createEventBus() });
|
|
532
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
533
|
+
const ctx = makeChildCtx(cwd, "bare-token-session-deny");
|
|
534
|
+
await fireSessionStart(pi, ctx);
|
|
535
|
+
|
|
536
|
+
const result = await fireBashToolCall(pi, ctx, "cat id_rsa");
|
|
537
|
+
expect(result.block).toBe(true);
|
|
538
|
+
|
|
539
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("denies a bare filename matching a wildcard path deny rule", async () => {
|
|
543
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
|
|
544
|
+
writeGlobalConfig({
|
|
545
|
+
permission: { "*": "allow", path: { "*.pem": "deny" } },
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const pi = makeFakePi({ events: createEventBus() });
|
|
549
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
550
|
+
const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
|
|
551
|
+
await fireSessionStart(pi, ctx);
|
|
552
|
+
|
|
553
|
+
const result = await fireBashToolCall(pi, ctx, "cat key.pem");
|
|
554
|
+
expect(result.block).toBe(true);
|
|
555
|
+
|
|
556
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("leaves a bare token that does not match any path rule unaffected", async () => {
|
|
560
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-bare-token-cwd-"));
|
|
561
|
+
writeGlobalConfig({
|
|
562
|
+
permission: { "*": "allow", path: { id_rsa: "deny" } },
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
const pi = makeFakePi({ events: createEventBus() });
|
|
566
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
567
|
+
const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
|
|
568
|
+
await fireSessionStart(pi, ctx);
|
|
569
|
+
|
|
570
|
+
const result = await fireBashToolCall(pi, ctx, "git status");
|
|
571
|
+
expect(result.block).toBeUndefined();
|
|
572
|
+
|
|
573
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
504
577
|
describe("multi-instance global service interplay", () => {
|
|
505
578
|
// The fix (#302) scopes the process-global service slot to the publishing
|
|
506
579
|
// instance. The parent publishes at its session_start; an in-process child
|
|
@@ -242,6 +242,20 @@ describe("formatDenyReason", () => {
|
|
|
242
242
|
"[pi-permission-system] Agent 'sec-agent' is not permitted to run tool 'read' for path '/etc/passwd' outside working directory '/project'.",
|
|
243
243
|
);
|
|
244
244
|
});
|
|
245
|
+
|
|
246
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
247
|
+
expect(
|
|
248
|
+
formatDenyReason({
|
|
249
|
+
kind: "external_directory",
|
|
250
|
+
toolName: "read",
|
|
251
|
+
pathValue: "demo-symlink-passwd",
|
|
252
|
+
resolvedPath: "/etc/passwd",
|
|
253
|
+
cwd: "/project",
|
|
254
|
+
}),
|
|
255
|
+
).toBe(
|
|
256
|
+
"[pi-permission-system] Current agent is not permitted to run tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/project'.",
|
|
257
|
+
);
|
|
258
|
+
});
|
|
245
259
|
});
|
|
246
260
|
|
|
247
261
|
describe("bash_external_directory context", () => {
|
|
@@ -250,7 +264,7 @@ describe("formatDenyReason", () => {
|
|
|
250
264
|
formatDenyReason({
|
|
251
265
|
kind: "bash_external_directory",
|
|
252
266
|
command: "cat /etc/hosts",
|
|
253
|
-
externalPaths: ["/etc/hosts"],
|
|
267
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
254
268
|
cwd: "/project",
|
|
255
269
|
}),
|
|
256
270
|
).toBe(
|
|
@@ -263,7 +277,7 @@ describe("formatDenyReason", () => {
|
|
|
263
277
|
formatDenyReason({
|
|
264
278
|
kind: "bash_external_directory",
|
|
265
279
|
command: "cp /etc/hosts /tmp/out",
|
|
266
|
-
externalPaths: ["/etc/hosts", "/tmp/out"],
|
|
280
|
+
externalPaths: [{ path: "/etc/hosts" }, { path: "/tmp/out" }],
|
|
267
281
|
cwd: "/project",
|
|
268
282
|
agentName: "my-agent",
|
|
269
283
|
}),
|
|
@@ -271,6 +285,22 @@ describe("formatDenyReason", () => {
|
|
|
271
285
|
"[pi-permission-system] Agent 'my-agent' is not permitted to run bash command 'cp /etc/hosts /tmp/out' which references path(s) outside working directory '/project': /etc/hosts, /tmp/out.",
|
|
272
286
|
);
|
|
273
287
|
});
|
|
288
|
+
|
|
289
|
+
test("discloses resolved targets per path when they differ", () => {
|
|
290
|
+
expect(
|
|
291
|
+
formatDenyReason({
|
|
292
|
+
kind: "bash_external_directory",
|
|
293
|
+
command: "cat demo-symlink-passwd /etc/hosts",
|
|
294
|
+
externalPaths: [
|
|
295
|
+
{ path: "demo-symlink-passwd", resolvedPath: "/etc/passwd" },
|
|
296
|
+
{ path: "/etc/hosts" },
|
|
297
|
+
],
|
|
298
|
+
cwd: "/project",
|
|
299
|
+
}),
|
|
300
|
+
).toBe(
|
|
301
|
+
"[pi-permission-system] Current agent is not permitted to run bash command 'cat demo-symlink-passwd /etc/hosts' which references path(s) outside working directory '/project': demo-symlink-passwd (resolves to '/etc/passwd'), /etc/hosts.",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
274
304
|
});
|
|
275
305
|
|
|
276
306
|
describe("bash_path context", () => {
|
|
@@ -403,12 +433,26 @@ describe("formatUnavailableReason", () => {
|
|
|
403
433
|
);
|
|
404
434
|
});
|
|
405
435
|
|
|
436
|
+
test("external_directory discloses the resolved path when it differs from the typed path", () => {
|
|
437
|
+
expect(
|
|
438
|
+
formatUnavailableReason({
|
|
439
|
+
kind: "external_directory",
|
|
440
|
+
toolName: "read",
|
|
441
|
+
pathValue: "demo-symlink-passwd",
|
|
442
|
+
resolvedPath: "/etc/passwd",
|
|
443
|
+
cwd: "/project",
|
|
444
|
+
}),
|
|
445
|
+
).toBe(
|
|
446
|
+
"[pi-permission-system] Accessing 'demo-symlink-passwd' (resolves to '/etc/passwd') outside the working directory requires approval, but no interactive UI is available.",
|
|
447
|
+
);
|
|
448
|
+
});
|
|
449
|
+
|
|
406
450
|
test("bash_external_directory", () => {
|
|
407
451
|
expect(
|
|
408
452
|
formatUnavailableReason({
|
|
409
453
|
kind: "bash_external_directory",
|
|
410
454
|
command: "cat /etc/hosts",
|
|
411
|
-
externalPaths: ["/etc/hosts"],
|
|
455
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
412
456
|
cwd: "/project",
|
|
413
457
|
}),
|
|
414
458
|
).toBe(
|
|
@@ -539,6 +583,20 @@ describe("formatUserDeniedReason", () => {
|
|
|
539
583
|
"[pi-permission-system] User denied external directory access for tool 'edit' path '/etc/hosts'. Reason: too risky.",
|
|
540
584
|
);
|
|
541
585
|
});
|
|
586
|
+
|
|
587
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
588
|
+
expect(
|
|
589
|
+
formatUserDeniedReason({
|
|
590
|
+
kind: "external_directory",
|
|
591
|
+
toolName: "edit",
|
|
592
|
+
pathValue: "demo-symlink-hosts",
|
|
593
|
+
resolvedPath: "/etc/hosts",
|
|
594
|
+
cwd: "/project",
|
|
595
|
+
}),
|
|
596
|
+
).toBe(
|
|
597
|
+
"[pi-permission-system] User denied external directory access for tool 'edit' path 'demo-symlink-hosts' (resolves to '/etc/hosts').",
|
|
598
|
+
);
|
|
599
|
+
});
|
|
542
600
|
});
|
|
543
601
|
|
|
544
602
|
describe("bash_external_directory context", () => {
|
|
@@ -547,7 +605,7 @@ describe("formatUserDeniedReason", () => {
|
|
|
547
605
|
formatUserDeniedReason({
|
|
548
606
|
kind: "bash_external_directory",
|
|
549
607
|
command: "rm /etc/hosts",
|
|
550
|
-
externalPaths: ["/etc/hosts"],
|
|
608
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
551
609
|
cwd: "/project",
|
|
552
610
|
}),
|
|
553
611
|
).toBe(
|
|
@@ -561,7 +619,7 @@ describe("formatUserDeniedReason", () => {
|
|
|
561
619
|
{
|
|
562
620
|
kind: "bash_external_directory",
|
|
563
621
|
command: "rm /etc/hosts",
|
|
564
|
-
externalPaths: ["/etc/hosts"],
|
|
622
|
+
externalPaths: [{ path: "/etc/hosts" }],
|
|
565
623
|
cwd: "/project",
|
|
566
624
|
},
|
|
567
625
|
"dangerous",
|
|
@@ -47,7 +47,12 @@ describe("external_directory helper regression guard", () => {
|
|
|
47
47
|
it("formatExternalDirectoryAskPrompt is a callable function", () => {
|
|
48
48
|
expect(typeof formatExternalDirectoryAskPrompt).toBe("function");
|
|
49
49
|
expect(
|
|
50
|
-
formatExternalDirectoryAskPrompt(
|
|
50
|
+
formatExternalDirectoryAskPrompt(
|
|
51
|
+
"read",
|
|
52
|
+
"/outside/file",
|
|
53
|
+
undefined,
|
|
54
|
+
"/project",
|
|
55
|
+
),
|
|
51
56
|
).toContain("/outside/file");
|
|
52
57
|
});
|
|
53
58
|
|
|
@@ -15,6 +15,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
15
15
|
const result = formatExternalDirectoryAskPrompt(
|
|
16
16
|
"read",
|
|
17
17
|
"/etc/passwd",
|
|
18
|
+
undefined,
|
|
18
19
|
"/projects/my-app",
|
|
19
20
|
);
|
|
20
21
|
expect(result).toContain("Current agent");
|
|
@@ -27,6 +28,7 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
27
28
|
const result = formatExternalDirectoryAskPrompt(
|
|
28
29
|
"write",
|
|
29
30
|
"/tmp/out.txt",
|
|
31
|
+
undefined,
|
|
30
32
|
"/projects/my-app",
|
|
31
33
|
"my-agent",
|
|
32
34
|
);
|
|
@@ -34,13 +36,35 @@ describe("formatExternalDirectoryAskPrompt", () => {
|
|
|
34
36
|
expect(result).toContain("write");
|
|
35
37
|
expect(result).toContain("/tmp/out.txt");
|
|
36
38
|
});
|
|
39
|
+
|
|
40
|
+
test("discloses the resolved path when it differs from the typed path", () => {
|
|
41
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
42
|
+
"read",
|
|
43
|
+
"demo-symlink-passwd",
|
|
44
|
+
"/etc/passwd",
|
|
45
|
+
"/projects/my-app",
|
|
46
|
+
);
|
|
47
|
+
expect(result).toBe(
|
|
48
|
+
"Current agent requested tool 'read' for path 'demo-symlink-passwd' (resolves to '/etc/passwd') outside working directory '/projects/my-app'. Allow this external directory access?",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("omits the disclosure when resolvedPath is undefined", () => {
|
|
53
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
54
|
+
"read",
|
|
55
|
+
"/etc/passwd",
|
|
56
|
+
undefined,
|
|
57
|
+
"/projects/my-app",
|
|
58
|
+
);
|
|
59
|
+
expect(result).not.toContain("resolves to");
|
|
60
|
+
});
|
|
37
61
|
});
|
|
38
62
|
|
|
39
63
|
describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
40
64
|
test("includes command, paths, cwd, and agent name", () => {
|
|
41
65
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
42
66
|
"cat /etc/passwd",
|
|
43
|
-
["/etc/passwd"],
|
|
67
|
+
[{ path: "/etc/passwd" }],
|
|
44
68
|
"/projects/my-app",
|
|
45
69
|
"my-agent",
|
|
46
70
|
);
|
|
@@ -53,7 +77,7 @@ describe("formatBashExternalDirectoryAskPrompt", () => {
|
|
|
53
77
|
test("uses 'Current agent' when no agent name provided", () => {
|
|
54
78
|
const result = formatBashExternalDirectoryAskPrompt(
|
|
55
79
|
"ls /tmp",
|
|
56
|
-
["/tmp"],
|
|
80
|
+
[{ path: "/tmp" }],
|
|
57
81
|
"/projects/my-app",
|
|
58
82
|
);
|
|
59
83
|
expect(result).toContain("Current agent");
|
|
@@ -186,6 +186,7 @@ describe("ToolCallGatePipeline", () => {
|
|
|
186
186
|
expect(mockBashProgramParse).toHaveBeenCalledWith(
|
|
187
187
|
"echo hello",
|
|
188
188
|
expect.any(PathNormalizer),
|
|
189
|
+
expect.any(Function),
|
|
189
190
|
);
|
|
190
191
|
});
|
|
191
192
|
|
|
@@ -202,6 +203,31 @@ describe("ToolCallGatePipeline", () => {
|
|
|
202
203
|
|
|
203
204
|
expect(mockBashProgramParse).not.toHaveBeenCalled();
|
|
204
205
|
});
|
|
206
|
+
|
|
207
|
+
it("passes the session's promotable path-token matcher into BashProgram.parse (#509)", async () => {
|
|
208
|
+
const resolver = makeResolver(makeCheckResult());
|
|
209
|
+
const isPromotable = vi.fn((token: string) => token === "id_rsa");
|
|
210
|
+
const getPromotablePathTokenMatcher = vi.fn(() => isPromotable);
|
|
211
|
+
const inputs = makeGateInputs({ getPromotablePathTokenMatcher });
|
|
212
|
+
const { runner } = makeGateRunner();
|
|
213
|
+
const pipeline = new ToolCallGatePipeline(resolver, inputs);
|
|
214
|
+
|
|
215
|
+
await pipeline.evaluate(
|
|
216
|
+
makeTcc({
|
|
217
|
+
toolName: "bash",
|
|
218
|
+
input: { command: "cat id_rsa" },
|
|
219
|
+
agentName: "my-agent",
|
|
220
|
+
}),
|
|
221
|
+
runner,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
expect(getPromotablePathTokenMatcher).toHaveBeenCalledWith("my-agent");
|
|
225
|
+
expect(mockBashProgramParse).toHaveBeenCalledWith(
|
|
226
|
+
"cat id_rsa",
|
|
227
|
+
expect.any(PathNormalizer),
|
|
228
|
+
isPromotable,
|
|
229
|
+
);
|
|
230
|
+
});
|
|
205
231
|
});
|
|
206
232
|
|
|
207
233
|
// ── customExtractors threading (#352) ────────────────────────────────────
|
|
@@ -15,7 +15,7 @@ import type { ScopedPermissionResolver } from "#src/permission-resolver";
|
|
|
15
15
|
import type { SessionApprovalRecorder } from "#src/session-approval-recorder";
|
|
16
16
|
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
|
|
17
17
|
import type { ToolPreviewFormatterOptions } from "#src/tool-preview-formatter";
|
|
18
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
18
|
+
import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
|
|
19
19
|
|
|
20
20
|
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
21
21
|
|
|
@@ -255,6 +255,9 @@ export function makeGateInputs(
|
|
|
255
255
|
getInfrastructureReadDirs?: () => string[];
|
|
256
256
|
getToolPreviewLimits?: () => ToolPreviewFormatterOptions;
|
|
257
257
|
getPathNormalizer?: () => PathNormalizer;
|
|
258
|
+
getPromotablePathTokenMatcher?: (
|
|
259
|
+
agentName?: string,
|
|
260
|
+
) => PathRuleTokenMatcher;
|
|
258
261
|
} = {},
|
|
259
262
|
): ToolCallGateInputs {
|
|
260
263
|
return {
|
|
@@ -275,6 +278,9 @@ export function makeGateInputs(
|
|
|
275
278
|
vi.fn<() => PathNormalizer>(
|
|
276
279
|
() => new PathNormalizer(process.platform, "/test/cwd"),
|
|
277
280
|
),
|
|
281
|
+
getPromotablePathTokenMatcher:
|
|
282
|
+
overrides.getPromotablePathTokenMatcher ??
|
|
283
|
+
vi.fn<(agentName?: string) => PathRuleTokenMatcher>(() => () => false),
|
|
278
284
|
};
|
|
279
285
|
}
|
|
280
286
|
|
|
@@ -25,7 +25,11 @@ import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
|
|
|
25
25
|
import type { Ruleset } from "#src/rule";
|
|
26
26
|
import type { SessionLogger } from "#src/session-logger";
|
|
27
27
|
import { SessionRules } from "#src/session-rules";
|
|
28
|
-
import type {
|
|
28
|
+
import type {
|
|
29
|
+
PathRuleTokenMatcher,
|
|
30
|
+
PermissionCheckResult,
|
|
31
|
+
PermissionState,
|
|
32
|
+
} from "#src/types";
|
|
29
33
|
|
|
30
34
|
// ── Per-collaborator fake factories ────────────────────────────────────────
|
|
31
35
|
|
|
@@ -105,6 +109,9 @@ export function makeFakePermissionManager() {
|
|
|
105
109
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
106
110
|
.mockReturnValue("allow"),
|
|
107
111
|
getConfigIssues: vi.fn((): string[] => []),
|
|
112
|
+
getPromotablePathTokenMatcher: vi
|
|
113
|
+
.fn<(agentName?: string) => PathRuleTokenMatcher>()
|
|
114
|
+
.mockReturnValue(() => false),
|
|
108
115
|
};
|
|
109
116
|
}
|
|
110
117
|
|
|
@@ -1096,6 +1096,87 @@ describe("PermissionManager with in-memory PolicyLoader", () => {
|
|
|
1096
1096
|
).toBe(true);
|
|
1097
1097
|
});
|
|
1098
1098
|
});
|
|
1099
|
+
|
|
1100
|
+
describe("getPromotablePathTokenMatcher (#509)", () => {
|
|
1101
|
+
it("matches a bare token against a specific deny pattern", () => {
|
|
1102
|
+
const manager = makeInMemoryManager({
|
|
1103
|
+
global: { permission: { path: { id_rsa: "deny" } } },
|
|
1104
|
+
});
|
|
1105
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1106
|
+
expect(isPromotable("id_rsa")).toBe(true);
|
|
1107
|
+
expect(isPromotable("other_file")).toBe(false);
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it("matches a bare token against a specific ask wildcard pattern", () => {
|
|
1111
|
+
const manager = makeInMemoryManager({
|
|
1112
|
+
global: { permission: { path: { "*.pem": "ask" } } },
|
|
1113
|
+
});
|
|
1114
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1115
|
+
expect(isPromotable("key.pem")).toBe(true);
|
|
1116
|
+
expect(isPromotable("key.txt")).toBe(false);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("does not match against the universal '*' path pattern", () => {
|
|
1120
|
+
const manager = makeInMemoryManager({
|
|
1121
|
+
global: { permission: { path: { "*": "ask" } } },
|
|
1122
|
+
});
|
|
1123
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1124
|
+
expect(isPromotable("anything")).toBe(false);
|
|
1125
|
+
expect(isPromotable("status")).toBe(false);
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it("does not match against an allow-only path pattern", () => {
|
|
1129
|
+
const manager = makeInMemoryManager({
|
|
1130
|
+
global: { permission: { path: { id_rsa: "allow" } } },
|
|
1131
|
+
});
|
|
1132
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1133
|
+
expect(isPromotable("id_rsa")).toBe(false);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it("returns a no-op matcher when no path rules exist", () => {
|
|
1137
|
+
const manager = makeInMemoryManager({
|
|
1138
|
+
global: { permission: { read: "allow" } },
|
|
1139
|
+
});
|
|
1140
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1141
|
+
expect(isPromotable("id_rsa")).toBe(false);
|
|
1142
|
+
expect(isPromotable("anything")).toBe(false);
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
it("folds case on an injected win32 platform", () => {
|
|
1146
|
+
const manager = new PermissionManager({
|
|
1147
|
+
policyLoader: createInMemoryPolicyLoader({
|
|
1148
|
+
global: { permission: { path: { id_rsa: "deny" } } },
|
|
1149
|
+
}),
|
|
1150
|
+
platform: "win32",
|
|
1151
|
+
});
|
|
1152
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1153
|
+
expect(isPromotable("ID_RSA")).toBe(true);
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
it("stays case-sensitive on a POSIX platform", () => {
|
|
1157
|
+
const manager = new PermissionManager({
|
|
1158
|
+
policyLoader: createInMemoryPolicyLoader({
|
|
1159
|
+
global: { permission: { path: { id_rsa: "deny" } } },
|
|
1160
|
+
}),
|
|
1161
|
+
platform: "linux",
|
|
1162
|
+
});
|
|
1163
|
+
const isPromotable = manager.getPromotablePathTokenMatcher();
|
|
1164
|
+
expect(isPromotable("ID_RSA")).toBe(false);
|
|
1165
|
+
});
|
|
1166
|
+
|
|
1167
|
+
it("scopes to the requested agent's composed rules", () => {
|
|
1168
|
+
const manager = makeInMemoryManager({
|
|
1169
|
+
global: { permission: { path: { id_rsa: "deny" } } },
|
|
1170
|
+
agent: {
|
|
1171
|
+
coder: { permission: { path: { "secret.key": "deny" } } },
|
|
1172
|
+
},
|
|
1173
|
+
});
|
|
1174
|
+
const globalMatcher = manager.getPromotablePathTokenMatcher();
|
|
1175
|
+
const agentMatcher = manager.getPromotablePathTokenMatcher("coder");
|
|
1176
|
+
expect(globalMatcher("secret.key")).toBe(false);
|
|
1177
|
+
expect(agentMatcher("secret.key")).toBe(true);
|
|
1178
|
+
});
|
|
1179
|
+
});
|
|
1099
1180
|
});
|
|
1100
1181
|
|
|
1101
1182
|
// ---------------------------------------------------------------------------
|
|
@@ -6,7 +6,11 @@ import { PermissionResolver } from "#src/permission-resolver";
|
|
|
6
6
|
import type { Ruleset } from "#src/rule";
|
|
7
7
|
import { SessionApproval } from "#src/session-approval";
|
|
8
8
|
import { SessionRules } from "#src/session-rules";
|
|
9
|
-
import type {
|
|
9
|
+
import type {
|
|
10
|
+
PathRuleTokenMatcher,
|
|
11
|
+
PermissionCheckResult,
|
|
12
|
+
PermissionState,
|
|
13
|
+
} from "#src/types";
|
|
10
14
|
|
|
11
15
|
function makePermissionManager() {
|
|
12
16
|
return {
|
|
@@ -28,6 +32,9 @@ function makePermissionManager() {
|
|
|
28
32
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
29
33
|
.mockReturnValue("allow"),
|
|
30
34
|
getConfigIssues: vi.fn((): string[] => []),
|
|
35
|
+
getPromotablePathTokenMatcher: vi
|
|
36
|
+
.fn<(agentName?: string) => PathRuleTokenMatcher>()
|
|
37
|
+
.mockReturnValue(() => false),
|
|
31
38
|
};
|
|
32
39
|
}
|
|
33
40
|
|