@gotgenes/pi-permission-system 10.5.1 → 10.5.2
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 +13 -0
- package/package.json +1 -1
- package/src/input-normalizer.ts +20 -8
- package/src/path-utils.ts +1 -10
- package/test/before-agent-start-cache.test.ts +89 -0
- package/test/handlers/external-directory-session-dedup.test.ts +96 -0
- package/test/handlers/gates/bash-path.test.ts +57 -0
- package/test/handlers/gates/path.test.ts +58 -0
- package/test/handlers/tool-call.test.ts +103 -0
- package/test/helpers/manager-harness.ts +61 -0
- package/test/input-normalizer.test.ts +77 -1
- package/test/logging.test.ts +51 -0
- package/test/path-utils.test.ts +10 -0
- package/test/permission-forwarding.test.ts +73 -0
- package/test/permission-manager-unified.test.ts +1577 -3
- package/test/skill-prompt-sanitizer.test.ts +130 -0
- package/test/status.test.ts +10 -0
- package/test/system-prompt-sanitizer.test.ts +68 -0
- package/test/tool-registry.test.ts +42 -0
- package/test/yolo-mode.test.ts +78 -0
- package/test/permission-system.test.ts +0 -2785
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [10.5.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.1...pi-permission-system-v10.5.2) (2026-06-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** expand $HOME in normalizePathForComparison ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([1b92ed3](https://github.com/gotgenes/pi-packages/commit/1b92ed3d2364174d3287171c58ce8452239b3e8d))
|
|
14
|
+
* **pi-permission-system:** home-expand path values before matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([48a7b37](https://github.com/gotgenes/pi-packages/commit/48a7b3783857b449442d30edefe04f8255e5f4f8))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** note path values are home-expanded for matching ([#350](https://github.com/gotgenes/pi-packages/issues/350)) ([e9c264d](https://github.com/gotgenes/pi-packages/commit/e9c264de85d327a0bfbcd84401a259cb509a5dfa))
|
|
20
|
+
|
|
8
21
|
## [10.5.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.5.0...pi-permission-system-v10.5.1) (2026-06-07)
|
|
9
22
|
|
|
10
23
|
|
package/package.json
CHANGED
package/src/input-normalizer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { toRecord } from "./common";
|
|
1
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
2
|
+
import { expandHomePath } from "./expand-home";
|
|
2
3
|
import { createMcpPermissionTargets } from "./mcp-targets";
|
|
3
|
-
import {
|
|
4
|
+
import { PATH_BEARING_TOOLS } from "./path-utils";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Construct a surface-appropriate input object from a raw value string.
|
|
@@ -66,13 +67,11 @@ export function normalizeInput(
|
|
|
66
67
|
input: unknown,
|
|
67
68
|
configuredMcpServerNames: readonly string[],
|
|
68
69
|
): NormalizedInput {
|
|
69
|
-
// --- Special surfaces (external_directory) ---
|
|
70
|
+
// --- Special surfaces (path, external_directory) ---
|
|
70
71
|
if (SPECIAL_PERMISSION_KEYS.has(toolName)) {
|
|
71
|
-
const record = toRecord(input);
|
|
72
|
-
const pathValue = typeof record.path === "string" ? record.path : null;
|
|
73
72
|
return {
|
|
74
73
|
surface: toolName,
|
|
75
|
-
values: [
|
|
74
|
+
values: [normalizePathSurfaceValue(input)],
|
|
76
75
|
resultExtras: {},
|
|
77
76
|
};
|
|
78
77
|
}
|
|
@@ -116,10 +115,9 @@ export function normalizeInput(
|
|
|
116
115
|
|
|
117
116
|
// --- Path-bearing tools (read, write, edit, grep, find, ls) ---
|
|
118
117
|
if (PATH_BEARING_TOOLS.has(toolName)) {
|
|
119
|
-
const path = getPathBearingToolPath(toolName, input);
|
|
120
118
|
return {
|
|
121
119
|
surface: toolName,
|
|
122
|
-
values: [
|
|
120
|
+
values: [normalizePathSurfaceValue(input)],
|
|
123
121
|
resultExtras: {},
|
|
124
122
|
};
|
|
125
123
|
}
|
|
@@ -131,3 +129,17 @@ export function normalizeInput(
|
|
|
131
129
|
resultExtras: {},
|
|
132
130
|
};
|
|
133
131
|
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Extract and home-expand the `input.path` lookup value shared by every path
|
|
135
|
+
* surface (`path`, `external_directory`, and the path-bearing tools).
|
|
136
|
+
*
|
|
137
|
+
* Missing, empty, or whitespace-only paths collapse to the surface catch-all
|
|
138
|
+
* `"*"`; otherwise `~/…` and `$HOME/…` prefixes are expanded to the OS home
|
|
139
|
+
* directory so values match home-anchored patterns symmetrically with how
|
|
140
|
+
* `compileWildcardPattern` expands the patterns themselves (#350).
|
|
141
|
+
*/
|
|
142
|
+
function normalizePathSurfaceValue(input: unknown): string {
|
|
143
|
+
const path = getNonEmptyString(toRecord(input).path);
|
|
144
|
+
return path === null ? "*" : expandHomePath(path);
|
|
145
|
+
}
|
package/src/path-utils.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
1
|
import { join, normalize, resolve, sep } from "node:path";
|
|
3
2
|
|
|
4
3
|
import { getNonEmptyString, toRecord } from "./common";
|
|
@@ -15,15 +14,7 @@ export function normalizePathForComparison(
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
18
|
-
|
|
19
|
-
if (normalizedPath === "~") {
|
|
20
|
-
normalizedPath = homedir();
|
|
21
|
-
} else if (
|
|
22
|
-
normalizedPath.startsWith("~/") ||
|
|
23
|
-
normalizedPath.startsWith("~\\")
|
|
24
|
-
) {
|
|
25
|
-
normalizedPath = join(homedir(), normalizedPath.slice(2));
|
|
26
|
-
}
|
|
17
|
+
normalizedPath = expandHomePath(normalizedPath);
|
|
27
18
|
|
|
28
19
|
const absolutePath = resolve(cwd, normalizedPath);
|
|
29
20
|
const normalizedAbsolutePath = normalize(absolutePath);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createActiveToolsCacheKey,
|
|
5
|
+
createBeforeAgentStartPromptStateKey,
|
|
6
|
+
shouldApplyCachedAgentStartState,
|
|
7
|
+
} from "#src/before-agent-start-cache";
|
|
8
|
+
import { createManager } from "#test/helpers/manager-harness";
|
|
9
|
+
|
|
10
|
+
test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
|
|
11
|
+
const allowedTools = ["read", "mcp"];
|
|
12
|
+
const activeToolsKey = createActiveToolsCacheKey(allowedTools);
|
|
13
|
+
const promptStateKey = createBeforeAgentStartPromptStateKey({
|
|
14
|
+
agentName: "code",
|
|
15
|
+
cwd: "C:/workspace/project",
|
|
16
|
+
permissionStamp: "permissions-v1",
|
|
17
|
+
systemPrompt: "Available tools:\n- read\n- mcp",
|
|
18
|
+
allowedToolNames: allowedTools,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
|
|
22
|
+
expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
|
|
23
|
+
false,
|
|
24
|
+
);
|
|
25
|
+
expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
|
|
26
|
+
expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
|
|
27
|
+
false,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
|
|
32
|
+
const { manager, globalConfigPath, cleanup } = createManager({
|
|
33
|
+
permission: { "*": "allow", write: "deny" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const baselineStamp = manager.getPolicyCacheStamp();
|
|
38
|
+
const baselineKey = createBeforeAgentStartPromptStateKey({
|
|
39
|
+
agentName: null,
|
|
40
|
+
cwd: "C:/workspace/project",
|
|
41
|
+
permissionStamp: baselineStamp,
|
|
42
|
+
systemPrompt: "Available tools:\n- read\n- write",
|
|
43
|
+
allowedToolNames: ["read"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
|
|
47
|
+
false,
|
|
48
|
+
);
|
|
49
|
+
expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
|
|
50
|
+
|
|
51
|
+
const updatedConfig = `${JSON.stringify(
|
|
52
|
+
{ permission: { "*": "allow", write: "allow" } },
|
|
53
|
+
null,
|
|
54
|
+
2,
|
|
55
|
+
)}\n`;
|
|
56
|
+
|
|
57
|
+
let updatedStamp = baselineStamp;
|
|
58
|
+
for (
|
|
59
|
+
let attempt = 0;
|
|
60
|
+
attempt < 10 && updatedStamp === baselineStamp;
|
|
61
|
+
attempt += 1
|
|
62
|
+
) {
|
|
63
|
+
const waitUntil = Date.now() + 2;
|
|
64
|
+
while (Date.now() < waitUntil) {
|
|
65
|
+
// Wait for the filesystem timestamp granularity to advance.
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
writeFileSync(globalConfigPath, updatedConfig, "utf8");
|
|
69
|
+
updatedStamp = manager.getPolicyCacheStamp();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
expect(updatedStamp).not.toBe(baselineStamp);
|
|
73
|
+
|
|
74
|
+
const invalidatedKey = createBeforeAgentStartPromptStateKey({
|
|
75
|
+
agentName: null,
|
|
76
|
+
cwd: "C:/workspace/project",
|
|
77
|
+
permissionStamp: updatedStamp,
|
|
78
|
+
systemPrompt: "Available tools:\n- read\n- write",
|
|
79
|
+
allowedToolNames: ["read", "write"],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
|
|
83
|
+
true,
|
|
84
|
+
);
|
|
85
|
+
expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
|
|
86
|
+
} finally {
|
|
87
|
+
cleanup();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -300,3 +300,99 @@ describe("external-directory session dedup", () => {
|
|
|
300
300
|
});
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
describe("session shutdown clears external-directory approvals", () => {
|
|
309
|
+
it("re-prompts for the same path after session shutdown", async () => {
|
|
310
|
+
// Build a fully wired handler inline so we can access session directly.
|
|
311
|
+
const { session, permissionManager, sessionRules, logger } =
|
|
312
|
+
makeRealSession();
|
|
313
|
+
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
314
|
+
|
|
315
|
+
// external_directory=ask; session-covered paths return allow/session.
|
|
316
|
+
vi.mocked(permissionManager.checkPermission).mockImplementation(
|
|
317
|
+
(surface, input, _agentName, rules): PermissionCheckResult => {
|
|
318
|
+
if (surface === "external_directory") {
|
|
319
|
+
const record = (input ?? {}) as Record<string, unknown>;
|
|
320
|
+
const pathValue =
|
|
321
|
+
typeof record.path === "string" ? record.path : null;
|
|
322
|
+
if (pathValue && rules && rules.length > 0) {
|
|
323
|
+
const match = rules.findLast(
|
|
324
|
+
(r) =>
|
|
325
|
+
r.surface === "external_directory" &&
|
|
326
|
+
wildcardMatch(r.pattern, pathValue),
|
|
327
|
+
);
|
|
328
|
+
if (match) {
|
|
329
|
+
return {
|
|
330
|
+
state: "allow",
|
|
331
|
+
toolName: surface,
|
|
332
|
+
source: "session",
|
|
333
|
+
origin: "session",
|
|
334
|
+
matchedPattern: match.pattern,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
state: "ask",
|
|
340
|
+
toolName: surface,
|
|
341
|
+
source: "special",
|
|
342
|
+
origin: "global",
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
return {
|
|
346
|
+
state: "allow",
|
|
347
|
+
toolName: surface,
|
|
348
|
+
source: "tool",
|
|
349
|
+
origin: "builtin",
|
|
350
|
+
};
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const events = makeEvents();
|
|
355
|
+
const reporter = new GateDecisionReporter(logger, events);
|
|
356
|
+
const prompter: GatePrompter = {
|
|
357
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
358
|
+
// Simulate "Yes, for this session" on first call, "Yes" on subsequent.
|
|
359
|
+
prompt: vi
|
|
360
|
+
.fn<GatePrompter["prompt"]>()
|
|
361
|
+
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
362
|
+
};
|
|
363
|
+
const runner = new GateRunner(resolver, sessionRules, prompter, reporter);
|
|
364
|
+
const handler = new PermissionGateHandler(
|
|
365
|
+
session,
|
|
366
|
+
makeToolRegistry({
|
|
367
|
+
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
368
|
+
}),
|
|
369
|
+
new ToolCallGatePipeline(resolver, session),
|
|
370
|
+
new SkillInputGatePipeline(resolver),
|
|
371
|
+
runner,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
const externalPath = "/tmp/sibling/foo.ts";
|
|
375
|
+
const ctx = makeCtx();
|
|
376
|
+
const event = {
|
|
377
|
+
type: "tool_call",
|
|
378
|
+
toolCallId: "tc-1",
|
|
379
|
+
toolName: "read",
|
|
380
|
+
input: { path: externalPath },
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// First access: prompt fires and records session approval.
|
|
384
|
+
await handler.handleToolCall(event, ctx);
|
|
385
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
386
|
+
|
|
387
|
+
// Second access: covered by session approval — no re-prompt.
|
|
388
|
+
await handler.handleToolCall({ ...event, toolCallId: "tc-2" }, ctx);
|
|
389
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
390
|
+
|
|
391
|
+
// Shutdown clears session approvals.
|
|
392
|
+
session.shutdown();
|
|
393
|
+
|
|
394
|
+
// Third access: session rules cleared — must re-prompt.
|
|
395
|
+
await handler.handleToolCall({ ...event, toolCallId: "tc-3" }, ctx);
|
|
396
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(2);
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -216,3 +216,60 @@ describe("describeBashPathGate", () => {
|
|
|
216
216
|
expect(desc.decision.value).toBe(".env");
|
|
217
217
|
});
|
|
218
218
|
});
|
|
219
|
+
|
|
220
|
+
// Home-relative path characterization (#350) ──────────────────────────────
|
|
221
|
+
//
|
|
222
|
+
// The parser extracts ~/... tokens from bash commands; the resolver receives
|
|
223
|
+
// the raw token and normalizeInput handles expansion. These tests verify the
|
|
224
|
+
// gate correctly dispatches ~/... tokens through the deny/ask path.
|
|
225
|
+
|
|
226
|
+
describe("describeBashPathGate — home-relative paths", () => {
|
|
227
|
+
it("extracts ~/... token and builds descriptor on deny", async () => {
|
|
228
|
+
// node:os is mocked: homedir() returns "/mock/home".
|
|
229
|
+
// cat ~/.ssh/config → token "~/.ssh/config" extracted.
|
|
230
|
+
const resolver = makePathDispatchResolver(
|
|
231
|
+
{
|
|
232
|
+
"~/.ssh/config": makeCheckResult({
|
|
233
|
+
state: "deny",
|
|
234
|
+
matchedPattern: "~/.ssh/*",
|
|
235
|
+
}),
|
|
236
|
+
},
|
|
237
|
+
makeCheckResult({ state: "allow" }),
|
|
238
|
+
);
|
|
239
|
+
const result = (await describeGate(
|
|
240
|
+
makeTcc({ input: { command: "cat ~/.ssh/config" } }),
|
|
241
|
+
resolver,
|
|
242
|
+
)) as GateDescriptor;
|
|
243
|
+
|
|
244
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
245
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
246
|
+
expect(result.denialContext).toMatchObject({
|
|
247
|
+
kind: "bash_path",
|
|
248
|
+
command: "cat ~/.ssh/config",
|
|
249
|
+
pathValue: "~/.ssh/config",
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("extracts $HOME/... token and builds descriptor on deny", async () => {
|
|
254
|
+
const resolver = makePathDispatchResolver(
|
|
255
|
+
{
|
|
256
|
+
"$HOME/.ssh/config": makeCheckResult({
|
|
257
|
+
state: "deny",
|
|
258
|
+
matchedPattern: "$HOME/.ssh/*",
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
makeCheckResult({ state: "allow" }),
|
|
262
|
+
);
|
|
263
|
+
const result = (await describeGate(
|
|
264
|
+
makeTcc({ input: { command: "cat $HOME/.ssh/config" } }),
|
|
265
|
+
resolver,
|
|
266
|
+
)) as GateDescriptor;
|
|
267
|
+
|
|
268
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
269
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
270
|
+
expect(result.denialContext).toMatchObject({
|
|
271
|
+
kind: "bash_path",
|
|
272
|
+
pathValue: "$HOME/.ssh/config",
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -148,3 +148,61 @@ describe("describePathGate", () => {
|
|
|
148
148
|
);
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
|
+
|
|
152
|
+
// Home-relative path characterization (#350) ──────────────────────────────
|
|
153
|
+
//
|
|
154
|
+
// The gate passes the raw path to the resolver; home expansion is handled
|
|
155
|
+
// downstream by normalizeInput. These tests lock in that the gate works
|
|
156
|
+
// correctly when the tool input contains a ~/... or $HOME/... path.
|
|
157
|
+
|
|
158
|
+
describe("describePathGate — home-relative paths", () => {
|
|
159
|
+
it("passes raw ~/... path to resolver and builds descriptor on deny", () => {
|
|
160
|
+
const resolver = makeResolver(
|
|
161
|
+
makeCheckResult({ state: "deny", matchedPattern: "~/.ssh/*" }),
|
|
162
|
+
);
|
|
163
|
+
const result = describePathGate(
|
|
164
|
+
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
165
|
+
resolver,
|
|
166
|
+
) as GateDescriptor;
|
|
167
|
+
|
|
168
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
169
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
170
|
+
// Raw path preserved in denial context for display.
|
|
171
|
+
expect(result.denialContext).toMatchObject({
|
|
172
|
+
kind: "path",
|
|
173
|
+
toolName: "read",
|
|
174
|
+
pathValue: "~/.ssh/config",
|
|
175
|
+
});
|
|
176
|
+
expect(resolver.resolve).toHaveBeenCalledWith(
|
|
177
|
+
"path",
|
|
178
|
+
{ path: "~/.ssh/config" },
|
|
179
|
+
undefined,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("passes raw $HOME/... path to resolver and builds descriptor on deny", () => {
|
|
184
|
+
const resolver = makeResolver(
|
|
185
|
+
makeCheckResult({ state: "deny", matchedPattern: "$HOME/.ssh/*" }),
|
|
186
|
+
);
|
|
187
|
+
const result = describePathGate(
|
|
188
|
+
makeTcc({ input: { path: "$HOME/.ssh/config" } }),
|
|
189
|
+
resolver,
|
|
190
|
+
) as GateDescriptor;
|
|
191
|
+
|
|
192
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
193
|
+
expect(result.preCheck?.state).toBe("deny");
|
|
194
|
+
expect(result.denialContext).toMatchObject({
|
|
195
|
+
kind: "path",
|
|
196
|
+
pathValue: "$HOME/.ssh/config",
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("returns null when home-relative path resolves to allow", () => {
|
|
201
|
+
const resolver = makeResolver(makeCheckResult({ state: "allow" }));
|
|
202
|
+
const result = describePathGate(
|
|
203
|
+
makeTcc({ input: { path: "~/.ssh/config" } }),
|
|
204
|
+
resolver,
|
|
205
|
+
);
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -290,3 +290,106 @@ describe("handleToolCall — bash command chain gate", () => {
|
|
|
290
290
|
expect(result).toEqual({});
|
|
291
291
|
});
|
|
292
292
|
});
|
|
293
|
+
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Moved from permission-system.test.ts catch-all (#342)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
describe("handleToolCall — bash external-directory policy states", () => {
|
|
299
|
+
it("allows bash command with only internal paths when external_directory is denied", async () => {
|
|
300
|
+
const { handler } = makeHandler({ tools: ["bash"] });
|
|
301
|
+
const event = makeToolCallEvent("bash", {
|
|
302
|
+
input: { command: "cat src/index.ts" },
|
|
303
|
+
});
|
|
304
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
305
|
+
expect(result).toEqual({});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("blocks bash command with external path when external_directory is ask and no UI", async () => {
|
|
309
|
+
const { handler } = makeHandler({
|
|
310
|
+
session: {
|
|
311
|
+
checkPermission: makeSurfaceCheck({
|
|
312
|
+
external_directory: { state: "ask", source: "special" },
|
|
313
|
+
}),
|
|
314
|
+
},
|
|
315
|
+
tools: ["bash"],
|
|
316
|
+
prompter: {
|
|
317
|
+
canConfirm: vi.fn().mockReturnValue(false),
|
|
318
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
const event = makeToolCallEvent("bash", {
|
|
322
|
+
input: { command: "cat /etc/hosts" },
|
|
323
|
+
});
|
|
324
|
+
const result = await handler.handleToolCall(
|
|
325
|
+
event,
|
|
326
|
+
makeCtx({ hasUI: false }),
|
|
327
|
+
);
|
|
328
|
+
expect(result).toMatchObject({ block: true });
|
|
329
|
+
expect(String((result as { reason?: unknown }).reason)).toMatch(
|
|
330
|
+
/no interactive UI/i,
|
|
331
|
+
);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("allows bash command with external path when external_directory is allow", async () => {
|
|
335
|
+
const { handler } = makeHandler({
|
|
336
|
+
session: {
|
|
337
|
+
checkPermission: makeSurfaceCheck({
|
|
338
|
+
external_directory: { state: "allow", source: "special" },
|
|
339
|
+
}),
|
|
340
|
+
},
|
|
341
|
+
tools: ["bash"],
|
|
342
|
+
});
|
|
343
|
+
const event = makeToolCallEvent("bash", {
|
|
344
|
+
input: { command: "cat /etc/hosts" },
|
|
345
|
+
});
|
|
346
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
347
|
+
expect(result).toEqual({});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("applies bash pattern deny after external_directory allow", async () => {
|
|
351
|
+
const { handler } = makeHandler({
|
|
352
|
+
session: {
|
|
353
|
+
checkPermission: makeSurfaceCheck(
|
|
354
|
+
{
|
|
355
|
+
external_directory: { state: "allow", source: "special" },
|
|
356
|
+
bash: { state: "deny", source: "bash" },
|
|
357
|
+
},
|
|
358
|
+
{ state: "allow" },
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
tools: ["bash"],
|
|
362
|
+
});
|
|
363
|
+
const event = makeToolCallEvent("bash", {
|
|
364
|
+
input: { command: "cat /etc/hosts" },
|
|
365
|
+
});
|
|
366
|
+
const result = await handler.handleToolCall(event, makeCtx());
|
|
367
|
+
expect(result).toMatchObject({ block: true });
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe("handleToolCall — generic ask prompt content", () => {
|
|
372
|
+
it("ask prompt includes serialized tool input for informed approval", async () => {
|
|
373
|
+
const { handler, prompter } = makeHandler({
|
|
374
|
+
session: {
|
|
375
|
+
checkPermission: makeSurfaceCheck({
|
|
376
|
+
weather_lookup: { state: "ask" },
|
|
377
|
+
}),
|
|
378
|
+
},
|
|
379
|
+
tools: ["weather_lookup"],
|
|
380
|
+
prompter: {
|
|
381
|
+
canConfirm: vi.fn().mockReturnValue(true),
|
|
382
|
+
prompt: vi.fn().mockResolvedValue({ approved: false, state: "denied" }),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
const event = makeToolCallEvent("weather_lookup", {
|
|
386
|
+
input: { city: "Chicago", units: "metric" },
|
|
387
|
+
});
|
|
388
|
+
await handler.handleToolCall(event, makeCtx());
|
|
389
|
+
expect(vi.mocked(prompter.prompt)).toHaveBeenCalledTimes(1);
|
|
390
|
+
const promptDetails = vi.mocked(prompter.prompt).mock.calls[0][0];
|
|
391
|
+
expect(promptDetails.message).toMatch(
|
|
392
|
+
/\{"city":"Chicago","units":"metric"\}/,
|
|
393
|
+
);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -15,6 +15,11 @@ export type CreateManagerOptions = {
|
|
|
15
15
|
mcpServerNames?: readonly string[];
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
export type CreateManagerWithProjectOptions = CreateManagerOptions & {
|
|
19
|
+
projectConfig?: ScopeConfig;
|
|
20
|
+
projectAgentFiles?: Record<string, string>;
|
|
21
|
+
};
|
|
22
|
+
|
|
18
23
|
export function createManager(
|
|
19
24
|
config: ScopeConfig,
|
|
20
25
|
agentFiles: Record<string, string> = {},
|
|
@@ -49,3 +54,59 @@ export function createManager(
|
|
|
49
54
|
},
|
|
50
55
|
};
|
|
51
56
|
}
|
|
57
|
+
|
|
58
|
+
export function createManagerWithProject(
|
|
59
|
+
config: ScopeConfig,
|
|
60
|
+
agentFiles: Record<string, string> = {},
|
|
61
|
+
options: CreateManagerWithProjectOptions = {},
|
|
62
|
+
) {
|
|
63
|
+
const baseDir = mkdtempSync(
|
|
64
|
+
join(tmpdir(), "pi-permission-system-proj-test-"),
|
|
65
|
+
);
|
|
66
|
+
const globalConfigPath = join(baseDir, "pi-permissions.jsonc");
|
|
67
|
+
const agentsDir = join(baseDir, "agents");
|
|
68
|
+
const projectRoot = join(baseDir, "project");
|
|
69
|
+
const projectGlobalConfigPath = join(projectRoot, "pi-permissions.jsonc");
|
|
70
|
+
const projectAgentsDir = join(projectRoot, "agents");
|
|
71
|
+
|
|
72
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
73
|
+
mkdirSync(projectAgentsDir, { recursive: true });
|
|
74
|
+
|
|
75
|
+
writeFileSync(
|
|
76
|
+
globalConfigPath,
|
|
77
|
+
`${JSON.stringify(config, null, 2)}\n`,
|
|
78
|
+
"utf8",
|
|
79
|
+
);
|
|
80
|
+
if (options.projectConfig) {
|
|
81
|
+
writeFileSync(
|
|
82
|
+
projectGlobalConfigPath,
|
|
83
|
+
`${JSON.stringify(options.projectConfig, null, 2)}\n`,
|
|
84
|
+
"utf8",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const [name, content] of Object.entries(agentFiles)) {
|
|
89
|
+
writeFileSync(join(agentsDir, `${name}.md`), content, "utf8");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
for (const [name, content] of Object.entries(
|
|
93
|
+
options.projectAgentFiles ?? {},
|
|
94
|
+
)) {
|
|
95
|
+
writeFileSync(join(projectAgentsDir, `${name}.md`), content, "utf8");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const manager = new PermissionManager({
|
|
99
|
+
globalConfigPath,
|
|
100
|
+
agentsDir,
|
|
101
|
+
projectGlobalConfigPath,
|
|
102
|
+
projectAgentsDir,
|
|
103
|
+
mcpServerNames: options.mcpServerNames,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
manager,
|
|
108
|
+
cleanup: (): void => {
|
|
109
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|