@gotgenes/pi-permission-system 11.0.0 → 13.0.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 +43 -0
- package/README.md +9 -11
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/config-modal.ts +3 -7
- 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/handlers/gates/external-directory.ts +8 -2
- package/src/handlers/gates/path.ts +4 -2
- package/src/handlers/gates/tool-call-gate-pipeline.ts +5 -2
- package/src/index.ts +8 -2
- package/src/input-normalizer.ts +17 -11
- package/src/path-utils.ts +122 -0
- package/src/permission-manager.ts +81 -17
- package/src/permission-resolver.ts +24 -0
- package/src/permission-session.ts +2 -4
- package/src/permissions-service.ts +12 -0
- package/src/rule.ts +61 -11
- package/src/service.ts +24 -0
- package/src/tool-access-extractor-registry.ts +68 -0
- package/test/bash-external-directory.test.ts +1 -81
- package/test/composition-root.test.ts +36 -0
- package/test/config-modal.test.ts +7 -10
- package/test/config-pipeline.test.ts +90 -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/external-directory.test.ts +54 -0
- package/test/handlers/gates/path.test.ts +72 -0
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +64 -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/path-utils.test.ts +135 -0
- package/test/permission-manager-unified.test.ts +134 -0
- package/test/permission-resolver.test.ts +69 -0
- package/test/permissions-service.test.ts +35 -1
- package/test/rule.test.ts +74 -1
- package/test/service-lifecycle.test.ts +1 -0
- package/test/service.test.ts +53 -0
- package/test/tool-access-extractor-registry.test.ts +77 -0
|
@@ -26,10 +26,13 @@ import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
|
26
26
|
*/
|
|
27
27
|
export function makeResolver(defaultCheck?: PermissionCheckResult) {
|
|
28
28
|
const resolve = vi.fn<ScopedPermissionResolver["resolve"]>();
|
|
29
|
+
const resolvePathPolicy =
|
|
30
|
+
vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
|
|
29
31
|
if (defaultCheck) {
|
|
30
32
|
resolve.mockReturnValue(defaultCheck);
|
|
33
|
+
resolvePathPolicy.mockReturnValue(defaultCheck);
|
|
31
34
|
}
|
|
32
|
-
return { resolve };
|
|
35
|
+
return { resolve, resolvePathPolicy };
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/**
|
|
@@ -92,6 +95,7 @@ export function makeGateRunner(
|
|
|
92
95
|
overrides: {
|
|
93
96
|
resolveResult?: PermissionCheckResult;
|
|
94
97
|
resolve?: ScopedPermissionResolver["resolve"];
|
|
98
|
+
resolvePathPolicy?: ScopedPermissionResolver["resolvePathPolicy"];
|
|
95
99
|
recordSessionApproval?: SessionApprovalRecorder["recordSessionApproval"];
|
|
96
100
|
canConfirm?: GatePrompter["canConfirm"];
|
|
97
101
|
prompt?: GatePrompter["prompt"];
|
|
@@ -106,6 +110,13 @@ export function makeGateRunner(
|
|
|
106
110
|
.mockReturnValue(
|
|
107
111
|
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
|
|
108
112
|
);
|
|
113
|
+
const resolvePathPolicy =
|
|
114
|
+
overrides.resolvePathPolicy ??
|
|
115
|
+
vi
|
|
116
|
+
.fn<ScopedPermissionResolver["resolvePathPolicy"]>()
|
|
117
|
+
.mockReturnValue(
|
|
118
|
+
overrides.resolveResult ?? makeCheckResult({ matchedPattern: "*" }),
|
|
119
|
+
);
|
|
109
120
|
const recordSessionApproval =
|
|
110
121
|
overrides.recordSessionApproval ??
|
|
111
122
|
(vi.fn() as SessionApprovalRecorder["recordSessionApproval"]);
|
|
@@ -118,7 +129,7 @@ export function makeGateRunner(
|
|
|
118
129
|
.fn<GatePrompter["prompt"]>()
|
|
119
130
|
.mockResolvedValue({ approved: true, state: "approved" });
|
|
120
131
|
const runner = new GateRunner(
|
|
121
|
-
{ resolve },
|
|
132
|
+
{ resolve, resolvePathPolicy },
|
|
122
133
|
{ recordSessionApproval },
|
|
123
134
|
{ canConfirm, prompt },
|
|
124
135
|
reporter,
|
|
@@ -127,6 +138,7 @@ export function makeGateRunner(
|
|
|
127
138
|
runner,
|
|
128
139
|
deps: {
|
|
129
140
|
resolve,
|
|
141
|
+
resolvePathPolicy,
|
|
130
142
|
recordSessionApproval,
|
|
131
143
|
canConfirm,
|
|
132
144
|
prompt,
|
|
@@ -212,7 +224,15 @@ export function makePathDispatchResolver(
|
|
|
212
224
|
}
|
|
213
225
|
return defaultResult;
|
|
214
226
|
});
|
|
215
|
-
|
|
227
|
+
const resolvePathPolicy =
|
|
228
|
+
vi.fn<ScopedPermissionResolver["resolvePathPolicy"]>();
|
|
229
|
+
resolvePathPolicy.mockImplementation((values) => {
|
|
230
|
+
for (const value of values) {
|
|
231
|
+
if (value in byPath) return byPath[value];
|
|
232
|
+
}
|
|
233
|
+
return defaultResult;
|
|
234
|
+
});
|
|
235
|
+
return { resolve, resolvePathPolicy };
|
|
216
236
|
}
|
|
217
237
|
|
|
218
238
|
/**
|
|
@@ -236,9 +236,21 @@ export function makeHandler(overrides?: {
|
|
|
236
236
|
|
|
237
237
|
// Apply session override bag to the real collaborators.
|
|
238
238
|
const so = overrides?.session;
|
|
239
|
-
|
|
239
|
+
const surfaceCheck = so?.checkPermission;
|
|
240
|
+
if (surfaceCheck) {
|
|
240
241
|
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
241
|
-
|
|
242
|
+
surfaceCheck,
|
|
243
|
+
);
|
|
244
|
+
// The bash path gate resolves through checkPathPolicy; route it through
|
|
245
|
+
// the same surface dispatcher so `path` overrides apply to bash tokens.
|
|
246
|
+
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
247
|
+
(values, agentName, sessionRules) =>
|
|
248
|
+
surfaceCheck(
|
|
249
|
+
"path",
|
|
250
|
+
{ path: values[0] ?? "*" },
|
|
251
|
+
agentName,
|
|
252
|
+
sessionRules,
|
|
253
|
+
),
|
|
242
254
|
);
|
|
243
255
|
}
|
|
244
256
|
if (so?.getActiveSkillEntries) {
|
|
@@ -102,6 +102,20 @@ export function makeFakePermissionManager() {
|
|
|
102
102
|
source: "tool",
|
|
103
103
|
origin: "builtin",
|
|
104
104
|
}),
|
|
105
|
+
checkPathPolicy: vi
|
|
106
|
+
.fn<
|
|
107
|
+
(
|
|
108
|
+
values: readonly string[],
|
|
109
|
+
agentName?: string,
|
|
110
|
+
sessionRules?: Ruleset,
|
|
111
|
+
) => PermissionCheckResult
|
|
112
|
+
>()
|
|
113
|
+
.mockReturnValue({
|
|
114
|
+
state: "allow",
|
|
115
|
+
toolName: "path",
|
|
116
|
+
source: "special",
|
|
117
|
+
origin: "builtin",
|
|
118
|
+
}),
|
|
105
119
|
getToolPermission: vi
|
|
106
120
|
.fn<(toolName: string, agentName?: string) => PermissionState>()
|
|
107
121
|
.mockReturnValue("allow"),
|
|
@@ -68,6 +68,32 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
68
68
|
const result = normalizeInput("path", {}, []);
|
|
69
69
|
expect(result.values).toEqual(["*"]);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
73
|
+
const result = normalizeInput(
|
|
74
|
+
"path",
|
|
75
|
+
{ path: "src/App.jsx" },
|
|
76
|
+
[],
|
|
77
|
+
"/workspace/project",
|
|
78
|
+
);
|
|
79
|
+
expect(result.values).toEqual([
|
|
80
|
+
"/workspace/project/src/App.jsx",
|
|
81
|
+
"src/App.jsx",
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("ignores a user-supplied string pathPolicyValues field", () => {
|
|
86
|
+
const result = normalizeInput(
|
|
87
|
+
"path",
|
|
88
|
+
{ path: "src/App.jsx", pathPolicyValues: ["/etc/shadow"] },
|
|
89
|
+
[],
|
|
90
|
+
"/workspace/project",
|
|
91
|
+
);
|
|
92
|
+
expect(result.values).toEqual([
|
|
93
|
+
"/workspace/project/src/App.jsx",
|
|
94
|
+
"src/App.jsx",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
71
97
|
});
|
|
72
98
|
|
|
73
99
|
describe("special / external_directory", () => {
|
|
@@ -119,6 +145,19 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
119
145
|
);
|
|
120
146
|
expect(result.values).toEqual([join("/mock/home", "dev/project")]);
|
|
121
147
|
});
|
|
148
|
+
|
|
149
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
150
|
+
const result = normalizeInput(
|
|
151
|
+
"external_directory",
|
|
152
|
+
{ path: "src/App.jsx" },
|
|
153
|
+
[],
|
|
154
|
+
"/workspace/project",
|
|
155
|
+
);
|
|
156
|
+
expect(result.values).toEqual([
|
|
157
|
+
"/workspace/project/src/App.jsx",
|
|
158
|
+
"src/App.jsx",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
122
161
|
});
|
|
123
162
|
|
|
124
163
|
describe("skill", () => {
|
|
@@ -206,6 +245,19 @@ describe("normalizeInput — non-MCP surfaces", () => {
|
|
|
206
245
|
const result = normalizeInput("write", { path: "$HOME/.ssh/config" }, []);
|
|
207
246
|
expect(result.values).toEqual([join("/mock/home", ".ssh/config")]);
|
|
208
247
|
});
|
|
248
|
+
|
|
249
|
+
it("adds cwd-normalized and relative aliases when cwd is provided", () => {
|
|
250
|
+
const result = normalizeInput(
|
|
251
|
+
"read",
|
|
252
|
+
{ path: "src/App.jsx" },
|
|
253
|
+
[],
|
|
254
|
+
"/workspace/project",
|
|
255
|
+
);
|
|
256
|
+
expect(result.values).toEqual([
|
|
257
|
+
"/workspace/project/src/App.jsx",
|
|
258
|
+
"src/App.jsx",
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
209
261
|
});
|
|
210
262
|
|
|
211
263
|
describe("extension tools (non-path-bearing)", () => {
|
package/test/path-utils.test.ts
CHANGED
|
@@ -23,15 +23,19 @@ vi.mock("node:fs", () => ({
|
|
|
23
23
|
import {
|
|
24
24
|
canonicalNormalizePathForComparison,
|
|
25
25
|
getPathBearingToolPath,
|
|
26
|
+
getPathPolicyValues,
|
|
27
|
+
getToolInputPath,
|
|
26
28
|
isPathOutsideWorkingDirectory,
|
|
27
29
|
isPathWithinDirectory,
|
|
28
30
|
isPiInfrastructureRead,
|
|
29
31
|
isSafeSystemPath,
|
|
30
32
|
normalizePathForComparison,
|
|
33
|
+
normalizePathPolicyLiteral,
|
|
31
34
|
PATH_BEARING_TOOLS,
|
|
32
35
|
READ_ONLY_PATH_BEARING_TOOLS,
|
|
33
36
|
SAFE_SYSTEM_PATHS,
|
|
34
37
|
} from "#src/path-utils";
|
|
38
|
+
import type { ToolAccessExtractorLookup } from "#src/tool-access-extractor-registry";
|
|
35
39
|
|
|
36
40
|
describe("normalizePathForComparison", () => {
|
|
37
41
|
const cwd = "/projects/my-app";
|
|
@@ -244,6 +248,67 @@ describe("getPathBearingToolPath", () => {
|
|
|
244
248
|
});
|
|
245
249
|
});
|
|
246
250
|
|
|
251
|
+
describe("getToolInputPath", () => {
|
|
252
|
+
function lookupOf(
|
|
253
|
+
toolName: string,
|
|
254
|
+
extractor: (input: Record<string, unknown>) => string | undefined,
|
|
255
|
+
): ToolAccessExtractorLookup {
|
|
256
|
+
return {
|
|
257
|
+
get: (name) => (name === toolName ? extractor : undefined),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("returns input.path for a built-in path-bearing tool", () => {
|
|
262
|
+
expect(getToolInputPath("read", { path: "/src/foo.ts" })).toBe(
|
|
263
|
+
"/src/foo.ts",
|
|
264
|
+
);
|
|
265
|
+
expect(getToolInputPath("write", { path: "/src/bar.ts" })).toBe(
|
|
266
|
+
"/src/bar.ts",
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("returns null for bash", () => {
|
|
271
|
+
expect(getToolInputPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("returns the MCP arguments.path for an mcp call", () => {
|
|
275
|
+
expect(getToolInputPath("mcp", { arguments: { path: "/etc/hosts" } })).toBe(
|
|
276
|
+
"/etc/hosts",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("returns null for an mcp call without an arguments.path", () => {
|
|
281
|
+
expect(getToolInputPath("mcp", { arguments: { query: "x" } })).toBeNull();
|
|
282
|
+
expect(getToolInputPath("mcp", {})).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("defaults to input.path for an unregistered extension tool", () => {
|
|
286
|
+
expect(getToolInputPath("my-ext", { path: "/work/file.txt" })).toBe(
|
|
287
|
+
"/work/file.txt",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("returns null for an extension tool without a path", () => {
|
|
292
|
+
expect(getToolInputPath("my-ext", { other: true })).toBeNull();
|
|
293
|
+
expect(getToolInputPath("my-ext", { path: "" })).toBeNull();
|
|
294
|
+
expect(getToolInputPath("my-ext", null)).toBeNull();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("uses a registered extractor's path over the default convention", () => {
|
|
298
|
+
const extractors = lookupOf("ffgrep", (input) =>
|
|
299
|
+
typeof input.target === "string" ? input.target : undefined,
|
|
300
|
+
);
|
|
301
|
+
expect(
|
|
302
|
+
getToolInputPath("ffgrep", { target: "/etc/passwd" }, extractors),
|
|
303
|
+
).toBe("/etc/passwd");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("returns null when a registered extractor declines", () => {
|
|
307
|
+
const extractors = lookupOf("ffgrep", () => undefined);
|
|
308
|
+
expect(getToolInputPath("ffgrep", { target: "x" }, extractors)).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
247
312
|
describe("isPathOutsideWorkingDirectory", () => {
|
|
248
313
|
const cwd = "/projects/my-app";
|
|
249
314
|
|
|
@@ -479,3 +544,73 @@ describe("isPiInfrastructureRead", () => {
|
|
|
479
544
|
).toBe(false);
|
|
480
545
|
});
|
|
481
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
|
+
});
|
|
@@ -3081,3 +3081,137 @@ test("getResolvedPolicyPaths returns false for missing files and null for absent
|
|
|
3081
3081
|
rmSync(tempDir, { recursive: true, force: true });
|
|
3082
3082
|
}
|
|
3083
3083
|
});
|
|
3084
|
+
|
|
3085
|
+
describe("checkPermission — cwd-aware path policy values", () => {
|
|
3086
|
+
const cwd = "/workspace/project";
|
|
3087
|
+
|
|
3088
|
+
it("matches a relative read input against an absolute allowlist", () => {
|
|
3089
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3090
|
+
read: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3091
|
+
});
|
|
3092
|
+
try {
|
|
3093
|
+
manager.configureForCwd(cwd);
|
|
3094
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3095
|
+
expect(result.state).toBe("allow");
|
|
3096
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3097
|
+
} finally {
|
|
3098
|
+
cleanup();
|
|
3099
|
+
}
|
|
3100
|
+
});
|
|
3101
|
+
|
|
3102
|
+
it("keeps legacy relative path rules working after configureForCwd", () => {
|
|
3103
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3104
|
+
read: { "*": "allow", "src/*": "deny" },
|
|
3105
|
+
});
|
|
3106
|
+
try {
|
|
3107
|
+
manager.configureForCwd(cwd);
|
|
3108
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3109
|
+
expect(result.state).toBe("deny");
|
|
3110
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3111
|
+
} finally {
|
|
3112
|
+
cleanup();
|
|
3113
|
+
}
|
|
3114
|
+
});
|
|
3115
|
+
|
|
3116
|
+
it("preserves last-match-wins across absolute and relative aliases", () => {
|
|
3117
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3118
|
+
read: {
|
|
3119
|
+
"*": "ask",
|
|
3120
|
+
[`${cwd}/*`]: "allow",
|
|
3121
|
+
"src/*": "deny",
|
|
3122
|
+
},
|
|
3123
|
+
});
|
|
3124
|
+
try {
|
|
3125
|
+
manager.configureForCwd(cwd);
|
|
3126
|
+
const result = manager.checkPermission("read", { path: "src/App.jsx" });
|
|
3127
|
+
// The later "src/*" deny wins over the earlier absolute allow.
|
|
3128
|
+
expect(result.state).toBe("deny");
|
|
3129
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3130
|
+
} finally {
|
|
3131
|
+
cleanup();
|
|
3132
|
+
}
|
|
3133
|
+
});
|
|
3134
|
+
|
|
3135
|
+
it("matches the cross-cutting path surface against absolute allowlists", () => {
|
|
3136
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3137
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3138
|
+
});
|
|
3139
|
+
try {
|
|
3140
|
+
manager.configureForCwd(cwd);
|
|
3141
|
+
const result = manager.checkPermission("path", { path: "src/App.jsx" });
|
|
3142
|
+
expect(result.state).toBe("allow");
|
|
3143
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3144
|
+
} finally {
|
|
3145
|
+
cleanup();
|
|
3146
|
+
}
|
|
3147
|
+
});
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
describe("checkPathPolicy", () => {
|
|
3151
|
+
const cwd = "/workspace/project";
|
|
3152
|
+
|
|
3153
|
+
it("evaluates precomputed policy values against the path surface", () => {
|
|
3154
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3155
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow" },
|
|
3156
|
+
});
|
|
3157
|
+
try {
|
|
3158
|
+
const result = manager.checkPathPolicy([
|
|
3159
|
+
`${cwd}/src/App.jsx`,
|
|
3160
|
+
"src/App.jsx",
|
|
3161
|
+
]);
|
|
3162
|
+
expect(result.state).toBe("allow");
|
|
3163
|
+
expect(result.matchedPattern).toBe(`${cwd}/*`);
|
|
3164
|
+
expect(result.source).toBe("special");
|
|
3165
|
+
expect(result.toolName).toBe("path");
|
|
3166
|
+
} finally {
|
|
3167
|
+
cleanup();
|
|
3168
|
+
}
|
|
3169
|
+
});
|
|
3170
|
+
|
|
3171
|
+
it("preserves last-match-wins across the provided aliases", () => {
|
|
3172
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3173
|
+
path: { "*": "ask", [`${cwd}/*`]: "allow", "src/*": "deny" },
|
|
3174
|
+
});
|
|
3175
|
+
try {
|
|
3176
|
+
const result = manager.checkPathPolicy([
|
|
3177
|
+
`${cwd}/src/App.jsx`,
|
|
3178
|
+
"src/App.jsx",
|
|
3179
|
+
]);
|
|
3180
|
+
expect(result.state).toBe("deny");
|
|
3181
|
+
expect(result.matchedPattern).toBe("src/*");
|
|
3182
|
+
} finally {
|
|
3183
|
+
cleanup();
|
|
3184
|
+
}
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
it("applies session rules over config", () => {
|
|
3188
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3189
|
+
path: { "*": "ask", "src/*": "deny" },
|
|
3190
|
+
});
|
|
3191
|
+
try {
|
|
3192
|
+
const sessionRules: Ruleset = [sessionAllow("path", "src/*")];
|
|
3193
|
+
const result = manager.checkPathPolicy(
|
|
3194
|
+
["src/App.jsx"],
|
|
3195
|
+
undefined,
|
|
3196
|
+
sessionRules,
|
|
3197
|
+
);
|
|
3198
|
+
expect(result.state).toBe("allow");
|
|
3199
|
+
expect(result.source).toBe("session");
|
|
3200
|
+
} finally {
|
|
3201
|
+
cleanup();
|
|
3202
|
+
}
|
|
3203
|
+
});
|
|
3204
|
+
|
|
3205
|
+
it("falls back to the catch-all for an empty value list", () => {
|
|
3206
|
+
const { manager, cleanup } = makeManagerWithConfig({
|
|
3207
|
+
path: { "*": "deny" },
|
|
3208
|
+
});
|
|
3209
|
+
try {
|
|
3210
|
+
const result = manager.checkPathPolicy([]);
|
|
3211
|
+
expect(result.state).toBe("deny");
|
|
3212
|
+
expect(result.matchedPattern).toBe("*");
|
|
3213
|
+
} finally {
|
|
3214
|
+
cleanup();
|
|
3215
|
+
}
|
|
3216
|
+
});
|
|
3217
|
+
});
|
|
@@ -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();
|
|
@@ -3,6 +3,7 @@ import type { ScopedPermissionManager } from "#src/permission-manager";
|
|
|
3
3
|
import { LocalPermissionsService } from "#src/permissions-service";
|
|
4
4
|
import type { Ruleset } from "#src/rule";
|
|
5
5
|
import type { SessionRules } from "#src/session-rules";
|
|
6
|
+
import type { ToolAccessExtractorRegistrar } from "#src/tool-access-extractor-registry";
|
|
6
7
|
import type {
|
|
7
8
|
ToolInputFormatter,
|
|
8
9
|
ToolInputFormatterRegistrar,
|
|
@@ -39,22 +40,40 @@ function makeFormatterRegistry(): ToolInputFormatterRegistrar {
|
|
|
39
40
|
};
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
function makeAccessExtractorRegistry(): ToolAccessExtractorRegistrar {
|
|
44
|
+
return {
|
|
45
|
+
register: vi
|
|
46
|
+
.fn<ToolAccessExtractorRegistrar["register"]>()
|
|
47
|
+
.mockReturnValue(vi.fn()),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
42
51
|
function makeService(overrides?: {
|
|
43
52
|
permissionManager?: ScopedPermissionManager;
|
|
44
53
|
sessionRules?: Pick<SessionRules, "getRuleset">;
|
|
45
54
|
formatterRegistry?: ToolInputFormatterRegistrar;
|
|
55
|
+
accessExtractorRegistry?: ToolAccessExtractorRegistrar;
|
|
46
56
|
}) {
|
|
47
57
|
const permissionManager =
|
|
48
58
|
overrides?.permissionManager ?? makeFakePermissionManager();
|
|
49
59
|
const sessionRules = overrides?.sessionRules ?? makeSessionRules();
|
|
50
60
|
const formatterRegistry =
|
|
51
61
|
overrides?.formatterRegistry ?? makeFormatterRegistry();
|
|
62
|
+
const accessExtractorRegistry =
|
|
63
|
+
overrides?.accessExtractorRegistry ?? makeAccessExtractorRegistry();
|
|
52
64
|
const service = new LocalPermissionsService(
|
|
53
65
|
permissionManager,
|
|
54
66
|
sessionRules,
|
|
55
67
|
formatterRegistry,
|
|
68
|
+
accessExtractorRegistry,
|
|
56
69
|
);
|
|
57
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
service,
|
|
72
|
+
permissionManager,
|
|
73
|
+
sessionRules,
|
|
74
|
+
formatterRegistry,
|
|
75
|
+
accessExtractorRegistry,
|
|
76
|
+
};
|
|
58
77
|
}
|
|
59
78
|
|
|
60
79
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
@@ -141,3 +160,18 @@ describe("registerToolInputFormatter", () => {
|
|
|
141
160
|
expect(result).toBe(unsub);
|
|
142
161
|
});
|
|
143
162
|
});
|
|
163
|
+
|
|
164
|
+
describe("registerToolAccessExtractor", () => {
|
|
165
|
+
it("delegates to accessExtractorRegistry.register and returns the unsubscribe function", () => {
|
|
166
|
+
const unsub = vi.fn();
|
|
167
|
+
const { service, accessExtractorRegistry } = makeService();
|
|
168
|
+
vi.mocked(accessExtractorRegistry.register).mockReturnValue(unsub);
|
|
169
|
+
const extractor = vi.fn();
|
|
170
|
+
const result = service.registerToolAccessExtractor("ffgrep", extractor);
|
|
171
|
+
expect(accessExtractorRegistry.register).toHaveBeenCalledWith(
|
|
172
|
+
"ffgrep",
|
|
173
|
+
extractor,
|
|
174
|
+
);
|
|
175
|
+
expect(result).toBe(unsub);
|
|
176
|
+
});
|
|
177
|
+
});
|