@gotgenes/pi-permission-system 18.0.2 → 18.1.1
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 +22 -0
- package/docs/assets/logo.png +0 -0
- package/docs/assets/logo.svg +213 -0
- package/docs/configuration.md +689 -0
- package/docs/cross-extension-api.md +525 -0
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +201 -0
- package/docs/guides/upstream-issue-template.md +113 -0
- package/docs/migration/legacy-to-flat.md +365 -0
- package/docs/opencode-compatibility.md +213 -0
- package/docs/session-approvals.md +68 -0
- package/docs/subagent-integration.md +102 -0
- package/docs/troubleshooting.md +53 -0
- package/package.json +5 -1
- 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/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/bash/program.test.ts +57 -0
- package/test/access-intent/bash/token-classification.test.ts +47 -0
- package/test/composition-root.test.ts +73 -0
- 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
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
ToolPreviewFormatter,
|
|
11
11
|
type ToolPreviewFormatterOptions,
|
|
12
12
|
} from "#src/tool-preview-formatter";
|
|
13
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
13
|
+
import type { PathRuleTokenMatcher, PermissionCheckResult } from "#src/types";
|
|
14
14
|
import { getNonEmptyString, toRecord } from "#src/value-guards";
|
|
15
15
|
import { resolveBashCommandCheck } from "./bash-command";
|
|
16
16
|
import { describeBashExternalDirectoryGate } from "./bash-external-directory";
|
|
@@ -42,6 +42,11 @@ export interface ToolCallGateInputs {
|
|
|
42
42
|
getToolPreviewLimits(): ToolPreviewFormatterOptions;
|
|
43
43
|
/** The session's path normalizer (platform + cwd baked in). */
|
|
44
44
|
getPathNormalizer(): PathNormalizer;
|
|
45
|
+
/**
|
|
46
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
47
|
+
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
48
|
+
*/
|
|
49
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
/**
|
|
@@ -73,7 +78,13 @@ export class ToolCallGatePipeline {
|
|
|
73
78
|
const normalizer = this.inputs.getPathNormalizer();
|
|
74
79
|
const bashProgram =
|
|
75
80
|
tcc.toolName === "bash" && command
|
|
76
|
-
? await BashProgram.parse(
|
|
81
|
+
? await BashProgram.parse(
|
|
82
|
+
command,
|
|
83
|
+
normalizer,
|
|
84
|
+
this.inputs.getPromotablePathTokenMatcher(
|
|
85
|
+
tcc.agentName ?? undefined,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
77
88
|
: null;
|
|
78
89
|
|
|
79
90
|
const formatter = new ToolPreviewFormatter(
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
type ResolvedPolicyPaths,
|
|
16
16
|
} from "./policy-loader";
|
|
17
17
|
import type { Rule, RuleOrigin, Ruleset } from "./rule";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
evaluate,
|
|
20
|
+
evaluateAnyValue,
|
|
21
|
+
evaluateFirst,
|
|
22
|
+
pathMatchOptions,
|
|
23
|
+
} from "./rule";
|
|
19
24
|
import { mergeScopesWithOrigins } from "./scope-merge";
|
|
20
25
|
import {
|
|
21
26
|
composeRuleset,
|
|
@@ -24,10 +29,12 @@ import {
|
|
|
24
29
|
} from "./synthesize";
|
|
25
30
|
import type {
|
|
26
31
|
FlatPermissionConfig,
|
|
32
|
+
PathRuleTokenMatcher,
|
|
27
33
|
PermissionCheckResult,
|
|
28
34
|
PermissionState,
|
|
29
35
|
} from "./types";
|
|
30
36
|
import { isPermissionState } from "./value-guards";
|
|
37
|
+
import { wildcardMatch } from "./wildcard-matcher";
|
|
31
38
|
|
|
32
39
|
const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
|
|
33
40
|
"bash",
|
|
@@ -43,6 +50,9 @@ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
|
|
|
43
50
|
/** Universal fallback when permission["*"] is absent from all scopes. */
|
|
44
51
|
const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
|
|
45
52
|
|
|
53
|
+
/** Promotion predicate matching no token — the no-`path`-rules default (#509). */
|
|
54
|
+
const NO_PROMOTION: PathRuleTokenMatcher = () => false;
|
|
55
|
+
|
|
46
56
|
type FileCacheEntry<TValue> = {
|
|
47
57
|
stamp: string;
|
|
48
58
|
value: TValue;
|
|
@@ -76,6 +86,15 @@ export interface ScopedPermissionManager {
|
|
|
76
86
|
): PermissionCheckResult;
|
|
77
87
|
getToolPermission(toolName: string, agentName?: string): PermissionState;
|
|
78
88
|
getConfigIssues(agentName?: string): string[];
|
|
89
|
+
/**
|
|
90
|
+
* Build a predicate deciding whether a bare bash token should be promoted
|
|
91
|
+
* into the `path` rule-candidate surface (#509).
|
|
92
|
+
*
|
|
93
|
+
* Matches against specific (non-`*`) `path`-surface config rules whose
|
|
94
|
+
* action is `deny` or `ask` — an allow rule never gates, and `"*"` would
|
|
95
|
+
* promote every bare bash argument.
|
|
96
|
+
*/
|
|
97
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher;
|
|
79
98
|
}
|
|
80
99
|
|
|
81
100
|
export interface PermissionManagerOptions extends PolicyLoaderOptions {
|
|
@@ -211,6 +230,37 @@ export class PermissionManager implements ScopedPermissionManager {
|
|
|
211
230
|
return composedRules.filter((r) => r.layer === "config");
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Build a predicate deciding whether a bare bash token should be promoted
|
|
235
|
+
* into the `path` rule-candidate surface (#509).
|
|
236
|
+
*
|
|
237
|
+
* Filters the composed config ruleset to specific (non-`*`) `path`-surface
|
|
238
|
+
* deny/ask patterns, then returns a closure matching a token against them
|
|
239
|
+
* with the platform-correct fold (Windows case-and-separator matching, same
|
|
240
|
+
* as {@link pathMatchOptions} applies for evaluation) so promotion agrees
|
|
241
|
+
* with the later `path`-surface decision.
|
|
242
|
+
*
|
|
243
|
+
* Returns a matcher rejecting every token when no such rule exists — the
|
|
244
|
+
* default-config case is unaffected by promotion.
|
|
245
|
+
*/
|
|
246
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
247
|
+
const { composedRules } = this.resolvePermissions(agentName);
|
|
248
|
+
const patterns = composedRules
|
|
249
|
+
.filter(
|
|
250
|
+
(r) =>
|
|
251
|
+
r.layer === "config" &&
|
|
252
|
+
r.surface === "path" &&
|
|
253
|
+
r.pattern !== "*" &&
|
|
254
|
+
r.action !== "allow",
|
|
255
|
+
)
|
|
256
|
+
.map((r) => r.pattern);
|
|
257
|
+
if (patterns.length === 0) return NO_PROMOTION;
|
|
258
|
+
|
|
259
|
+
const matchOptions = pathMatchOptions("path", this.platform);
|
|
260
|
+
return (token) =>
|
|
261
|
+
patterns.some((pattern) => wildcardMatch(pattern, token, matchOptions));
|
|
262
|
+
}
|
|
263
|
+
|
|
214
264
|
/**
|
|
215
265
|
* Get the tool-level permission state for a tool, without considering
|
|
216
266
|
* command-level rules. Used for tool injection decisions.
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
resolveToolPreviewLimits,
|
|
20
20
|
type ToolPreviewFormatterOptions,
|
|
21
21
|
} from "./tool-preview-formatter";
|
|
22
|
+
import type { PathRuleTokenMatcher } from "./types";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Encapsulates all mutable session state and exposes operations instead of
|
|
@@ -214,4 +215,15 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
214
215
|
getPathNormalizer(): PathNormalizer {
|
|
215
216
|
return this.pathNormalizer;
|
|
216
217
|
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
221
|
+
* `path` rule-candidate surface (#509), scoped to the given agent.
|
|
222
|
+
*
|
|
223
|
+
* Straight delegate to `permissionManager.getPromotablePathTokenMatcher` —
|
|
224
|
+
* the manager owns the composed ruleset and the platform-correct match.
|
|
225
|
+
*/
|
|
226
|
+
getPromotablePathTokenMatcher(agentName?: string): PathRuleTokenMatcher {
|
|
227
|
+
return this.permissionManager.getPromotablePathTokenMatcher(agentName);
|
|
228
|
+
}
|
|
217
229
|
}
|
package/src/rule.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function evaluate(
|
|
|
74
74
|
* pattern→value match (case and separators) so mixed-case / forward-slash
|
|
75
75
|
* overrides still match. The surface→surface match stays exact.
|
|
76
76
|
*/
|
|
77
|
-
function pathMatchOptions(
|
|
77
|
+
export function pathMatchOptions(
|
|
78
78
|
surface: string,
|
|
79
79
|
platform: NodeJS.Platform,
|
|
80
80
|
): { caseInsensitive: true; windowsSeparators: true } | undefined {
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,17 @@ export interface DenyWithReason {
|
|
|
16
16
|
/** A pattern value: a PermissionState string OR a DenyWithReason object. */
|
|
17
17
|
export type PatternValue = PermissionState | DenyWithReason;
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Predicate deciding whether a bare bash token should be promoted into the
|
|
21
|
+
* `path` rule-candidate surface.
|
|
22
|
+
*
|
|
23
|
+
* Built by `PermissionManager.getPromotablePathTokenMatcher` from the
|
|
24
|
+
* composed config ruleset (specific, non-`*` `path` deny/ask patterns) and
|
|
25
|
+
* threaded through to `BashPathResolver` so promotion policy stays in the
|
|
26
|
+
* manager while the bash layer only asks the predicate.
|
|
27
|
+
*/
|
|
28
|
+
export type PathRuleTokenMatcher = (token: string) => boolean;
|
|
29
|
+
|
|
19
30
|
/**
|
|
20
31
|
* The on-disk permission shape inside the `"permission"` key.
|
|
21
32
|
* A surface value is a PermissionState string (shorthand for `{ "*": action }`)
|
|
@@ -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
|
+
});
|
|
@@ -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
|
|
@@ -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
|
|