@gotgenes/pi-permission-system 3.0.0 → 3.0.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 +20 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +96 -12
- package/src/active-agent.ts +58 -0
- 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 +59 -1062
- 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 +142 -0
- package/tests/common.test.ts +189 -0
- package/tests/external-directory.test.ts +254 -0
- package/tests/permission-prompts.test.ts +304 -0
- 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 +455 -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,304 @@
|
|
|
1
|
+
import { afterEach, beforeEach, 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
|
+
beforeEach(() => {
|
|
26
|
+
mockedFormatToolInput.mockReset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function toolResult(
|
|
34
|
+
toolName: string,
|
|
35
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
36
|
+
): PermissionCheckResult {
|
|
37
|
+
return { toolName, state: "ask", source: "tool", ...overrides };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function mcpResult(
|
|
41
|
+
target: string,
|
|
42
|
+
overrides: Partial<PermissionCheckResult> = {},
|
|
43
|
+
): PermissionCheckResult {
|
|
44
|
+
return {
|
|
45
|
+
toolName: "mcp",
|
|
46
|
+
target,
|
|
47
|
+
state: "ask",
|
|
48
|
+
source: "tool",
|
|
49
|
+
...overrides,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skillEntry(name: string): SkillPromptEntry {
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
description: "A skill",
|
|
57
|
+
location: `/skills/${name}/SKILL.md`,
|
|
58
|
+
state: "ask",
|
|
59
|
+
normalizedLocation: `/skills/${name}/SKILL.md`,
|
|
60
|
+
normalizedBaseDir: `/skills/${name}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("formatMissingToolNameReason", () => {
|
|
65
|
+
test("mentions missing tool name and pi.getAllTools()", () => {
|
|
66
|
+
const result = formatMissingToolNameReason();
|
|
67
|
+
expect(result).toContain("no tool name");
|
|
68
|
+
expect(result).toContain("pi.getAllTools()");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("formatUnknownToolReason", () => {
|
|
73
|
+
test("mentions the unknown tool name and lists available tools", () => {
|
|
74
|
+
const result = formatUnknownToolReason("phantom", ["read", "write"]);
|
|
75
|
+
expect(result).toContain("phantom");
|
|
76
|
+
expect(result).toContain("read");
|
|
77
|
+
expect(result).toContain("write");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("includes MCP hint for non-mcp tool names", () => {
|
|
81
|
+
const result = formatUnknownToolReason("my-server:tool", ["mcp"]);
|
|
82
|
+
expect(result).toContain("mcp");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("omits MCP hint when tool name is 'mcp'", () => {
|
|
86
|
+
const result = formatUnknownToolReason("mcp", []);
|
|
87
|
+
expect(result).not.toContain("call the registered 'mcp' tool");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("shows 'none' when no tools are registered", () => {
|
|
91
|
+
const result = formatUnknownToolReason("ghost", []);
|
|
92
|
+
expect(result).toContain("none");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("caps preview at 10 tools and appends ellipsis for longer lists", () => {
|
|
96
|
+
const tools = Array.from({ length: 15 }, (_, i) => `tool${i}`);
|
|
97
|
+
const result = formatUnknownToolReason("ghost", tools);
|
|
98
|
+
expect(result).toContain("...");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("formatPermissionHardStopHint", () => {
|
|
103
|
+
test("returns MCP-specific message for mcp tool with target", () => {
|
|
104
|
+
const result = formatPermissionHardStopHint(mcpResult("server:tool"));
|
|
105
|
+
expect(result).toContain("MCP permission denial");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("returns MCP-specific message for mcp source with target", () => {
|
|
109
|
+
const result = formatPermissionHardStopHint(
|
|
110
|
+
toolResult("anything", { source: "mcp", target: "server:tool" }),
|
|
111
|
+
);
|
|
112
|
+
expect(result).toContain("MCP permission denial");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("returns generic message for non-MCP tools", () => {
|
|
116
|
+
const result = formatPermissionHardStopHint(toolResult("read"));
|
|
117
|
+
expect(result).toContain("Hard stop");
|
|
118
|
+
expect(result).not.toContain("MCP");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("formatDenyReason", () => {
|
|
123
|
+
test("includes tool name and hard stop hint", () => {
|
|
124
|
+
const result = formatDenyReason(toolResult("read"));
|
|
125
|
+
expect(result).toContain("read");
|
|
126
|
+
expect(result).toContain("Hard stop");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("includes agent name when provided", () => {
|
|
130
|
+
const result = formatDenyReason(toolResult("write"), "my-agent");
|
|
131
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("includes MCP target for mcp results", () => {
|
|
135
|
+
const result = formatDenyReason(mcpResult("server:do-thing"));
|
|
136
|
+
expect(result).toContain("server:do-thing");
|
|
137
|
+
expect(result).toContain("MCP");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("includes bash command when present", () => {
|
|
141
|
+
const result = formatDenyReason(
|
|
142
|
+
toolResult("bash", { command: "rm -rf /" }),
|
|
143
|
+
);
|
|
144
|
+
expect(result).toContain("rm -rf /");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("includes matched pattern when present", () => {
|
|
148
|
+
const result = formatDenyReason(
|
|
149
|
+
toolResult("bash", { command: "rm -rf /", matchedPattern: "rm *" }),
|
|
150
|
+
);
|
|
151
|
+
expect(result).toContain("matched 'rm *'");
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("formatUserDeniedReason", () => {
|
|
156
|
+
test("mentions tool name for generic tools", () => {
|
|
157
|
+
const result = formatUserDeniedReason(toolResult("read"));
|
|
158
|
+
expect(result).toContain("read");
|
|
159
|
+
expect(result).toContain("Hard stop");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("mentions bash command for bash results", () => {
|
|
163
|
+
const result = formatUserDeniedReason(
|
|
164
|
+
toolResult("bash", { command: "ls -la" }),
|
|
165
|
+
);
|
|
166
|
+
expect(result).toContain("ls -la");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("mentions MCP target for mcp results", () => {
|
|
170
|
+
const result = formatUserDeniedReason(mcpResult("server:query"));
|
|
171
|
+
expect(result).toContain("server:query");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("appends denial reason when provided", () => {
|
|
175
|
+
const result = formatUserDeniedReason(toolResult("read"), "too sensitive");
|
|
176
|
+
expect(result).toContain("Reason: too sensitive");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("omits reason suffix when not provided", () => {
|
|
180
|
+
const result = formatUserDeniedReason(toolResult("read"));
|
|
181
|
+
expect(result).not.toContain("Reason:");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("formatAskPrompt", () => {
|
|
186
|
+
test("uses 'Current agent' when no agent name given", () => {
|
|
187
|
+
const result = formatAskPrompt(toolResult("read"), undefined, {
|
|
188
|
+
path: "/src",
|
|
189
|
+
});
|
|
190
|
+
expect(result).toContain("Current agent");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("uses agent name when provided", () => {
|
|
194
|
+
const result = formatAskPrompt(toolResult("read"), "my-agent", {
|
|
195
|
+
path: "/src",
|
|
196
|
+
});
|
|
197
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("formats bash prompt with command and no tool-input-preview call", () => {
|
|
201
|
+
const result = formatAskPrompt(
|
|
202
|
+
toolResult("bash", { command: "git status" }),
|
|
203
|
+
);
|
|
204
|
+
expect(result).toContain("git status");
|
|
205
|
+
expect(result).toContain("Allow this command?");
|
|
206
|
+
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("formats bash prompt with matched pattern", () => {
|
|
210
|
+
const result = formatAskPrompt(
|
|
211
|
+
toolResult("bash", { command: "git push", matchedPattern: "git *" }),
|
|
212
|
+
);
|
|
213
|
+
expect(result).toContain("matched 'git *'");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("formats MCP prompt with target", () => {
|
|
217
|
+
const result = formatAskPrompt(mcpResult("server:query"));
|
|
218
|
+
expect(result).toContain("server:query");
|
|
219
|
+
expect(result).toContain("Allow this call?");
|
|
220
|
+
expect(mockedFormatToolInput).not.toHaveBeenCalled();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("formats MCP prompt with matched pattern", () => {
|
|
224
|
+
const result = formatAskPrompt(
|
|
225
|
+
mcpResult("server:query", { matchedPattern: "server:*" }),
|
|
226
|
+
);
|
|
227
|
+
expect(result).toContain("matched 'server:*'");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("calls formatToolInputForPrompt for non-bash non-mcp tools", () => {
|
|
231
|
+
mockedFormatToolInput.mockReturnValue("for '/src/foo.ts'");
|
|
232
|
+
const result = formatAskPrompt(toolResult("read"), undefined, {
|
|
233
|
+
path: "/src/foo.ts",
|
|
234
|
+
});
|
|
235
|
+
expect(mockedFormatToolInput).toHaveBeenCalledWith("read", {
|
|
236
|
+
path: "/src/foo.ts",
|
|
237
|
+
});
|
|
238
|
+
expect(result).toContain("for '/src/foo.ts'");
|
|
239
|
+
expect(result).toContain("Allow this call?");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("omits input suffix when formatToolInputForPrompt returns empty string", () => {
|
|
243
|
+
mockedFormatToolInput.mockReturnValue("");
|
|
244
|
+
const result = formatAskPrompt(toolResult("task"));
|
|
245
|
+
expect(result).toContain("task");
|
|
246
|
+
expect(result).not.toContain("undefined");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("formatSkillAskPrompt", () => {
|
|
251
|
+
test("includes skill name and agent name", () => {
|
|
252
|
+
const result = formatSkillAskPrompt("librarian", "my-agent");
|
|
253
|
+
expect(result).toContain("librarian");
|
|
254
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("uses 'Current agent' without agent name", () => {
|
|
258
|
+
const result = formatSkillAskPrompt("librarian");
|
|
259
|
+
expect(result).toContain("Current agent");
|
|
260
|
+
expect(result).toContain("librarian");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
describe("formatSkillPathAskPrompt", () => {
|
|
265
|
+
test("includes skill name, read path, and agent name", () => {
|
|
266
|
+
const result = formatSkillPathAskPrompt(
|
|
267
|
+
skillEntry("librarian"),
|
|
268
|
+
"/skills/librarian/SKILL.md",
|
|
269
|
+
"my-agent",
|
|
270
|
+
);
|
|
271
|
+
expect(result).toContain("librarian");
|
|
272
|
+
expect(result).toContain("/skills/librarian/SKILL.md");
|
|
273
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("uses 'Current agent' without agent name", () => {
|
|
277
|
+
const result = formatSkillPathAskPrompt(
|
|
278
|
+
skillEntry("librarian"),
|
|
279
|
+
"/skills/librarian/SKILL.md",
|
|
280
|
+
);
|
|
281
|
+
expect(result).toContain("Current agent");
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
describe("formatSkillPathDenyReason", () => {
|
|
286
|
+
test("includes skill name, read path, and agent name", () => {
|
|
287
|
+
const result = formatSkillPathDenyReason(
|
|
288
|
+
skillEntry("librarian"),
|
|
289
|
+
"/skills/librarian/SKILL.md",
|
|
290
|
+
"my-agent",
|
|
291
|
+
);
|
|
292
|
+
expect(result).toContain("librarian");
|
|
293
|
+
expect(result).toContain("/skills/librarian/SKILL.md");
|
|
294
|
+
expect(result).toContain("Agent 'my-agent'");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("uses 'Current agent' without agent name", () => {
|
|
298
|
+
const result = formatSkillPathDenyReason(
|
|
299
|
+
skillEntry("librarian"),
|
|
300
|
+
"/skills/librarian/SKILL.md",
|
|
301
|
+
);
|
|
302
|
+
expect(result).toContain("Current agent");
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type { PermissionManager } from "../src/permission-manager.js";
|
|
3
|
+
import {
|
|
4
|
+
findSkillPathMatch,
|
|
5
|
+
resolveSkillPromptEntries,
|
|
6
|
+
} from "../src/skill-prompt-sanitizer.js";
|
|
7
|
+
import type { PermissionCheckResult } from "../src/types.js";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const CWD = "/projects/my-app";
|
|
16
|
+
|
|
17
|
+
function makeManager(
|
|
18
|
+
defaultState: "allow" | "deny" | "ask" = "allow",
|
|
19
|
+
overrides: Record<string, "allow" | "deny" | "ask"> = {},
|
|
20
|
+
): PermissionManager {
|
|
21
|
+
return {
|
|
22
|
+
checkPermission: vi.fn(
|
|
23
|
+
(_surface: string, input: { name?: string }): PermissionCheckResult => {
|
|
24
|
+
const name = input.name ?? "";
|
|
25
|
+
const state = overrides[name] ?? defaultState;
|
|
26
|
+
return { toolName: "skill", state, source: "tool" };
|
|
27
|
+
},
|
|
28
|
+
),
|
|
29
|
+
} as unknown as PermissionManager;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function skillBlock(
|
|
33
|
+
name: string,
|
|
34
|
+
location = `/skills/${name}/SKILL.md`,
|
|
35
|
+
): string {
|
|
36
|
+
return [
|
|
37
|
+
" <skill>",
|
|
38
|
+
` <name>${name}</name>`,
|
|
39
|
+
` <description>Description of ${name}</description>`,
|
|
40
|
+
` <location>${location}</location>`,
|
|
41
|
+
" </skill>",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function availableSkillsSection(...names: string[]): string {
|
|
46
|
+
return [
|
|
47
|
+
"<available_skills>",
|
|
48
|
+
...names.map((n) => skillBlock(n)),
|
|
49
|
+
"</available_skills>",
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── resolveSkillPromptEntries ───────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
describe("resolveSkillPromptEntries", () => {
|
|
56
|
+
test("returns unchanged prompt and empty entries when no skills section present", () => {
|
|
57
|
+
const input = "You are a helpful assistant.";
|
|
58
|
+
const manager = makeManager("allow");
|
|
59
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
60
|
+
expect(result.prompt).toBe(input);
|
|
61
|
+
expect(result.entries).toEqual([]);
|
|
62
|
+
expect(manager.checkPermission).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("keeps all skills when all are allowed", () => {
|
|
66
|
+
const input = availableSkillsSection("librarian", "ask-user");
|
|
67
|
+
const manager = makeManager("allow");
|
|
68
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
69
|
+
expect(result.prompt).toContain("librarian");
|
|
70
|
+
expect(result.prompt).toContain("ask-user");
|
|
71
|
+
expect(result.entries).toHaveLength(2);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("removes denied skill from section", () => {
|
|
75
|
+
const input = availableSkillsSection("librarian", "dangerous");
|
|
76
|
+
const manager = makeManager("allow", { dangerous: "deny" });
|
|
77
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
78
|
+
expect(result.prompt).toContain("librarian");
|
|
79
|
+
expect(result.prompt).not.toContain("dangerous");
|
|
80
|
+
// denied skill is excluded from returned entries
|
|
81
|
+
expect(result.entries.map((e) => e.name)).not.toContain("dangerous");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("removes entire section when all skills are denied", () => {
|
|
85
|
+
const input = `Intro\n${availableSkillsSection("dangerous")}\nOutro`;
|
|
86
|
+
const manager = makeManager("deny");
|
|
87
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
88
|
+
expect(result.prompt).not.toContain("<available_skills>");
|
|
89
|
+
expect(result.prompt).toContain("Intro");
|
|
90
|
+
expect(result.prompt).toContain("Outro");
|
|
91
|
+
expect(result.entries).toHaveLength(0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("keeps ask-state skills in section and entries", () => {
|
|
95
|
+
const input = availableSkillsSection("librarian");
|
|
96
|
+
const manager = makeManager("ask");
|
|
97
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
98
|
+
expect(result.prompt).toContain("librarian");
|
|
99
|
+
expect(result.entries).toHaveLength(1);
|
|
100
|
+
expect(result.entries[0].state).toBe("ask");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("delegates permission check to permissionManager for each skill", () => {
|
|
104
|
+
const input = availableSkillsSection("alpha", "beta");
|
|
105
|
+
const manager = makeManager("allow");
|
|
106
|
+
resolveSkillPromptEntries(input, manager, null, CWD);
|
|
107
|
+
expect(manager.checkPermission).toHaveBeenCalledWith(
|
|
108
|
+
"skill",
|
|
109
|
+
{ name: "alpha" },
|
|
110
|
+
undefined,
|
|
111
|
+
);
|
|
112
|
+
expect(manager.checkPermission).toHaveBeenCalledWith(
|
|
113
|
+
"skill",
|
|
114
|
+
{ name: "beta" },
|
|
115
|
+
undefined,
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("passes agentName to permissionManager", () => {
|
|
120
|
+
const input = availableSkillsSection("librarian");
|
|
121
|
+
const manager = makeManager("allow");
|
|
122
|
+
resolveSkillPromptEntries(input, manager, "my-agent", CWD);
|
|
123
|
+
expect(manager.checkPermission).toHaveBeenCalledWith(
|
|
124
|
+
"skill",
|
|
125
|
+
{ name: "librarian" },
|
|
126
|
+
"my-agent",
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("caches permission result: checkPermission called once per unique skill name", () => {
|
|
131
|
+
// Same skill appears in two separate sections.
|
|
132
|
+
const input = [
|
|
133
|
+
availableSkillsSection("librarian"),
|
|
134
|
+
availableSkillsSection("librarian"),
|
|
135
|
+
].join("\n");
|
|
136
|
+
const manager = makeManager("allow");
|
|
137
|
+
resolveSkillPromptEntries(input, manager, null, CWD);
|
|
138
|
+
// Should only be called once despite appearing twice.
|
|
139
|
+
expect(manager.checkPermission).toHaveBeenCalledTimes(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("resolves entry normalizedLocation relative to cwd", () => {
|
|
143
|
+
const location = "/skills/librarian/SKILL.md";
|
|
144
|
+
const input = availableSkillsSection("librarian");
|
|
145
|
+
const manager = makeManager("allow");
|
|
146
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
147
|
+
expect(result.entries[0].normalizedLocation).toBe(location);
|
|
148
|
+
expect(result.entries[0].normalizedBaseDir).toBe("/skills/librarian");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("handles multi-section prompt: processes each section independently", () => {
|
|
152
|
+
const section1 = availableSkillsSection("alpha");
|
|
153
|
+
const section2 = availableSkillsSection("beta");
|
|
154
|
+
const input = `${section1}\n${section2}`;
|
|
155
|
+
const manager = makeManager("allow", { beta: "deny" });
|
|
156
|
+
const result = resolveSkillPromptEntries(input, manager, null, CWD);
|
|
157
|
+
expect(result.entries.map((e) => e.name)).toContain("alpha");
|
|
158
|
+
expect(result.entries.map((e) => e.name)).not.toContain("beta");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// ── findSkillPathMatch ──────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
describe("findSkillPathMatch", () => {
|
|
165
|
+
const entries = [
|
|
166
|
+
{
|
|
167
|
+
name: "librarian",
|
|
168
|
+
description: "desc",
|
|
169
|
+
location: "/skills/librarian/SKILL.md",
|
|
170
|
+
state: "allow" as const,
|
|
171
|
+
normalizedLocation: "/skills/librarian/SKILL.md",
|
|
172
|
+
normalizedBaseDir: "/skills/librarian",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "ask-user",
|
|
176
|
+
description: "desc",
|
|
177
|
+
location: "/skills/ask-user/SKILL.md",
|
|
178
|
+
state: "allow" as const,
|
|
179
|
+
normalizedLocation: "/skills/ask-user/SKILL.md",
|
|
180
|
+
normalizedBaseDir: "/skills/ask-user",
|
|
181
|
+
},
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
test("returns null for empty normalized path", () => {
|
|
185
|
+
expect(findSkillPathMatch("", entries)).toBeNull();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("returns null for empty entries array", () => {
|
|
189
|
+
expect(findSkillPathMatch("/skills/librarian/SKILL.md", [])).toBeNull();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("matches exact location path", () => {
|
|
193
|
+
const match = findSkillPathMatch("/skills/librarian/SKILL.md", entries);
|
|
194
|
+
expect(match?.name).toBe("librarian");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("matches path within skill base directory", () => {
|
|
198
|
+
const match = findSkillPathMatch(
|
|
199
|
+
"/skills/librarian/extra/helper.md",
|
|
200
|
+
entries,
|
|
201
|
+
);
|
|
202
|
+
expect(match?.name).toBe("librarian");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("returns null for path not within any skill directory", () => {
|
|
206
|
+
const match = findSkillPathMatch("/other/path/file.md", entries);
|
|
207
|
+
expect(match).toBeNull();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("returns null for sibling path that shares a prefix", () => {
|
|
211
|
+
// "/skills/librarian-extra" should not match "/skills/librarian"
|
|
212
|
+
const match = findSkillPathMatch(
|
|
213
|
+
"/skills/librarian-extra/SKILL.md",
|
|
214
|
+
entries,
|
|
215
|
+
);
|
|
216
|
+
expect(match).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("prefers longer matching base directory (most specific skill wins)", () => {
|
|
220
|
+
const nestedEntries = [
|
|
221
|
+
{
|
|
222
|
+
name: "parent",
|
|
223
|
+
description: "desc",
|
|
224
|
+
location: "/skills/parent/SKILL.md",
|
|
225
|
+
state: "allow" as const,
|
|
226
|
+
normalizedLocation: "/skills/parent/SKILL.md",
|
|
227
|
+
normalizedBaseDir: "/skills/parent",
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: "child",
|
|
231
|
+
description: "desc",
|
|
232
|
+
location: "/skills/parent/child/SKILL.md",
|
|
233
|
+
state: "allow" as const,
|
|
234
|
+
normalizedLocation: "/skills/parent/child/SKILL.md",
|
|
235
|
+
normalizedBaseDir: "/skills/parent/child",
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
const match = findSkillPathMatch(
|
|
239
|
+
"/skills/parent/child/helper.md",
|
|
240
|
+
nestedEntries,
|
|
241
|
+
);
|
|
242
|
+
expect(match?.name).toBe("child");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { SUBAGENT_ENV_HINT_KEYS } from "../src/permission-forwarding.js";
|
|
4
|
+
import {
|
|
5
|
+
isSubagentExecutionContext,
|
|
6
|
+
normalizeFilesystemPath,
|
|
7
|
+
} from "../src/subagent-context.js";
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.unstubAllEnvs();
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
function makeCtx(sessionDir: string | null): ExtensionContext {
|
|
15
|
+
return {
|
|
16
|
+
sessionManager: {
|
|
17
|
+
getSessionDir: vi.fn(() => sessionDir),
|
|
18
|
+
},
|
|
19
|
+
} as unknown as ExtensionContext;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("normalizeFilesystemPath", () => {
|
|
23
|
+
test("normalizes a simple absolute path", () => {
|
|
24
|
+
expect(normalizeFilesystemPath("/projects/my-app")).toBe(
|
|
25
|
+
"/projects/my-app",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("collapses redundant separators", () => {
|
|
30
|
+
expect(normalizeFilesystemPath("/projects//my-app")).toBe(
|
|
31
|
+
"/projects/my-app",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resolves . and .. segments", () => {
|
|
36
|
+
expect(normalizeFilesystemPath("/projects/my-app/../other")).toBe(
|
|
37
|
+
"/projects/other",
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("isSubagentExecutionContext — env hint detection", () => {
|
|
43
|
+
test("returns true when PI_IS_SUBAGENT is set", () => {
|
|
44
|
+
vi.stubEnv("PI_IS_SUBAGENT", "true");
|
|
45
|
+
expect(
|
|
46
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("returns true when PI_SUBAGENT_SESSION_ID is set", () => {
|
|
51
|
+
vi.stubEnv("PI_SUBAGENT_SESSION_ID", "abc123");
|
|
52
|
+
expect(
|
|
53
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("returns true when PI_AGENT_ROUTER_SUBAGENT is set", () => {
|
|
58
|
+
vi.stubEnv("PI_AGENT_ROUTER_SUBAGENT", "1");
|
|
59
|
+
expect(
|
|
60
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
61
|
+
).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("covers all three declared SUBAGENT_ENV_HINT_KEYS", () => {
|
|
65
|
+
// Verify the keys we test match what the module declares.
|
|
66
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_IS_SUBAGENT");
|
|
67
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_SUBAGENT_SESSION_ID");
|
|
68
|
+
expect(SUBAGENT_ENV_HINT_KEYS).toContain("PI_AGENT_ROUTER_SUBAGENT");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("returns false when env hint value is empty string", () => {
|
|
72
|
+
vi.stubEnv("PI_IS_SUBAGENT", "");
|
|
73
|
+
expect(
|
|
74
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("returns false when env hint value is whitespace only", () => {
|
|
79
|
+
vi.stubEnv("PI_IS_SUBAGENT", " ");
|
|
80
|
+
expect(
|
|
81
|
+
isSubagentExecutionContext(makeCtx(null), "/sessions/subagents"),
|
|
82
|
+
).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("isSubagentExecutionContext — session dir detection", () => {
|
|
87
|
+
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
|
|
88
|
+
|
|
89
|
+
test("returns true when session dir is within subagent root", () => {
|
|
90
|
+
const sessionDir = `${subagentRoot}/session-abc`;
|
|
91
|
+
expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
|
|
92
|
+
true,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns true when session dir equals subagent root", () => {
|
|
97
|
+
expect(
|
|
98
|
+
isSubagentExecutionContext(makeCtx(subagentRoot), subagentRoot),
|
|
99
|
+
).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns false when session dir is outside subagent root", () => {
|
|
103
|
+
const sessionDir = "/home/user/.pi/agent/sessions/main-session";
|
|
104
|
+
expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
|
|
105
|
+
false,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns false when session dir is a sibling with shared prefix", () => {
|
|
110
|
+
// "/sessions/subagents-extra" should not match root "/sessions/subagents"
|
|
111
|
+
const sessionDir = `${subagentRoot}-extra/session-abc`;
|
|
112
|
+
expect(isSubagentExecutionContext(makeCtx(sessionDir), subagentRoot)).toBe(
|
|
113
|
+
false,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns false when getSessionDir returns null", () => {
|
|
118
|
+
expect(isSubagentExecutionContext(makeCtx(null), subagentRoot)).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns false when getSessionDir returns empty string", () => {
|
|
122
|
+
expect(isSubagentExecutionContext(makeCtx(""), subagentRoot)).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|