@gotgenes/pi-permission-system 2.0.0 → 3.0.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 +36 -0
- package/README.md +92 -35
- package/config/config.example.json +6 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +114 -16
- package/src/active-agent.ts +58 -0
- package/src/config-loader.ts +398 -0
- package/src/config-paths.ts +34 -0
- package/src/config-reporter.ts +16 -8
- package/src/external-directory.ts +113 -0
- package/src/forwarded-permissions/io.ts +328 -0
- package/src/forwarded-permissions/polling.ts +334 -0
- package/src/index.ts +153 -1095
- package/src/permission-manager.ts +25 -111
- package/src/permission-prompts.ts +131 -0
- package/src/subagent-context.ts +52 -0
- package/src/tool-input-preview.ts +206 -0
- package/tests/active-agent.test.ts +160 -0
- package/tests/bash-filter.test.ts +137 -0
- package/tests/common.test.ts +189 -0
- package/tests/config-loader.test.ts +364 -0
- package/tests/config-paths.test.ts +78 -0
- package/tests/config-reporter.test.ts +42 -33
- package/tests/extension-config.test.ts +51 -0
- package/tests/external-directory.test.ts +250 -0
- package/tests/permission-prompts.test.ts +301 -0
- package/tests/permission-system.test.ts +9 -26
- package/tests/session-start.test.ts +8 -33
- package/tests/skill-prompt-sanitizer.test.ts +244 -0
- package/tests/subagent-context.test.ts +124 -0
- package/tests/system-prompt-sanitizer.test.ts +186 -0
- package/tests/tool-input-preview.test.ts +452 -0
- package/tests/tool-registry.test.ts +155 -0
- package/tests/wildcard-matcher.test.ts +180 -0
- package/tests/yolo-mode.test.ts +110 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
5
|
+
vi.mock("node:os", () => ({
|
|
6
|
+
homedir: vi.fn(() => "/mock/home"),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
formatExternalDirectoryAskPrompt,
|
|
11
|
+
formatExternalDirectoryDenyReason,
|
|
12
|
+
formatExternalDirectoryHardStopHint,
|
|
13
|
+
formatExternalDirectoryUserDeniedReason,
|
|
14
|
+
getPathBearingToolPath,
|
|
15
|
+
isPathOutsideWorkingDirectory,
|
|
16
|
+
isPathWithinDirectory,
|
|
17
|
+
normalizePathForComparison,
|
|
18
|
+
PATH_BEARING_TOOLS,
|
|
19
|
+
} from "../src/external-directory.js";
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("PATH_BEARING_TOOLS", () => {
|
|
26
|
+
test("contains the expected tool names", () => {
|
|
27
|
+
for (const tool of ["read", "write", "edit", "find", "grep", "ls"]) {
|
|
28
|
+
expect(PATH_BEARING_TOOLS.has(tool)).toBe(true);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("does not contain bash or mcp", () => {
|
|
33
|
+
expect(PATH_BEARING_TOOLS.has("bash")).toBe(false);
|
|
34
|
+
expect(PATH_BEARING_TOOLS.has("mcp")).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("normalizePathForComparison", () => {
|
|
39
|
+
const cwd = "/projects/my-app";
|
|
40
|
+
|
|
41
|
+
test("resolves absolute path unchanged", () => {
|
|
42
|
+
expect(normalizePathForComparison("/usr/local/bin", cwd)).toBe(
|
|
43
|
+
"/usr/local/bin",
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("resolves relative path against cwd", () => {
|
|
48
|
+
expect(normalizePathForComparison("src/foo.ts", cwd)).toBe(
|
|
49
|
+
"/projects/my-app/src/foo.ts",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("expands bare ~ to homedir", () => {
|
|
54
|
+
expect(normalizePathForComparison("~", cwd)).toBe("/mock/home");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("expands ~/... to homedir-relative path", () => {
|
|
58
|
+
expect(normalizePathForComparison("~/docs/readme.md", cwd)).toBe(
|
|
59
|
+
join("/mock/home", "docs/readme.md"),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("strips leading @ before resolving", () => {
|
|
64
|
+
expect(normalizePathForComparison("@/usr/local/bin", cwd)).toBe(
|
|
65
|
+
"/usr/local/bin",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("strips surrounding quotes", () => {
|
|
70
|
+
expect(normalizePathForComparison("'/usr/local/bin'", cwd)).toBe(
|
|
71
|
+
"/usr/local/bin",
|
|
72
|
+
);
|
|
73
|
+
expect(normalizePathForComparison('"/usr/local/bin"', cwd)).toBe(
|
|
74
|
+
"/usr/local/bin",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns empty string for blank/whitespace-only path", () => {
|
|
79
|
+
expect(normalizePathForComparison("", cwd)).toBe("");
|
|
80
|
+
expect(normalizePathForComparison(" ", cwd)).toBe("");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("isPathWithinDirectory", () => {
|
|
85
|
+
test("returns true when path equals directory", () => {
|
|
86
|
+
expect(isPathWithinDirectory("/a/b", "/a/b")).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("returns true when path is a direct child", () => {
|
|
90
|
+
expect(isPathWithinDirectory("/a/b/c", "/a/b")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("returns true when path is a deep descendant", () => {
|
|
94
|
+
expect(isPathWithinDirectory("/a/b/c/d/e", "/a/b")).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns false when path is a sibling directory", () => {
|
|
98
|
+
expect(isPathWithinDirectory("/a/bc", "/a/b")).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("returns false when path is outside the directory", () => {
|
|
102
|
+
expect(isPathWithinDirectory("/other/path", "/a/b")).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns false for empty path", () => {
|
|
106
|
+
expect(isPathWithinDirectory("", "/a/b")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns false for empty directory", () => {
|
|
110
|
+
expect(isPathWithinDirectory("/a/b", "")).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("getPathBearingToolPath", () => {
|
|
115
|
+
test("returns path for a path-bearing tool", () => {
|
|
116
|
+
expect(getPathBearingToolPath("read", { path: "/src/foo.ts" })).toBe(
|
|
117
|
+
"/src/foo.ts",
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns null for a non-path-bearing tool", () => {
|
|
122
|
+
expect(getPathBearingToolPath("bash", { path: "/src/foo.ts" })).toBeNull();
|
|
123
|
+
expect(getPathBearingToolPath("mcp", { path: "/src/foo.ts" })).toBeNull();
|
|
124
|
+
expect(getPathBearingToolPath("task", { path: "/src/foo.ts" })).toBeNull();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("returns null when input has no path", () => {
|
|
128
|
+
expect(getPathBearingToolPath("read", {})).toBeNull();
|
|
129
|
+
expect(getPathBearingToolPath("read", { path: "" })).toBeNull();
|
|
130
|
+
expect(getPathBearingToolPath("read", null)).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("isPathOutsideWorkingDirectory", () => {
|
|
135
|
+
const cwd = "/projects/my-app";
|
|
136
|
+
|
|
137
|
+
test("returns false when path is inside cwd", () => {
|
|
138
|
+
expect(isPathOutsideWorkingDirectory("/projects/my-app/src", cwd)).toBe(
|
|
139
|
+
false,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("returns false when path equals cwd", () => {
|
|
144
|
+
expect(isPathOutsideWorkingDirectory("/projects/my-app", cwd)).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("returns true when path is outside cwd", () => {
|
|
148
|
+
expect(isPathOutsideWorkingDirectory("/etc/passwd", cwd)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("returns true for home directory when outside cwd", () => {
|
|
152
|
+
expect(isPathOutsideWorkingDirectory("~/secrets", cwd)).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("returns false for relative path resolving inside cwd", () => {
|
|
156
|
+
expect(isPathOutsideWorkingDirectory("src/index.ts", cwd)).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("returns false for empty path (normalizes to empty string)", () => {
|
|
160
|
+
expect(isPathOutsideWorkingDirectory("", cwd)).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("formatExternalDirectoryHardStopHint", () => {
|
|
165
|
+
test("returns the hard stop instruction string", () => {
|
|
166
|
+
const hint = formatExternalDirectoryHardStopHint();
|
|
167
|
+
expect(hint).toContain("Hard stop");
|
|
168
|
+
expect(hint).toContain("external directory");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("formatExternalDirectoryAskPrompt", () => {
|
|
173
|
+
test("uses 'Current agent' when no agent name provided", () => {
|
|
174
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
175
|
+
"read",
|
|
176
|
+
"/etc/passwd",
|
|
177
|
+
"/projects/my-app",
|
|
178
|
+
);
|
|
179
|
+
expect(result).toContain("Current agent");
|
|
180
|
+
expect(result).toContain("read");
|
|
181
|
+
expect(result).toContain("/etc/passwd");
|
|
182
|
+
expect(result).toContain("/projects/my-app");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("uses agent name when provided", () => {
|
|
186
|
+
const result = formatExternalDirectoryAskPrompt(
|
|
187
|
+
"write",
|
|
188
|
+
"/tmp/out.txt",
|
|
189
|
+
"/projects/my-app",
|
|
190
|
+
"my-agent",
|
|
191
|
+
);
|
|
192
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
193
|
+
expect(result).toContain("write");
|
|
194
|
+
expect(result).toContain("/tmp/out.txt");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("formatExternalDirectoryDenyReason", () => {
|
|
199
|
+
test("includes tool name, path, cwd, agent name, and hard stop hint", () => {
|
|
200
|
+
const result = formatExternalDirectoryDenyReason(
|
|
201
|
+
"read",
|
|
202
|
+
"/etc/passwd",
|
|
203
|
+
"/projects/my-app",
|
|
204
|
+
"sec-agent",
|
|
205
|
+
);
|
|
206
|
+
expect(result).toContain("Agent 'sec-agent'");
|
|
207
|
+
expect(result).toContain("read");
|
|
208
|
+
expect(result).toContain("/etc/passwd");
|
|
209
|
+
expect(result).toContain("/projects/my-app");
|
|
210
|
+
expect(result).toContain("Hard stop");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("uses 'Current agent' without agent name", () => {
|
|
214
|
+
const result = formatExternalDirectoryDenyReason(
|
|
215
|
+
"read",
|
|
216
|
+
"/etc",
|
|
217
|
+
"/projects",
|
|
218
|
+
);
|
|
219
|
+
expect(result).toContain("Current agent");
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("formatExternalDirectoryUserDeniedReason", () => {
|
|
224
|
+
test("includes tool name and path", () => {
|
|
225
|
+
const result = formatExternalDirectoryUserDeniedReason(
|
|
226
|
+
"edit",
|
|
227
|
+
"/etc/hosts",
|
|
228
|
+
);
|
|
229
|
+
expect(result).toContain("edit");
|
|
230
|
+
expect(result).toContain("/etc/hosts");
|
|
231
|
+
expect(result).toContain("Hard stop");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("appends denial reason when provided", () => {
|
|
235
|
+
const result = formatExternalDirectoryUserDeniedReason(
|
|
236
|
+
"edit",
|
|
237
|
+
"/etc/hosts",
|
|
238
|
+
"too risky",
|
|
239
|
+
);
|
|
240
|
+
expect(result).toContain("Reason: too risky");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("omits reason suffix when not provided", () => {
|
|
244
|
+
const result = formatExternalDirectoryUserDeniedReason(
|
|
245
|
+
"edit",
|
|
246
|
+
"/etc/hosts",
|
|
247
|
+
);
|
|
248
|
+
expect(result).not.toContain("Reason:");
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock tool-input-preview collaborator before importing the module under test.
|
|
4
|
+
vi.mock("../src/tool-input-preview.js", () => ({
|
|
5
|
+
formatToolInputForPrompt: vi.fn(() => "mocked preview"),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
formatAskPrompt,
|
|
10
|
+
formatDenyReason,
|
|
11
|
+
formatMissingToolNameReason,
|
|
12
|
+
formatPermissionHardStopHint,
|
|
13
|
+
formatSkillAskPrompt,
|
|
14
|
+
formatSkillPathAskPrompt,
|
|
15
|
+
formatSkillPathDenyReason,
|
|
16
|
+
formatUnknownToolReason,
|
|
17
|
+
formatUserDeniedReason,
|
|
18
|
+
} from "../src/permission-prompts.js";
|
|
19
|
+
import type { SkillPromptEntry } from "../src/skill-prompt-sanitizer.js";
|
|
20
|
+
import { formatToolInputForPrompt } from "../src/tool-input-preview.js";
|
|
21
|
+
import type { PermissionCheckResult } from "../src/types.js";
|
|
22
|
+
|
|
23
|
+
const mockedFormatToolInput = vi.mocked(formatToolInputForPrompt);
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function toolResult(
|
|
31
|
+
toolName: string,
|
|
32
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
33
|
+
): PermissionCheckResult {
|
|
34
|
+
return { toolName, state: "ask", source: "tool", ...overrides };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mcpResult(
|
|
38
|
+
target: string,
|
|
39
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
40
|
+
): PermissionCheckResult {
|
|
41
|
+
return {
|
|
42
|
+
toolName: "mcp",
|
|
43
|
+
target,
|
|
44
|
+
state: "ask",
|
|
45
|
+
source: "tool",
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function skillEntry(name: string): SkillPromptEntry {
|
|
51
|
+
return {
|
|
52
|
+
name,
|
|
53
|
+
description: "A skill",
|
|
54
|
+
location: `/skills/${name}/SKILL.md`,
|
|
55
|
+
state: "ask",
|
|
56
|
+
normalizedLocation: `/skills/${name}/SKILL.md`,
|
|
57
|
+
normalizedBaseDir: `/skills/${name}`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("formatMissingToolNameReason", () => {
|
|
62
|
+
test("mentions missing tool name and pi.getAllTools()", () => {
|
|
63
|
+
const result = formatMissingToolNameReason();
|
|
64
|
+
expect(result).toContain("no tool name");
|
|
65
|
+
expect(result).toContain("pi.getAllTools()");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("formatUnknownToolReason", () => {
|
|
70
|
+
test("mentions the unknown tool name and lists available tools", () => {
|
|
71
|
+
const result = formatUnknownToolReason("phantom", ["read", "write"]);
|
|
72
|
+
expect(result).toContain("phantom");
|
|
73
|
+
expect(result).toContain("read");
|
|
74
|
+
expect(result).toContain("write");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("includes MCP hint for non-mcp tool names", () => {
|
|
78
|
+
const result = formatUnknownToolReason("my-server:tool", ["mcp"]);
|
|
79
|
+
expect(result).toContain("mcp");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("omits MCP hint when tool name is 'mcp'", () => {
|
|
83
|
+
const result = formatUnknownToolReason("mcp", []);
|
|
84
|
+
expect(result).not.toContain("call the registered 'mcp' tool");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("shows 'none' when no tools are registered", () => {
|
|
88
|
+
const result = formatUnknownToolReason("ghost", []);
|
|
89
|
+
expect(result).toContain("none");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("caps preview at 10 tools and appends ellipsis for longer lists", () => {
|
|
93
|
+
const tools = Array.from({ length: 15 }, (_, i) => `tool${i}`);
|
|
94
|
+
const result = formatUnknownToolReason("ghost", tools);
|
|
95
|
+
expect(result).toContain("...");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("formatPermissionHardStopHint", () => {
|
|
100
|
+
test("returns MCP-specific message for mcp tool with target", () => {
|
|
101
|
+
const result = formatPermissionHardStopHint(mcpResult("server:tool"));
|
|
102
|
+
expect(result).toContain("MCP permission denial");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns MCP-specific message for mcp source with target", () => {
|
|
106
|
+
const result = formatPermissionHardStopHint(
|
|
107
|
+
toolResult("anything", { source: "mcp", target: "server:tool" }),
|
|
108
|
+
);
|
|
109
|
+
expect(result).toContain("MCP permission denial");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("returns generic message for non-MCP tools", () => {
|
|
113
|
+
const result = formatPermissionHardStopHint(toolResult("read"));
|
|
114
|
+
expect(result).toContain("Hard stop");
|
|
115
|
+
expect(result).not.toContain("MCP");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("formatDenyReason", () => {
|
|
120
|
+
test("includes tool name and hard stop hint", () => {
|
|
121
|
+
const result = formatDenyReason(toolResult("read"));
|
|
122
|
+
expect(result).toContain("read");
|
|
123
|
+
expect(result).toContain("Hard stop");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("includes agent name when provided", () => {
|
|
127
|
+
const result = formatDenyReason(toolResult("write"), "my-agent");
|
|
128
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("includes MCP target for mcp results", () => {
|
|
132
|
+
const result = formatDenyReason(mcpResult("server:do-thing"));
|
|
133
|
+
expect(result).toContain("server:do-thing");
|
|
134
|
+
expect(result).toContain("MCP");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("includes bash command when present", () => {
|
|
138
|
+
const result = formatDenyReason(
|
|
139
|
+
toolResult("bash", { command: "rm -rf /" }),
|
|
140
|
+
);
|
|
141
|
+
expect(result).toContain("rm -rf /");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("includes matched pattern when present", () => {
|
|
145
|
+
const result = formatDenyReason(
|
|
146
|
+
toolResult("bash", { command: "rm -rf /", matchedPattern: "rm *" }),
|
|
147
|
+
);
|
|
148
|
+
expect(result).toContain("matched 'rm *'");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("formatUserDeniedReason", () => {
|
|
153
|
+
test("mentions tool name for generic tools", () => {
|
|
154
|
+
const result = formatUserDeniedReason(toolResult("read"));
|
|
155
|
+
expect(result).toContain("read");
|
|
156
|
+
expect(result).toContain("Hard stop");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("mentions bash command for bash results", () => {
|
|
160
|
+
const result = formatUserDeniedReason(
|
|
161
|
+
toolResult("bash", { command: "ls -la" }),
|
|
162
|
+
);
|
|
163
|
+
expect(result).toContain("ls -la");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("mentions MCP target for mcp results", () => {
|
|
167
|
+
const result = formatUserDeniedReason(mcpResult("server:query"));
|
|
168
|
+
expect(result).toContain("server:query");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("appends denial reason when provided", () => {
|
|
172
|
+
const result = formatUserDeniedReason(toolResult("read"), "too sensitive");
|
|
173
|
+
expect(result).toContain("Reason: too sensitive");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("omits reason suffix when not provided", () => {
|
|
177
|
+
const result = formatUserDeniedReason(toolResult("read"));
|
|
178
|
+
expect(result).not.toContain("Reason:");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("formatAskPrompt", () => {
|
|
183
|
+
test("uses 'Current agent' when no agent name given", () => {
|
|
184
|
+
const result = formatAskPrompt(toolResult("read"), undefined, {
|
|
185
|
+
path: "/src",
|
|
186
|
+
});
|
|
187
|
+
expect(result).toContain("Current agent");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("uses agent name when provided", () => {
|
|
191
|
+
const result = formatAskPrompt(toolResult("read"), "my-agent", {
|
|
192
|
+
path: "/src",
|
|
193
|
+
});
|
|
194
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("formats bash prompt with command and no tool-input-preview call", () => {
|
|
198
|
+
const result = formatAskPrompt(
|
|
199
|
+
toolResult("bash", { command: "git status" }),
|
|
200
|
+
);
|
|
201
|
+
expect(result).toContain("git status");
|
|
202
|
+
expect(result).toContain("Allow this command?");
|
|
203
|
+
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("formats bash prompt with matched pattern", () => {
|
|
207
|
+
const result = formatAskPrompt(
|
|
208
|
+
toolResult("bash", { command: "git push", matchedPattern: "git *" }),
|
|
209
|
+
);
|
|
210
|
+
expect(result).toContain("matched 'git *'");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("formats MCP prompt with target", () => {
|
|
214
|
+
const result = formatAskPrompt(mcpResult("server:query"));
|
|
215
|
+
expect(result).toContain("server:query");
|
|
216
|
+
expect(result).toContain("Allow this call?");
|
|
217
|
+
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("formats MCP prompt with matched pattern", () => {
|
|
221
|
+
const result = formatAskPrompt(
|
|
222
|
+
mcpResult("server:query", { matchedPattern: "server:*" }),
|
|
223
|
+
);
|
|
224
|
+
expect(result).toContain("matched 'server:*'");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("calls formatToolInputForPrompt for non-bash non-mcp tools", () => {
|
|
228
|
+
mockedFormatToolInput.mockReturnValue("for '/src/foo.ts'");
|
|
229
|
+
const result = formatAskPrompt(toolResult("read"), undefined, {
|
|
230
|
+
path: "/src/foo.ts",
|
|
231
|
+
});
|
|
232
|
+
expect(mockedFormatToolInput).toHaveBeenCalledWith("read", {
|
|
233
|
+
path: "/src/foo.ts",
|
|
234
|
+
});
|
|
235
|
+
expect(result).toContain("for '/src/foo.ts'");
|
|
236
|
+
expect(result).toContain("Allow this call?");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("omits input suffix when formatToolInputForPrompt returns empty string", () => {
|
|
240
|
+
mockedFormatToolInput.mockReturnValue("");
|
|
241
|
+
const result = formatAskPrompt(toolResult("task"));
|
|
242
|
+
expect(result).toContain("task");
|
|
243
|
+
expect(result).not.toContain("undefined");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("formatSkillAskPrompt", () => {
|
|
248
|
+
test("includes skill name and agent name", () => {
|
|
249
|
+
const result = formatSkillAskPrompt("librarian", "my-agent");
|
|
250
|
+
expect(result).toContain("librarian");
|
|
251
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("uses 'Current agent' without agent name", () => {
|
|
255
|
+
const result = formatSkillAskPrompt("librarian");
|
|
256
|
+
expect(result).toContain("Current agent");
|
|
257
|
+
expect(result).toContain("librarian");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("formatSkillPathAskPrompt", () => {
|
|
262
|
+
test("includes skill name, read path, and agent name", () => {
|
|
263
|
+
const result = formatSkillPathAskPrompt(
|
|
264
|
+
skillEntry("librarian"),
|
|
265
|
+
"/skills/librarian/SKILL.md",
|
|
266
|
+
"my-agent",
|
|
267
|
+
);
|
|
268
|
+
expect(result).toContain("librarian");
|
|
269
|
+
expect(result).toContain("/skills/librarian/SKILL.md");
|
|
270
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("uses 'Current agent' without agent name", () => {
|
|
274
|
+
const result = formatSkillPathAskPrompt(
|
|
275
|
+
skillEntry("librarian"),
|
|
276
|
+
"/skills/librarian/SKILL.md",
|
|
277
|
+
);
|
|
278
|
+
expect(result).toContain("Current agent");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("formatSkillPathDenyReason", () => {
|
|
283
|
+
test("includes skill name, read path, and agent name", () => {
|
|
284
|
+
const result = formatSkillPathDenyReason(
|
|
285
|
+
skillEntry("librarian"),
|
|
286
|
+
"/skills/librarian/SKILL.md",
|
|
287
|
+
"my-agent",
|
|
288
|
+
);
|
|
289
|
+
expect(result).toContain("librarian");
|
|
290
|
+
expect(result).toContain("/skills/librarian/SKILL.md");
|
|
291
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("uses 'Current agent' without agent name", () => {
|
|
295
|
+
const result = formatSkillPathDenyReason(
|
|
296
|
+
skillEntry("librarian"),
|
|
297
|
+
"/skills/librarian/SKILL.md",
|
|
298
|
+
);
|
|
299
|
+
expect(result).toContain("Current agent");
|
|
300
|
+
});
|
|
301
|
+
});
|
|
@@ -5,11 +5,10 @@ import {
|
|
|
5
5
|
mkdtempSync,
|
|
6
6
|
readFileSync,
|
|
7
7
|
rmSync,
|
|
8
|
-
unlinkSync,
|
|
9
8
|
writeFileSync,
|
|
10
9
|
} from "node:fs";
|
|
11
10
|
import { tmpdir } from "node:os";
|
|
12
|
-
import { join, resolve } from "node:path";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
13
12
|
import { test } from "vitest";
|
|
14
13
|
import { BashFilter } from "../src/bash-filter.js";
|
|
15
14
|
import {
|
|
@@ -17,8 +16,8 @@ import {
|
|
|
17
16
|
createBeforeAgentStartPromptStateKey,
|
|
18
17
|
shouldApplyCachedAgentStartState,
|
|
19
18
|
} from "../src/before-agent-start-cache.js";
|
|
19
|
+
import { getGlobalConfigPath } from "../src/config-paths.js";
|
|
20
20
|
import {
|
|
21
|
-
CONFIG_PATH,
|
|
22
21
|
DEFAULT_EXTENSION_CONFIG,
|
|
23
22
|
loadPermissionSystemConfig,
|
|
24
23
|
savePermissionSystemConfig,
|
|
@@ -156,20 +155,13 @@ function createToolCallHarness(
|
|
|
156
155
|
const prompts: string[] = [];
|
|
157
156
|
const handlers: Record<string, MockHandler> = {};
|
|
158
157
|
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
159
|
-
const
|
|
160
|
-
? readFileSync(CONFIG_PATH, "utf8")
|
|
161
|
-
: null;
|
|
162
|
-
|
|
158
|
+
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
163
159
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
160
|
+
mkdirSync(dirname(globalConfigPath), { recursive: true });
|
|
164
161
|
mkdirSync(cwd, { recursive: true });
|
|
165
162
|
writeFileSync(
|
|
166
|
-
|
|
167
|
-
`${JSON.stringify(config, null, 2)}\n`,
|
|
168
|
-
"utf8",
|
|
169
|
-
);
|
|
170
|
-
writeFileSync(
|
|
171
|
-
CONFIG_PATH,
|
|
172
|
-
`${JSON.stringify(DEFAULT_EXTENSION_CONFIG, null, 2)}\n`,
|
|
163
|
+
globalConfigPath,
|
|
164
|
+
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
|
|
173
165
|
"utf8",
|
|
174
166
|
);
|
|
175
167
|
|
|
@@ -208,13 +200,6 @@ function createToolCallHarness(
|
|
|
208
200
|
createMockContext(cwd, prompts, options),
|
|
209
201
|
),
|
|
210
202
|
);
|
|
211
|
-
if (originalExtensionConfig === null) {
|
|
212
|
-
if (existsSync(CONFIG_PATH)) {
|
|
213
|
-
unlinkSync(CONFIG_PATH);
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
writeFileSync(CONFIG_PATH, originalExtensionConfig, "utf8");
|
|
217
|
-
}
|
|
218
203
|
rmSync(baseDir, { recursive: true, force: true });
|
|
219
204
|
},
|
|
220
205
|
};
|
|
@@ -1818,7 +1803,9 @@ permission:
|
|
|
1818
1803
|
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
1819
1804
|
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
|
|
1820
1805
|
const agentsDir = join(baseDir, "agents");
|
|
1806
|
+
const newConfigPath = getGlobalConfigPath(baseDir);
|
|
1821
1807
|
mkdirSync(agentsDir, { recursive: true });
|
|
1808
|
+
mkdirSync(dirname(newConfigPath), { recursive: true });
|
|
1822
1809
|
|
|
1823
1810
|
const config: GlobalPermissionConfig = {
|
|
1824
1811
|
defaultPolicy: {
|
|
@@ -1834,11 +1821,7 @@ test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
|
|
|
1834
1821
|
skills: {},
|
|
1835
1822
|
special: {},
|
|
1836
1823
|
};
|
|
1837
|
-
writeFileSync(
|
|
1838
|
-
join(baseDir, "pi-permissions.jsonc"),
|
|
1839
|
-
JSON.stringify(config),
|
|
1840
|
-
"utf8",
|
|
1841
|
-
);
|
|
1824
|
+
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
|
|
1842
1825
|
|
|
1843
1826
|
const original = process.env.PI_CODING_AGENT_DIR;
|
|
1844
1827
|
process.env.PI_CODING_AGENT_DIR = baseDir;
|