@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
|
@@ -1,19 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
existsSync,
|
|
3
|
-
mkdirSync,
|
|
4
|
-
mkdtempSync,
|
|
5
|
-
readFileSync,
|
|
6
|
-
rmSync,
|
|
7
|
-
unlinkSync,
|
|
8
|
-
writeFileSync,
|
|
9
|
-
} from "node:fs";
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
10
2
|
import { tmpdir } from "node:os";
|
|
11
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
12
4
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
DEFAULT_EXTENSION_CONFIG,
|
|
16
|
-
} from "../src/extension-config.js";
|
|
5
|
+
import { getGlobalConfigPath } from "../src/config-paths.js";
|
|
6
|
+
import { DEFAULT_EXTENSION_CONFIG } from "../src/extension-config.js";
|
|
17
7
|
import piPermissionSystemExtension from "../src/index.js";
|
|
18
8
|
import type { GlobalPermissionConfig } from "../src/types.js";
|
|
19
9
|
|
|
@@ -28,16 +18,13 @@ type MockHandler = (
|
|
|
28
18
|
describe("session_start handler consolidation", () => {
|
|
29
19
|
let baseDir: string;
|
|
30
20
|
let originalAgentDir: string | undefined;
|
|
31
|
-
let originalExtensionConfig: string | null;
|
|
32
|
-
|
|
33
21
|
beforeEach(() => {
|
|
34
22
|
baseDir = mkdtempSync(join(tmpdir(), "pi-permission-session-start-"));
|
|
35
23
|
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
36
|
-
originalExtensionConfig = existsSync(CONFIG_PATH)
|
|
37
|
-
? readFileSync(CONFIG_PATH, "utf8")
|
|
38
|
-
: null;
|
|
39
24
|
|
|
25
|
+
const globalConfigPath = getGlobalConfigPath(baseDir);
|
|
40
26
|
mkdirSync(join(baseDir, "agents"), { recursive: true });
|
|
27
|
+
mkdirSync(dirname(globalConfigPath), { recursive: true });
|
|
41
28
|
|
|
42
29
|
const config: GlobalPermissionConfig = {
|
|
43
30
|
defaultPolicy: {
|
|
@@ -49,13 +36,8 @@ describe("session_start handler consolidation", () => {
|
|
|
49
36
|
},
|
|
50
37
|
};
|
|
51
38
|
writeFileSync(
|
|
52
|
-
|
|
53
|
-
`${JSON.stringify(config, null, 2)}\n`,
|
|
54
|
-
"utf8",
|
|
55
|
-
);
|
|
56
|
-
writeFileSync(
|
|
57
|
-
CONFIG_PATH,
|
|
58
|
-
`${JSON.stringify(DEFAULT_EXTENSION_CONFIG, null, 2)}\n`,
|
|
39
|
+
globalConfigPath,
|
|
40
|
+
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
|
|
59
41
|
"utf8",
|
|
60
42
|
);
|
|
61
43
|
|
|
@@ -68,13 +50,6 @@ describe("session_start handler consolidation", () => {
|
|
|
68
50
|
} else {
|
|
69
51
|
process.env.PI_CODING_AGENT_DIR = originalAgentDir;
|
|
70
52
|
}
|
|
71
|
-
if (originalExtensionConfig === null) {
|
|
72
|
-
if (existsSync(CONFIG_PATH)) {
|
|
73
|
-
unlinkSync(CONFIG_PATH);
|
|
74
|
-
}
|
|
75
|
-
} else {
|
|
76
|
-
writeFileSync(CONFIG_PATH, originalExtensionConfig, "utf8");
|
|
77
|
-
}
|
|
78
53
|
rmSync(baseDir, { recursive: true, force: true });
|
|
79
54
|
});
|
|
80
55
|
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sanitizeAvailableToolsSection } from "../src/system-prompt-sanitizer.js";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Helpers for building prompt sections.
|
|
10
|
+
function availableToolsSection(tools: string[]): string {
|
|
11
|
+
return ["Available tools:", ...tools.map((t) => `- ${t}`)].join("\n");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function guidelinesSection(guidelines: string[]): string {
|
|
15
|
+
return ["Guidelines:", ...guidelines.map((g) => `- ${g}`)].join("\n");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function prompt(...sections: string[]): string {
|
|
19
|
+
return sections.join("\n\n");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("sanitizeAvailableToolsSection — Available tools section", () => {
|
|
23
|
+
test("sets removed:true and strips the Available tools header", () => {
|
|
24
|
+
const input = prompt(
|
|
25
|
+
availableToolsSection(["bash", "read"]),
|
|
26
|
+
"Other content",
|
|
27
|
+
);
|
|
28
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
29
|
+
expect(result.removed).toBe(true);
|
|
30
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Bug #33: findSection extends to lines.length when no subsequent recognised
|
|
34
|
+
// header follows, so content after the last section is silently deleted.
|
|
35
|
+
test.fails("preserves content that follows the Available tools section (bug #33)", () => {
|
|
36
|
+
const input = prompt(
|
|
37
|
+
availableToolsSection(["bash", "read"]),
|
|
38
|
+
"Other content",
|
|
39
|
+
);
|
|
40
|
+
const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
|
|
41
|
+
expect(result.prompt).toContain("Other content");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("removed flag is false when no Available tools section is present", () => {
|
|
45
|
+
const input = "Just some instructions.\n\nNo tools section.";
|
|
46
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
47
|
+
expect(result.removed).toBe(false);
|
|
48
|
+
expect(result.prompt).toBe(input);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("removes only the tools section and leaves other sections intact", () => {
|
|
52
|
+
const input = prompt(
|
|
53
|
+
"Preamble text",
|
|
54
|
+
availableToolsSection(["bash"]),
|
|
55
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
56
|
+
);
|
|
57
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
58
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
59
|
+
expect(result.prompt).toContain("Guidelines:");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("returns original prompt reference unchanged when nothing is removed", () => {
|
|
63
|
+
const input = "No tools section here.";
|
|
64
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
65
|
+
expect(result.prompt).toBe(input);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("sanitizeAvailableToolsSection — Guidelines section", () => {
|
|
70
|
+
test("removes bash guideline when bash is not in allowed tools", () => {
|
|
71
|
+
const input = prompt(
|
|
72
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
73
|
+
);
|
|
74
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
75
|
+
expect(result.removed).toBe(true);
|
|
76
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("keeps bash guideline when bash is in allowed tools", () => {
|
|
80
|
+
const input = prompt(
|
|
81
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
82
|
+
);
|
|
83
|
+
const result = sanitizeAvailableToolsSection(input, ["bash"]);
|
|
84
|
+
expect(result.removed).toBe(false);
|
|
85
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("removes read guideline when read is not allowed", () => {
|
|
89
|
+
const input = prompt(
|
|
90
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
91
|
+
);
|
|
92
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
93
|
+
expect(result.removed).toBe(true);
|
|
94
|
+
expect(result.prompt).not.toContain("use read to examine files");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("keeps read guideline when read is allowed", () => {
|
|
98
|
+
const input = prompt(
|
|
99
|
+
guidelinesSection(["use read to examine files instead of cat or sed."]),
|
|
100
|
+
);
|
|
101
|
+
const result = sanitizeAvailableToolsSection(input, ["read"]);
|
|
102
|
+
expect(result.removed).toBe(false);
|
|
103
|
+
expect(result.prompt).toContain("use read to examine files");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("removes edit guideline when edit is not allowed", () => {
|
|
107
|
+
const input = prompt(
|
|
108
|
+
guidelinesSection([
|
|
109
|
+
"use edit for precise changes (old text must match exactly)",
|
|
110
|
+
]),
|
|
111
|
+
);
|
|
112
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
113
|
+
expect(result.removed).toBe(true);
|
|
114
|
+
expect(result.prompt).not.toContain("use edit for precise changes");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("removes write guideline when write is not allowed", () => {
|
|
118
|
+
const input = prompt(
|
|
119
|
+
guidelinesSection(["use write only for new files or complete rewrites"]),
|
|
120
|
+
);
|
|
121
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
122
|
+
expect(result.removed).toBe(true);
|
|
123
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("removes entire Guidelines section when all bullets are filtered out", () => {
|
|
127
|
+
const input = prompt(
|
|
128
|
+
guidelinesSection([
|
|
129
|
+
"use bash for file operations like ls, rg, find",
|
|
130
|
+
"use write only for new files or complete rewrites",
|
|
131
|
+
]),
|
|
132
|
+
);
|
|
133
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
134
|
+
expect(result.removed).toBe(true);
|
|
135
|
+
expect(result.prompt).not.toContain("Guidelines:");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("preserves unrecognised guidelines regardless of allowed tools", () => {
|
|
139
|
+
const input = prompt(
|
|
140
|
+
guidelinesSection(["some custom guideline not in the rules"]),
|
|
141
|
+
);
|
|
142
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
143
|
+
expect(result.removed).toBe(false);
|
|
144
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("handles both sections together: removes tools section and filters guidelines", () => {
|
|
148
|
+
const input = prompt(
|
|
149
|
+
availableToolsSection(["bash"]),
|
|
150
|
+
guidelinesSection([
|
|
151
|
+
"use bash for file operations like ls, rg, find",
|
|
152
|
+
"use write only for new files or complete rewrites",
|
|
153
|
+
"some custom guideline not in the rules",
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
157
|
+
expect(result.removed).toBe(true);
|
|
158
|
+
expect(result.prompt).not.toContain("Available tools:");
|
|
159
|
+
expect(result.prompt).not.toContain("use bash for file operations");
|
|
160
|
+
expect(result.prompt).not.toContain("use write only for new files");
|
|
161
|
+
expect(result.prompt).toContain("some custom guideline not in the rules");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("trims whitespace from allowed tool names", () => {
|
|
165
|
+
const input = prompt(
|
|
166
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
167
|
+
);
|
|
168
|
+
const result = sanitizeAvailableToolsSection(input, [" bash "]);
|
|
169
|
+
expect(result.removed).toBe(false);
|
|
170
|
+
expect(result.prompt).toContain("use bash for file operations");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe("sanitizeAvailableToolsSection — multi-section prompt", () => {
|
|
175
|
+
test("collapses extra blank lines after removal", () => {
|
|
176
|
+
const input = prompt(
|
|
177
|
+
"Intro",
|
|
178
|
+
availableToolsSection(["bash"]),
|
|
179
|
+
guidelinesSection(["use bash for file operations like ls, rg, find"]),
|
|
180
|
+
"Closing",
|
|
181
|
+
);
|
|
182
|
+
const result = sanitizeAvailableToolsSection(input, []);
|
|
183
|
+
// No run of 3+ consecutive newlines
|
|
184
|
+
expect(result.prompt).not.toMatch(/\n{3,}/);
|
|
185
|
+
});
|
|
186
|
+
});
|