@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
|
@@ -337,6 +337,42 @@ describe("service and gate share one formatter registry", () => {
|
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
+
describe("service and gate share one access extractor registry", () => {
|
|
341
|
+
// An extractor registered through the published service must be consulted by
|
|
342
|
+
// the live gate handler — proving both reference the same
|
|
343
|
+
// ToolAccessExtractorRegistry instance the factory created once (#352).
|
|
344
|
+
it("path-gates a custom-shaped tool via a service-registered extractor", async () => {
|
|
345
|
+
writeGlobalConfig({
|
|
346
|
+
permission: { "*": "allow", path: { "*.env": "deny" } },
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
|
|
350
|
+
const pi = makeFakePi({ toolNames: ["ffgrep"] });
|
|
351
|
+
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
|
|
352
|
+
|
|
353
|
+
const { ctx } = makeUiCtx(cwd, []);
|
|
354
|
+
await fireSessionStart(pi, ctx);
|
|
355
|
+
|
|
356
|
+
// ffgrep carries its path under a non-standard key; without the extractor
|
|
357
|
+
// the default input.path convention would miss it.
|
|
358
|
+
getPermissionsService()!.registerToolAccessExtractor("ffgrep", (input) =>
|
|
359
|
+
typeof input.target === "string" ? input.target : undefined,
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
const result = (await pi.fire(
|
|
363
|
+
"tool_call",
|
|
364
|
+
{ toolName: "ffgrep", toolCallId: "ff-1", input: { target: ".env" } },
|
|
365
|
+
ctx,
|
|
366
|
+
)) as { block?: true };
|
|
367
|
+
|
|
368
|
+
// The path deny fired — so the gate extracted ffgrep's path through the
|
|
369
|
+
// same registry the service wrote to.
|
|
370
|
+
expect(result.block).toBe(true);
|
|
371
|
+
|
|
372
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
340
376
|
describe("ready emitted after service publication", () => {
|
|
341
377
|
// Ordering contracts exist only at the composition root: a consumer reacting
|
|
342
378
|
// to permissions:ready must be able to resolve the service immediately. The
|
|
@@ -2,6 +2,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { expect, test, vi } from "vitest";
|
|
5
|
+
import { loadUnifiedConfig } from "#src/config-loader";
|
|
5
6
|
import { registerPermissionSystemCommand } from "#src/config-modal";
|
|
6
7
|
import type { CommandConfigStore } from "#src/config-store";
|
|
7
8
|
import {
|
|
@@ -89,8 +90,7 @@ test("permission-system command completions expose top-level config actions", ()
|
|
|
89
90
|
const controller = {
|
|
90
91
|
config: configStore,
|
|
91
92
|
configPath,
|
|
92
|
-
|
|
93
|
-
session: { lastKnownActiveAgentName: null },
|
|
93
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
94
94
|
};
|
|
95
95
|
|
|
96
96
|
let definition: {
|
|
@@ -146,7 +146,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
146
146
|
current: () => config,
|
|
147
147
|
save: (next) => {
|
|
148
148
|
const currentConfig = normalizePermissionSystemConfig(
|
|
149
|
-
|
|
149
|
+
loadUnifiedConfig(configPath).config,
|
|
150
150
|
);
|
|
151
151
|
const normalized = normalizePermissionSystemConfig(next);
|
|
152
152
|
writeFileSync(
|
|
@@ -155,7 +155,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
155
155
|
"utf-8",
|
|
156
156
|
);
|
|
157
157
|
config = normalizePermissionSystemConfig(
|
|
158
|
-
|
|
158
|
+
loadUnifiedConfig(configPath).config,
|
|
159
159
|
);
|
|
160
160
|
expect(config).not.toEqual(currentConfig);
|
|
161
161
|
},
|
|
@@ -163,8 +163,7 @@ test("permission-system command handlers manage config summary, persistence, and
|
|
|
163
163
|
const controller = {
|
|
164
164
|
config: configStore,
|
|
165
165
|
configPath,
|
|
166
|
-
|
|
167
|
-
session: { lastKnownActiveAgentName: null },
|
|
166
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
168
167
|
};
|
|
169
168
|
|
|
170
169
|
let registeredName = "";
|
|
@@ -262,8 +261,7 @@ test("show output includes rule origins when getComposedRules is provided", asyn
|
|
|
262
261
|
const controller = {
|
|
263
262
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
264
263
|
configPath: "/fake/config.json",
|
|
265
|
-
|
|
266
|
-
session: { lastKnownActiveAgentName: null },
|
|
264
|
+
getActiveAgentConfigRules: () => composedRules,
|
|
267
265
|
};
|
|
268
266
|
|
|
269
267
|
let definition: {
|
|
@@ -295,8 +293,7 @@ test("show output omits rule summary when getComposedRules is not provided", asy
|
|
|
295
293
|
const controller = {
|
|
296
294
|
config: { current: () => config, save: () => {} } as CommandConfigStore,
|
|
297
295
|
configPath: "/fake/config.json",
|
|
298
|
-
|
|
299
|
-
session: { lastKnownActiveAgentName: null },
|
|
296
|
+
getActiveAgentConfigRules: () => [] as Ruleset,
|
|
300
297
|
};
|
|
301
298
|
|
|
302
299
|
let definition: {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { loadAndMergeConfigs } from "#src/config-loader";
|
|
7
|
+
import { normalizePermissionSystemConfig } from "#src/extension-config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-pipeline seam tests: write a temp config.json → loadAndMergeConfigs →
|
|
11
|
+
* normalizePermissionSystemConfig → assert values survive end to end.
|
|
12
|
+
*
|
|
13
|
+
* These tests guard the seam between the two normalizers — the class of bug
|
|
14
|
+
* fixed in #332, where a field declared on PermissionSystemExtensionConfig was
|
|
15
|
+
* silently dropped by the UnifiedPermissionConfig intermediate.
|
|
16
|
+
*/
|
|
17
|
+
describe("config pipeline seam", () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
let agentDir: string;
|
|
20
|
+
let cwd: string;
|
|
21
|
+
let extensionRoot: string;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
|
|
25
|
+
agentDir = join(tempDir, "agent");
|
|
26
|
+
cwd = join(tempDir, "project");
|
|
27
|
+
extensionRoot = join(tempDir, "ext");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function writeGlobal(content: Record<string, unknown>): void {
|
|
35
|
+
const dir = join(agentDir, "extensions", "pi-permission-system");
|
|
36
|
+
mkdirSync(dir, { recursive: true });
|
|
37
|
+
writeFileSync(join(dir, "config.json"), JSON.stringify(content));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it("runtime knob and preview-length field both survive the full pipeline", () => {
|
|
41
|
+
writeGlobal({
|
|
42
|
+
debugLog: true,
|
|
43
|
+
toolInputPreviewMaxLength: 1000,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
47
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
48
|
+
|
|
49
|
+
expect(config.debugLog).toBe(true);
|
|
50
|
+
expect(config.toolInputPreviewMaxLength).toBe(1000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("text summary length field survives the full pipeline", () => {
|
|
54
|
+
writeGlobal({
|
|
55
|
+
toolTextSummaryMaxLength: 250,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
59
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
60
|
+
|
|
61
|
+
expect(config.toolTextSummaryMaxLength).toBe(250);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("project config overrides global preview-length field end to end", () => {
|
|
65
|
+
writeGlobal({ toolInputPreviewMaxLength: 200 });
|
|
66
|
+
const projectDir = join(cwd, ".pi", "extensions", "pi-permission-system");
|
|
67
|
+
mkdirSync(projectDir, { recursive: true });
|
|
68
|
+
writeFileSync(
|
|
69
|
+
join(projectDir, "config.json"),
|
|
70
|
+
JSON.stringify({ toolInputPreviewMaxLength: 500 }),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
74
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
75
|
+
|
|
76
|
+
expect(config.toolInputPreviewMaxLength).toBe(500);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("defaults apply when config file is absent", () => {
|
|
80
|
+
// No config files written — agentDir and cwd directories don't exist.
|
|
81
|
+
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
|
|
82
|
+
const config = normalizePermissionSystemConfig(mergeResult.merged);
|
|
83
|
+
|
|
84
|
+
expect(config.debugLog).toBe(false);
|
|
85
|
+
expect(config.permissionReviewLog).toBe(true);
|
|
86
|
+
expect(config.yoloMode).toBe(false);
|
|
87
|
+
expect(config.toolInputPreviewMaxLength).toBeUndefined();
|
|
88
|
+
expect(config.toolTextSummaryMaxLength).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -103,26 +103,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
103
103
|
expect(result.yoloMode).toBe(false);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
it("coerces non-boolean values to their defaults", () => {
|
|
107
|
-
const result = normalizePermissionSystemConfig({
|
|
108
|
-
debugLog: "yes",
|
|
109
|
-
permissionReviewLog: 1,
|
|
110
|
-
yoloMode: null,
|
|
111
|
-
});
|
|
112
|
-
expect(result.debugLog).toBe(false);
|
|
113
|
-
expect(result.permissionReviewLog).toBe(true);
|
|
114
|
-
expect(result.yoloMode).toBe(false);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("handles null/undefined input gracefully", () => {
|
|
118
|
-
const result = normalizePermissionSystemConfig(null);
|
|
119
|
-
expect(result).toEqual({
|
|
120
|
-
debugLog: false,
|
|
121
|
-
permissionReviewLog: true,
|
|
122
|
-
yoloMode: false,
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
106
|
it("includes toolInputPreviewMaxLength when a valid positive integer is provided", () => {
|
|
127
107
|
const result = normalizePermissionSystemConfig({
|
|
128
108
|
toolInputPreviewMaxLength: 400,
|
|
@@ -135,25 +115,6 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
135
115
|
expect("toolInputPreviewMaxLength" in result).toBe(false);
|
|
136
116
|
});
|
|
137
117
|
|
|
138
|
-
it("omits toolInputPreviewMaxLength for invalid values", () => {
|
|
139
|
-
expect(
|
|
140
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 0 })
|
|
141
|
-
.toolInputPreviewMaxLength,
|
|
142
|
-
).toBeUndefined();
|
|
143
|
-
expect(
|
|
144
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: -1 })
|
|
145
|
-
.toolInputPreviewMaxLength,
|
|
146
|
-
).toBeUndefined();
|
|
147
|
-
expect(
|
|
148
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: 200.5 })
|
|
149
|
-
.toolInputPreviewMaxLength,
|
|
150
|
-
).toBeUndefined();
|
|
151
|
-
expect(
|
|
152
|
-
normalizePermissionSystemConfig({ toolInputPreviewMaxLength: "200" })
|
|
153
|
-
.toolInputPreviewMaxLength,
|
|
154
|
-
).toBeUndefined();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
118
|
it("includes toolTextSummaryMaxLength when a valid positive integer is provided", () => {
|
|
158
119
|
const result = normalizePermissionSystemConfig({
|
|
159
120
|
toolTextSummaryMaxLength: 120,
|
|
@@ -165,23 +126,4 @@ describe("normalizePermissionSystemConfig", () => {
|
|
|
165
126
|
const result = normalizePermissionSystemConfig({});
|
|
166
127
|
expect("toolTextSummaryMaxLength" in result).toBe(false);
|
|
167
128
|
});
|
|
168
|
-
|
|
169
|
-
it("omits toolTextSummaryMaxLength for invalid values", () => {
|
|
170
|
-
expect(
|
|
171
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 0 })
|
|
172
|
-
.toolTextSummaryMaxLength,
|
|
173
|
-
).toBeUndefined();
|
|
174
|
-
expect(
|
|
175
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: -1 })
|
|
176
|
-
.toolTextSummaryMaxLength,
|
|
177
|
-
).toBeUndefined();
|
|
178
|
-
expect(
|
|
179
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: 80.1 })
|
|
180
|
-
.toolTextSummaryMaxLength,
|
|
181
|
-
).toBeUndefined();
|
|
182
|
-
expect(
|
|
183
|
-
normalizePermissionSystemConfig({ toolTextSummaryMaxLength: true })
|
|
184
|
-
.toolTextSummaryMaxLength,
|
|
185
|
-
).toBeUndefined();
|
|
186
|
-
});
|
|
187
129
|
});
|
|
@@ -215,6 +215,49 @@ describe("describeBashPathGate", () => {
|
|
|
215
215
|
expect(desc.preCheck?.state).toBe("deny");
|
|
216
216
|
expect(desc.decision.value).toBe(".env");
|
|
217
217
|
});
|
|
218
|
+
|
|
219
|
+
it("resolves cd-aware policy values while keeping the raw prompt token", async () => {
|
|
220
|
+
const resolver = makeResolver(
|
|
221
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
222
|
+
);
|
|
223
|
+
const result = (await describeGate(
|
|
224
|
+
makeTcc({
|
|
225
|
+
input: { command: "cd nested && cat src/file.txt" },
|
|
226
|
+
cwd: "/test/project",
|
|
227
|
+
}),
|
|
228
|
+
resolver,
|
|
229
|
+
)) as GateDescriptor;
|
|
230
|
+
|
|
231
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
232
|
+
[
|
|
233
|
+
"/test/project/nested/src/file.txt",
|
|
234
|
+
"nested/src/file.txt",
|
|
235
|
+
"src/file.txt",
|
|
236
|
+
],
|
|
237
|
+
undefined,
|
|
238
|
+
);
|
|
239
|
+
// The raw token drives the prompt, denial context, and session approval.
|
|
240
|
+
expect(result.denialContext).toMatchObject({ pathValue: "src/file.txt" });
|
|
241
|
+
expect(result.decision.value).toBe("src/file.txt");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("does not resolve relative policy values through an unknown cd", async () => {
|
|
245
|
+
const resolver = makeResolver(
|
|
246
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
247
|
+
);
|
|
248
|
+
await describeGate(
|
|
249
|
+
makeTcc({
|
|
250
|
+
input: { command: 'cd "$DIR" && cat src/foo.ts' },
|
|
251
|
+
cwd: "/test/project",
|
|
252
|
+
}),
|
|
253
|
+
resolver,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(resolver.resolvePathPolicy).toHaveBeenCalledWith(
|
|
257
|
+
["src/foo.ts"],
|
|
258
|
+
undefined,
|
|
259
|
+
);
|
|
260
|
+
});
|
|
218
261
|
});
|
|
219
262
|
|
|
220
263
|
// Home-relative path characterization (#350) ──────────────────────────────
|
|
@@ -229,7 +272,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
229
272
|
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
230
273
|
const resolver = makePathDispatchResolver(
|
|
231
274
|
{
|
|
232
|
-
"
|
|
275
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
233
276
|
state: "deny",
|
|
234
277
|
matchedPattern: "~/.ssh/*",
|
|
235
278
|
}),
|
|
@@ -253,7 +296,7 @@ describe("describeBashPathGate — home-relative paths", () => {
|
|
|
253
296
|
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
254
297
|
const resolver = makePathDispatchResolver(
|
|
255
298
|
{
|
|
256
|
-
"
|
|
299
|
+
"/mock/home/.ssh/config": makeCheckResult({
|
|
257
300
|
state: "deny",
|
|
258
301
|
matchedPattern: "$HOME/.ssh/*",
|
|
259
302
|
}),
|
|
@@ -13,20 +13,50 @@ vi.mock("node:fs", () => ({
|
|
|
13
13
|
import { BashProgram } from "#src/handlers/gates/bash-program";
|
|
14
14
|
|
|
15
15
|
describe("BashProgram", () => {
|
|
16
|
-
describe("
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
describe("pathRuleCandidates", () => {
|
|
17
|
+
const cwd = "/projects/my-app";
|
|
18
|
+
|
|
19
|
+
it("adds absolute and relative policy values for relative tokens", async () => {
|
|
20
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
21
|
+
expect(program.pathRuleCandidates(cwd)).toEqual([
|
|
22
|
+
{
|
|
23
|
+
token: "src/foo.ts",
|
|
24
|
+
policyValues: ["/projects/my-app/src/foo.ts", "src/foo.ts"],
|
|
25
|
+
},
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns the literal token only when no cwd is provided", async () => {
|
|
30
|
+
const program = await BashProgram.parse("cat src/foo.ts");
|
|
31
|
+
expect(program.pathRuleCandidates()).toEqual([
|
|
32
|
+
{ token: "src/foo.ts", policyValues: ["src/foo.ts"] },
|
|
33
|
+
]);
|
|
20
34
|
});
|
|
21
35
|
|
|
22
|
-
it("
|
|
23
|
-
const program = await BashProgram.parse("
|
|
24
|
-
|
|
36
|
+
it("resolves tokens after literal cd against the effective directory", async () => {
|
|
37
|
+
const program = await BashProgram.parse("cd nested && cat src/file.txt");
|
|
38
|
+
const fileCandidate = program
|
|
39
|
+
.pathRuleCandidates(cwd)
|
|
40
|
+
.find((candidate) => candidate.token === "src/file.txt");
|
|
41
|
+
expect(fileCandidate).toEqual({
|
|
42
|
+
token: "src/file.txt",
|
|
43
|
+
policyValues: [
|
|
44
|
+
"/projects/my-app/nested/src/file.txt",
|
|
45
|
+
"nested/src/file.txt",
|
|
46
|
+
"src/file.txt",
|
|
47
|
+
],
|
|
48
|
+
});
|
|
25
49
|
});
|
|
26
50
|
|
|
27
|
-
it("
|
|
28
|
-
const program = await BashProgram.parse("
|
|
29
|
-
|
|
51
|
+
it("does not absolute-allow relative tokens after unknown cd", async () => {
|
|
52
|
+
const program = await BashProgram.parse('cd "$DIR" && cat src/foo.ts');
|
|
53
|
+
const fileCandidate = program
|
|
54
|
+
.pathRuleCandidates(cwd)
|
|
55
|
+
.find((candidate) => candidate.token === "src/foo.ts");
|
|
56
|
+
expect(fileCandidate).toEqual({
|
|
57
|
+
token: "src/foo.ts",
|
|
58
|
+
policyValues: ["src/foo.ts"],
|
|
59
|
+
});
|
|
30
60
|
});
|
|
31
61
|
});
|
|
32
62
|
|
|
@@ -322,7 +352,10 @@ describe("BashProgram", () => {
|
|
|
322
352
|
|
|
323
353
|
it("derives both slices from a single parse", async () => {
|
|
324
354
|
const program = await BashProgram.parse("cat .env /etc/hosts");
|
|
325
|
-
expect(program.
|
|
355
|
+
expect(program.pathRuleCandidates().map(({ token }) => token)).toEqual([
|
|
356
|
+
".env",
|
|
357
|
+
"/etc/hosts",
|
|
358
|
+
]);
|
|
326
359
|
const external = program.externalPaths("/projects/my-app");
|
|
327
360
|
expect(external).toContain("/etc/hosts");
|
|
328
361
|
expect(external).not.toContain(".env");
|
|
@@ -168,3 +168,57 @@ describe("describeExternalDirectoryGate", () => {
|
|
|
168
168
|
expect(result.logContext.message).toBeDefined();
|
|
169
169
|
});
|
|
170
170
|
});
|
|
171
|
+
|
|
172
|
+
// Extension and MCP tools are now external-directory gated (#352) ───────────
|
|
173
|
+
|
|
174
|
+
describe("describeExternalDirectoryGate — extension and MCP tools (#352)", () => {
|
|
175
|
+
it("gates an extension tool with an external input.path", () => {
|
|
176
|
+
const result = describeExternalDirectoryGate(
|
|
177
|
+
makeTcc({
|
|
178
|
+
toolName: "my-ext",
|
|
179
|
+
input: { path: "/outside/project/file.ts" },
|
|
180
|
+
}),
|
|
181
|
+
["/test/agent"],
|
|
182
|
+
);
|
|
183
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
184
|
+
expect((result as GateDescriptor).surface).toBe("external_directory");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("gates an MCP tool with an external arguments.path", () => {
|
|
188
|
+
const result = describeExternalDirectoryGate(
|
|
189
|
+
makeTcc({
|
|
190
|
+
toolName: "mcp",
|
|
191
|
+
input: { arguments: { path: "/outside/project/file.ts" } },
|
|
192
|
+
}),
|
|
193
|
+
["/test/agent"],
|
|
194
|
+
);
|
|
195
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("uses a registered extractor's external path for a custom-shaped tool", () => {
|
|
199
|
+
const extractors = {
|
|
200
|
+
get: (name: string) =>
|
|
201
|
+
name === "ffgrep"
|
|
202
|
+
? (input: Record<string, unknown>) =>
|
|
203
|
+
typeof input.target === "string" ? input.target : undefined
|
|
204
|
+
: undefined,
|
|
205
|
+
};
|
|
206
|
+
const result = describeExternalDirectoryGate(
|
|
207
|
+
makeTcc({ toolName: "ffgrep", input: { target: "/outside/project/x" } }),
|
|
208
|
+
["/test/agent"],
|
|
209
|
+
extractors,
|
|
210
|
+
);
|
|
211
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("returns null for an extension tool whose path is inside cwd", () => {
|
|
215
|
+
const result = describeExternalDirectoryGate(
|
|
216
|
+
makeTcc({
|
|
217
|
+
toolName: "my-ext",
|
|
218
|
+
input: { path: "/test/project/src/x.ts" },
|
|
219
|
+
}),
|
|
220
|
+
["/test/agent"],
|
|
221
|
+
);
|
|
222
|
+
expect(result).toBeNull();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -206,3 +206,75 @@ describe("describePathGate — home-relative paths", () => {
|
|
|
206
206
|
expect(result).toBeNull();
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// Extension and MCP tools are now path-gated (#352) ──────────────────────────
|
|
211
|
+
|
|
212
|
+
describe("describePathGate — extension and MCP tools (#352)", () => {
|
|
213
|
+
function extractorLookup(toolName: string, key: string) {
|
|
214
|
+
return {
|
|
215
|
+
get: (name: string) =>
|
|
216
|
+
name === toolName
|
|
217
|
+
? (input: Record<string, unknown>) =>
|
|
218
|
+
typeof input[key] === "string" ? input[key] : undefined
|
|
219
|
+
: undefined,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it("gates an extension tool that exposes input.path", () => {
|
|
224
|
+
const resolver = makeResolver(
|
|
225
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
226
|
+
);
|
|
227
|
+
const result = describePathGate(
|
|
228
|
+
makeTcc({ toolName: "my-ext", input: { path: ".env" } }),
|
|
229
|
+
resolver,
|
|
230
|
+
);
|
|
231
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
232
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
233
|
+
"path",
|
|
234
|
+
{ path: ".env" },
|
|
235
|
+
undefined,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("gates an MCP tool via arguments.path", () => {
|
|
240
|
+
const resolver = makeResolver(
|
|
241
|
+
makeCheckResult({ state: "deny", matchedPattern: "*.env" }),
|
|
242
|
+
);
|
|
243
|
+
const result = describePathGate(
|
|
244
|
+
makeTcc({ toolName: "mcp", input: { arguments: { path: ".env" } } }),
|
|
245
|
+
resolver,
|
|
246
|
+
);
|
|
247
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
248
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
249
|
+
"path",
|
|
250
|
+
{ path: ".env" },
|
|
251
|
+
undefined,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("uses a registered extractor's path for a custom-shaped tool", () => {
|
|
256
|
+
const resolver = makeResolver(
|
|
257
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
258
|
+
);
|
|
259
|
+
describePathGate(
|
|
260
|
+
makeTcc({ toolName: "ffgrep", input: { target: "/etc/passwd" } }),
|
|
261
|
+
resolver,
|
|
262
|
+
extractorLookup("ffgrep", "target"),
|
|
263
|
+
);
|
|
264
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
265
|
+
"path",
|
|
266
|
+
{ path: "/etc/passwd" },
|
|
267
|
+
undefined,
|
|
268
|
+
);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("returns null for an extension tool without a path", () => {
|
|
272
|
+
const resolver = makeResolver();
|
|
273
|
+
const result = describePathGate(
|
|
274
|
+
makeTcc({ toolName: "my-ext", input: { other: true } }),
|
|
275
|
+
resolver,
|
|
276
|
+
);
|
|
277
|
+
expect(result).toBeNull();
|
|
278
|
+
expect(resolver.resolve).not.toHaveBeenCalled();
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -23,7 +23,7 @@ vi.mock("#src/handlers/gates/bash-program", () => ({
|
|
|
23
23
|
function makeMockBashProgram() {
|
|
24
24
|
return {
|
|
25
25
|
commands: vi.fn<() => []>(() => []),
|
|
26
|
-
|
|
26
|
+
pathRuleCandidates: vi.fn<() => []>(() => []),
|
|
27
27
|
externalPaths: vi.fn<() => []>(() => []),
|
|
28
28
|
};
|
|
29
29
|
}
|
|
@@ -186,4 +186,67 @@ describe("ToolCallGatePipeline", () => {
|
|
|
186
186
|
expect(mockBashProgramParse).not.toHaveBeenCalled();
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
|
+
|
|
190
|
+
// ── customExtractors threading (#352) ────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("evaluate — customExtractors threading (#352)", () => {
|
|
193
|
+
// Deny only the cross-cutting `path` surface; allow everything else, so a
|
|
194
|
+
// block can only come from the path gate seeing the extracted path.
|
|
195
|
+
function pathDenyingResolver() {
|
|
196
|
+
const resolver = makeResolver();
|
|
197
|
+
resolver.resolve.mockImplementation((surface) =>
|
|
198
|
+
surface === "path"
|
|
199
|
+
? makeCheckResult({ state: "deny", matchedPattern: "*" })
|
|
200
|
+
: makeCheckResult(),
|
|
201
|
+
);
|
|
202
|
+
return resolver;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const extractors = {
|
|
206
|
+
get: (name: string) =>
|
|
207
|
+
name === "ffgrep"
|
|
208
|
+
? (input: Record<string, unknown>) =>
|
|
209
|
+
typeof input.target === "string" ? input.target : undefined
|
|
210
|
+
: undefined,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
it("forwards extractors so a custom-shaped tool is path-gated", async () => {
|
|
214
|
+
const resolver = pathDenyingResolver();
|
|
215
|
+
const inputs = makeGateInputs();
|
|
216
|
+
const { runner } = makeGateRunner();
|
|
217
|
+
const pipeline = new ToolCallGatePipeline(
|
|
218
|
+
resolver,
|
|
219
|
+
inputs,
|
|
220
|
+
undefined,
|
|
221
|
+
extractors,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const result = await pipeline.evaluate(
|
|
225
|
+
makeTcc({
|
|
226
|
+
toolName: "ffgrep",
|
|
227
|
+
input: { target: "/test/project/secret.env" },
|
|
228
|
+
}),
|
|
229
|
+
runner,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result).toMatchObject({ action: "block" });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("without extractors the custom-shaped tool is not path-gated", async () => {
|
|
236
|
+
const resolver = pathDenyingResolver();
|
|
237
|
+
const inputs = makeGateInputs();
|
|
238
|
+
const { runner } = makeGateRunner();
|
|
239
|
+
const pipeline = new ToolCallGatePipeline(resolver, inputs);
|
|
240
|
+
|
|
241
|
+
const result = await pipeline.evaluate(
|
|
242
|
+
makeTcc({
|
|
243
|
+
toolName: "ffgrep",
|
|
244
|
+
input: { target: "/test/project/secret.env" },
|
|
245
|
+
}),
|
|
246
|
+
runner,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({ action: "allow" });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
189
252
|
});
|