@gotgenes/pi-permission-system 14.0.1 → 15.0.0

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 CHANGED
@@ -5,6 +5,22 @@ 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
+ ## [15.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v14.0.1...pi-permission-system-v15.0.0) (2026-06-20)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * the wire system prompt now lists the active tools (narrowed to the permission-allowed set) in the `Available tools:` section. Previously the permission system removed that section entirely, so the model saw no tool listing. Sessions that relied on the empty-listing behavior will now see the narrowed listing.
14
+
15
+ ### Bug Fixes
16
+
17
+ * narrow the Available tools section to the active set instead of stripping it ([#437](https://github.com/gotgenes/pi-packages/issues/437)) ([dc0b97d](https://github.com/gotgenes/pi-packages/commit/dc0b97d7571d6f3a5cf0b0e15172f0d2d92b050a))
18
+
19
+
20
+ ### Documentation
21
+
22
+ * describe Available-tools narrowing and drop the prompt-cache module ([#437](https://github.com/gotgenes/pi-packages/issues/437)) ([4112057](https://github.com/gotgenes/pi-packages/commit/411205711c5574aebb7add9edf9e035d21614946))
23
+
8
24
  ## [14.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v14.0.0...pi-permission-system-v14.0.1) (2026-06-19)
9
25
 
10
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "14.0.1",
3
+ "version": "15.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -2,10 +2,6 @@ import type {
2
2
  BeforeAgentStartEventResult,
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
- import {
6
- createActiveToolsCacheKey,
7
- createBeforeAgentStartPromptStateKey,
8
- } from "#src/before-agent-start-cache";
9
5
  import type { PermissionResolver } from "#src/permission-resolver";
10
6
  import type { PermissionSession } from "#src/permission-session";
11
7
  import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
@@ -35,9 +31,14 @@ export function shouldExposeTool(
35
31
  /**
36
32
  * Handles the `before_agent_start` event: tool filtering + prompt sanitization.
37
33
  *
34
+ * Recomputes the active tool set and the returned system-prompt override on
35
+ * every fire (no memoization): the override must be returned each turn so that
36
+ * skill filtering is reapplied and the wire prompt stays byte-stable, rather
37
+ * than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
38
+ *
38
39
  * Constructor deps:
39
40
  * - `session` — encapsulates all mutable session state and lifecycle operations
40
- * - `resolver` — owns permission-query surface: `getToolPermission`, `getPolicyCacheStamp`, skill check
41
+ * - `resolver` — owns permission-query surface: `getToolPermission`, skill check
41
42
  * - `toolRegistry` — Pi tool API subset (getActive + setActive)
42
43
  */
43
44
  export class AgentPrepHandler {
@@ -73,40 +74,21 @@ export class AgentPrepHandler {
73
74
  }
74
75
  }
75
76
 
76
- const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
77
- this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
78
- this.toolRegistry.setActive(allowedTools);
79
- });
77
+ this.toolRegistry.setActive(allowedTools);
80
78
 
81
- const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
79
+ const toolPromptResult = sanitizeAvailableToolsSection(
80
+ event.systemPrompt,
81
+ allowedTools,
82
+ );
83
+ const skillPromptResult = resolveSkillPromptEntries(
84
+ toolPromptResult.prompt,
85
+ this.resolver,
82
86
  agentName,
83
- cwd: ctx.cwd,
84
- permissionStamp: this.resolver.getPolicyCacheStamp(
85
- agentName ?? undefined,
86
- ),
87
- systemPrompt: event.systemPrompt,
88
- allowedToolNames: allowedTools,
89
- });
90
-
91
- const promptResult = this.session.promptStateGate.runIfChanged(
92
- promptStateCacheKey,
93
- () => {
94
- const toolPromptResult = sanitizeAvailableToolsSection(
95
- event.systemPrompt,
96
- allowedTools,
97
- );
98
- const skillPromptResult = resolveSkillPromptEntries(
99
- toolPromptResult.prompt,
100
- this.resolver,
101
- agentName,
102
- ctx.cwd,
103
- );
104
- this.session.setActiveSkillEntries(skillPromptResult.entries);
105
- return skillPromptResult.prompt !== event.systemPrompt
106
- ? { systemPrompt: skillPromptResult.prompt }
107
- : {};
108
- },
87
+ ctx.cwd,
109
88
  );
110
- return promptResult ?? {};
89
+ this.session.setActiveSkillEntries(skillPromptResult.entries);
90
+ return skillPromptResult.prompt !== event.systemPrompt
91
+ ? { systemPrompt: skillPromptResult.prompt }
92
+ : {};
111
93
  }
112
94
  }
@@ -84,7 +84,6 @@ export interface ScopedPermissionManager {
84
84
  ): PermissionCheckResult;
85
85
  getToolPermission(toolName: string, agentName?: string): PermissionState;
86
86
  getConfigIssues(agentName?: string): string[];
87
- getPolicyCacheStamp(agentName?: string): string;
88
87
  }
89
88
 
90
89
  export interface PermissionManagerOptions extends PolicyLoaderOptions {
@@ -144,10 +143,6 @@ export class PermissionManager implements ScopedPermissionManager {
144
143
  return this.loader.getResolvedPolicyPaths();
145
144
  }
146
145
 
147
- getPolicyCacheStamp(agentName?: string): string {
148
- return this.loader.getCacheStamp(agentName);
149
- }
150
-
151
146
  private resolvePermissions(agentName?: string): ResolvedPermissions {
152
147
  const cacheKey = agentName ?? "__global__";
153
148
  const stamp = this.loader.getCacheStamp(agentName);
@@ -106,8 +106,4 @@ export class PermissionResolver implements ScopedPermissionResolver {
106
106
  getConfigIssues(agentName?: string): string[] {
107
107
  return this.permissionManager.getConfigIssues(agentName);
108
108
  }
109
-
110
- getPolicyCacheStamp(agentName?: string): string {
111
- return this.permissionManager.getPolicyCacheStamp(agentName);
112
- }
113
109
  }
@@ -1,5 +1,4 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { CacheKeyGate } from "#src/cache-key-gate";
3
2
  import {
4
3
  getActiveAgentName,
5
4
  getActiveAgentNameFromSystemPrompt,
@@ -38,8 +37,6 @@ export class PermissionSession implements ToolCallGateInputs {
38
37
  private context: ExtensionContext | null = null;
39
38
  private skillEntries: SkillPromptEntry[] = [];
40
39
  private knownAgentName: string | null = null;
41
- readonly activeToolsGate = new CacheKeyGate();
42
- readonly promptStateGate = new CacheKeyGate();
43
40
 
44
41
  constructor(
45
42
  private readonly paths: ExtensionPaths,
@@ -83,38 +80,32 @@ export class PermissionSession implements ToolCallGateInputs {
83
80
  /**
84
81
  * Reset all mutable state for a new session.
85
82
  *
86
- * Configures the injected PermissionManager for `ctx.cwd`, clears caches,
87
- * skill entries, and activates the new context.
83
+ * Configures the injected PermissionManager for `ctx.cwd`, clears skill
84
+ * entries, and activates the new context.
88
85
  */
89
86
  resetForNewSession(ctx: ExtensionContext): void {
90
87
  this.permissionManager.configureForCwd(ctx.cwd);
91
88
  this.skillEntries = [];
92
- this.activeToolsGate.reset();
93
- this.promptStateGate.reset();
94
89
  this.activate(ctx);
95
90
  }
96
91
 
97
92
  /**
98
- * Shut down the session: clear rules, caches, skill entries, and
99
- * deactivate context + forwarding.
93
+ * Shut down the session: clear rules, skill entries, and deactivate
94
+ * context + forwarding.
100
95
  */
101
96
  shutdown(): void {
102
97
  this.sessionRules.clear();
103
98
  this.skillEntries = [];
104
- this.activeToolsGate.reset();
105
- this.promptStateGate.reset();
106
99
  this.deactivate();
107
100
  }
108
101
 
109
102
  /**
110
- * Reload permission manager and clear caches for the current context.
103
+ * Reload permission manager and clear skill entries for the current context.
111
104
  * Used on config reload (e.g. `resources_discover` with reason "reload").
112
105
  */
113
106
  reload(): void {
114
107
  this.permissionManager.configureForCwd(this.context?.cwd);
115
108
  this.skillEntries = [];
116
- this.activeToolsGate.reset();
117
- this.promptStateGate.reset();
118
109
  }
119
110
 
120
111
  // ── Skill entries ──────────────────────────────────────────────────────
@@ -135,16 +135,59 @@ function findSection(
135
135
  return { start, end };
136
136
  }
137
137
 
138
- function removeLineSection(
138
+ /**
139
+ * Tool name from an `Available tools:` bullet (`- read: …` -> `read`), or
140
+ * `null` for non-tool lines (blank lines, boilerplate prose). Matches the
141
+ * first token after the bullet marker, with or without a trailing colon.
142
+ */
143
+ function extractToolBulletName(line: string): string | null {
144
+ const match = /^\s*-\s+([A-Za-z0-9_-]+)/.exec(line);
145
+ return match ? match[1] : null;
146
+ }
147
+
148
+ /**
149
+ * Narrow the `Available tools:` section to the allowed tools: keep allowed-tool
150
+ * bullet lines and any non-tool prose, drop denied/inactive bullet lines. When
151
+ * no tool bullet survives, remove the section header too. This mirrors what Pi
152
+ * itself renders for the active tool set, so the result is byte-stable across
153
+ * turns regardless of whether the input still carries the full default listing.
154
+ */
155
+ function narrowAvailableToolsSection(
139
156
  lines: readonly string[],
140
- section: LineSection | null,
157
+ allowedTools: ReadonlySet<string>,
141
158
  ): { lines: string[]; removed: boolean } {
159
+ const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
142
160
  if (!section) {
143
161
  return { lines: [...lines], removed: false };
144
162
  }
145
163
 
164
+ const before = lines.slice(0, section.start);
165
+ const header = lines[section.start];
166
+ const body = lines.slice(section.start + 1, section.end);
167
+ const after = lines.slice(section.end);
168
+
169
+ const filteredBody = body.filter((line) => {
170
+ const toolName = extractToolBulletName(line);
171
+ if (toolName === null) {
172
+ return true; // keep blank lines and non-tool boilerplate
173
+ }
174
+ return allowedTools.has(toolName);
175
+ });
176
+
177
+ const removed = filteredBody.length !== body.length;
178
+ if (!removed) {
179
+ return { lines: [...lines], removed: false };
180
+ }
181
+
182
+ const hasToolBullet = filteredBody.some(
183
+ (line) => extractToolBulletName(line) !== null,
184
+ );
185
+ if (!hasToolBullet) {
186
+ return { lines: [...before, ...after], removed: true };
187
+ }
188
+
146
189
  return {
147
- lines: [...lines.slice(0, section.start), ...lines.slice(section.end)],
190
+ lines: [...before, header, ...filteredBody, ...after],
148
191
  removed: true,
149
192
  };
150
193
  }
@@ -212,15 +255,15 @@ export function sanitizeAvailableToolsSection(
212
255
  allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
213
256
  );
214
257
  const normalizedLines = normalizePrompt(systemPrompt).split("\n");
215
- const removedToolsSection = removeLineSection(
258
+ const narrowedToolsSection = narrowAvailableToolsSection(
216
259
  normalizedLines,
217
- findSection(normalizedLines, AVAILABLE_TOOLS_SECTION_HEADER),
260
+ allowedTools,
218
261
  );
219
262
  const sanitizedGuidelines = sanitizeGuidelinesSection(
220
- removedToolsSection.lines,
263
+ narrowedToolsSection.lines,
221
264
  allowedTools,
222
265
  );
223
- const removed = removedToolsSection.removed || sanitizedGuidelines.removed;
266
+ const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
224
267
 
225
268
  return {
226
269
  prompt: removed
@@ -171,7 +171,7 @@ describe("AgentPrepHandler.handle", () => {
171
171
  ]);
172
172
  });
173
173
 
174
- it("calls setActive once across repeated calls with the same allowed tools", async () => {
174
+ it("calls setActive on every turn (no dedup gate)", async () => {
175
175
  const { handler, toolRegistry } = makeSetup({
176
176
  toolRegistry: {
177
177
  getActive: vi.fn().mockReturnValue(["read"]),
@@ -179,7 +179,40 @@ describe("AgentPrepHandler.handle", () => {
179
179
  });
180
180
  await handler.handle(makeEvent(), makeCtx());
181
181
  await handler.handle(makeEvent(), makeCtx());
182
- expect(toolRegistry.setActive).toHaveBeenCalledOnce();
182
+ expect(toolRegistry.setActive).toHaveBeenCalledTimes(2);
183
+ });
184
+
185
+ it("filters a denied skill from the systemPrompt on every turn, not just the first", async () => {
186
+ const systemPrompt = [
187
+ "You are an assistant.",
188
+ "",
189
+ "<available_skills>",
190
+ " <skill>",
191
+ " <name>secret</name>",
192
+ " <description>A denied skill</description>",
193
+ " <location>/skills/secret/SKILL.md</location>",
194
+ " </skill>",
195
+ "</available_skills>",
196
+ ].join("\n");
197
+ const { handler, permissionManager } = makeSetup();
198
+ vi.mocked(permissionManager.checkPermission).mockImplementation(
199
+ (surface) =>
200
+ surface === "skill"
201
+ ? makeCheckResult({ state: "deny" })
202
+ : makeCheckResult(),
203
+ );
204
+
205
+ const first = await handler.handle(makeEvent(systemPrompt), makeCtx());
206
+ const second = await handler.handle(makeEvent(systemPrompt), makeCtx());
207
+
208
+ expect(first).toHaveProperty("systemPrompt");
209
+ expect((first as { systemPrompt: string }).systemPrompt).not.toContain(
210
+ "secret",
211
+ );
212
+ expect(second).toHaveProperty("systemPrompt");
213
+ expect((second as { systemPrompt: string }).systemPrompt).not.toContain(
214
+ "secret",
215
+ );
183
216
  });
184
217
 
185
218
  it("returns empty object on repeated calls with unchanged inputs", async () => {
@@ -209,4 +242,76 @@ describe("AgentPrepHandler.handle", () => {
209
242
  const result = await handler.handle(makeEvent(prompt), makeCtx());
210
243
  expect(result).toEqual({});
211
244
  });
245
+
246
+ it("narrows a denied tool out of the Available tools listing without removing the section", async () => {
247
+ const systemPrompt = [
248
+ "Available tools:",
249
+ "- read: Read file contents",
250
+ "- bash: Run shell commands",
251
+ ].join("\n");
252
+ const { handler, permissionManager } = makeSetup({
253
+ toolRegistry: {
254
+ getActive: vi.fn().mockReturnValue(["read", "bash"]),
255
+ },
256
+ });
257
+ vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
258
+ tool === "bash" ? "deny" : "allow",
259
+ );
260
+
261
+ const result = await handler.handle(makeEvent(systemPrompt), makeCtx());
262
+
263
+ expect(result.systemPrompt).toBeDefined();
264
+ const out = result.systemPrompt ?? "";
265
+ expect(out).toContain("Available tools:");
266
+ expect(out).toContain("- read: Read file contents");
267
+ expect(out).not.toContain("- bash");
268
+ });
269
+
270
+ it("keeps the wire system prompt byte-stable across the tool-listing drift between turns", async () => {
271
+ const fullProse = [
272
+ "You are an assistant.",
273
+ "",
274
+ "Available tools:",
275
+ "- bash: Run shell commands",
276
+ "- read: Read file contents",
277
+ "- edit: Edit a file",
278
+ "- write: Write a file",
279
+ "",
280
+ "Guidelines:",
281
+ "- use bash for file operations like ls, rg, find",
282
+ "- use read to examine files instead of cat or sed.",
283
+ "- Be concise in your responses",
284
+ ].join("\n");
285
+ const narrowedProse = [
286
+ "You are an assistant.",
287
+ "",
288
+ "Available tools:",
289
+ "- read: Read file contents",
290
+ "- edit: Edit a file",
291
+ "- write: Write a file",
292
+ "",
293
+ "Guidelines:",
294
+ "- use read to examine files instead of cat or sed.",
295
+ "- Be concise in your responses",
296
+ ].join("\n");
297
+ const { handler, permissionManager } = makeSetup({
298
+ toolRegistry: {
299
+ getActive: vi.fn().mockReturnValue(["bash", "read", "edit", "write"]),
300
+ },
301
+ });
302
+ vi.mocked(permissionManager.getToolPermission).mockImplementation((tool) =>
303
+ tool === "bash" ? "deny" : "allow",
304
+ );
305
+
306
+ // Turn 1: Pi feeds the full default listing.
307
+ const first = await handler.handle(makeEvent(fullProse), makeCtx());
308
+ // Turn 2: Pi's setActive rebuild means the event now carries the narrowed
309
+ // listing, so the override the handler returns must still match turn 1.
310
+ const second = await handler.handle(makeEvent(narrowedProse), makeCtx());
311
+
312
+ const wire1 = first.systemPrompt ?? fullProse;
313
+ const wire2 = second.systemPrompt ?? narrowedProse;
314
+ expect(wire1).toBe(narrowedProse);
315
+ expect(wire2).toBe(narrowedProse);
316
+ });
212
317
  });
@@ -120,7 +120,6 @@ export function makeFakePermissionManager() {
120
120
  .fn<(toolName: string, agentName?: string) => PermissionState>()
121
121
  .mockReturnValue("allow"),
122
122
  getConfigIssues: vi.fn((): string[] => []),
123
- getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
124
123
  };
125
124
  }
126
125
 
@@ -1586,7 +1586,6 @@ describe("PermissionManager — configureForCwd and agentDir option", () => {
1586
1586
  expect(typeof scoped.checkPermission).toBe("function");
1587
1587
  expect(typeof scoped.getToolPermission).toBe("function");
1588
1588
  expect(typeof scoped.getConfigIssues).toBe("function");
1589
- expect(typeof scoped.getPolicyCacheStamp).toBe("function");
1590
1589
  });
1591
1590
 
1592
1591
  it("construction with { agentDir } reads global config from getGlobalConfigPath(agentDir)", () => {
@@ -42,7 +42,6 @@ function makePermissionManager() {
42
42
  .fn<(toolName: string, agentName?: string) => PermissionState>()
43
43
  .mockReturnValue("allow"),
44
44
  getConfigIssues: vi.fn((): string[] => []),
45
- getPolicyCacheStamp: vi.fn((): string => "stamp-1"),
46
45
  };
47
46
  }
48
47
 
@@ -263,17 +262,4 @@ describe("PermissionResolver", () => {
263
262
  expect(result).toEqual(["issue-1"]);
264
263
  });
265
264
  });
266
-
267
- describe("getPolicyCacheStamp", () => {
268
- it("delegates to permissionManager.getPolicyCacheStamp", () => {
269
- const pm = makePermissionManager();
270
- vi.mocked(pm.getPolicyCacheStamp).mockReturnValue("stamp-abc");
271
- const { resolver } = makeResolver(pm);
272
-
273
- const result = resolver.getPolicyCacheStamp("agent-1");
274
-
275
- expect(pm.getPolicyCacheStamp).toHaveBeenCalledWith("agent-1");
276
- expect(result).toBe("stamp-abc");
277
- });
278
- });
279
265
  });
@@ -103,23 +103,6 @@ describe("PermissionSession", () => {
103
103
  expect(pm.configureForCwd).toHaveBeenCalledWith("/new/project");
104
104
  });
105
105
 
106
- it("clears cache keys", () => {
107
- const { session } = createSession();
108
- // Prime both gates with a key
109
- session.activeToolsGate.runIfChanged("key-1", () => {});
110
- session.promptStateGate.runIfChanged("key-2", () => {});
111
-
112
- session.resetForNewSession(makeCtx());
113
-
114
- // After reset, the same keys should run the effect again
115
- const toolsEffect = vi.fn();
116
- const promptEffect = vi.fn();
117
- session.activeToolsGate.runIfChanged("key-1", toolsEffect);
118
- session.promptStateGate.runIfChanged("key-2", promptEffect);
119
- expect(toolsEffect).toHaveBeenCalledOnce();
120
- expect(promptEffect).toHaveBeenCalledOnce();
121
- });
122
-
123
106
  it("clears skill entries", () => {
124
107
  const { session } = createSession();
125
108
  session.setActiveSkillEntries([makeSkillEntry("test")]);
@@ -163,23 +146,6 @@ describe("PermissionSession", () => {
163
146
  expect(sessionRules.getRuleset()).toEqual([]);
164
147
  });
165
148
 
166
- it("clears cache keys", () => {
167
- const { session } = createSession();
168
- // Prime both gates with a key
169
- session.activeToolsGate.runIfChanged("k1", () => {});
170
- session.promptStateGate.runIfChanged("k2", () => {});
171
-
172
- session.shutdown();
173
-
174
- // After shutdown, the same keys should run the effect again
175
- const toolsEffect = vi.fn();
176
- const promptEffect = vi.fn();
177
- session.activeToolsGate.runIfChanged("k1", toolsEffect);
178
- session.promptStateGate.runIfChanged("k2", promptEffect);
179
- expect(toolsEffect).toHaveBeenCalledOnce();
180
- expect(promptEffect).toHaveBeenCalledOnce();
181
- });
182
-
183
149
  it("clears skill entries", () => {
184
150
  const { session } = createSession();
185
151
  session.setActiveSkillEntries([makeSkillEntry("s")]);
@@ -333,22 +299,12 @@ describe("PermissionSession", () => {
333
299
  expect(pm.configureForCwd).toHaveBeenCalledWith("/project");
334
300
  });
335
301
 
336
- it("clears caches and skill entries", () => {
302
+ it("clears skill entries", () => {
337
303
  const { session } = createSession();
338
- // Prime both gates with a key
339
- session.activeToolsGate.runIfChanged("k1", () => {});
340
- session.promptStateGate.runIfChanged("k2", () => {});
341
304
  session.setActiveSkillEntries([makeSkillEntry("s")]);
342
305
 
343
306
  session.reload();
344
307
 
345
- // After reload, the same keys should run the effect again
346
- const toolsEffect = vi.fn();
347
- const promptEffect = vi.fn();
348
- session.activeToolsGate.runIfChanged("k1", toolsEffect);
349
- session.promptStateGate.runIfChanged("k2", promptEffect);
350
- expect(toolsEffect).toHaveBeenCalledOnce();
351
- expect(promptEffect).toHaveBeenCalledOnce();
352
308
  expect(session.getActiveSkillEntries()).toEqual([]);
353
309
  });
354
310
  });
@@ -20,14 +20,26 @@ function prompt(...sections: string[]): string {
20
20
  }
21
21
 
22
22
  describe("sanitizeAvailableToolsSection — Available tools section", () => {
23
- test("sets removed:true and strips the Available tools header", () => {
23
+ test("keeps allowed tool lines and the header, drops denied ones", () => {
24
24
  const input = prompt(
25
25
  availableToolsSection(["bash", "read"]),
26
26
  "Other content",
27
27
  );
28
- const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
28
+ const result = sanitizeAvailableToolsSection(input, ["read"]);
29
29
  expect(result.removed).toBe(true);
30
- expect(result.prompt).not.toContain("Available tools:");
30
+ expect(result.prompt).toContain("Available tools:");
31
+ expect(result.prompt).toContain("- read");
32
+ expect(result.prompt).not.toContain("- bash");
33
+ });
34
+
35
+ test("leaves the section untouched when every tool is allowed", () => {
36
+ const input = prompt(
37
+ availableToolsSection(["bash", "read"]),
38
+ "Other content",
39
+ );
40
+ const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
41
+ expect(result.removed).toBe(false);
42
+ expect(result.prompt).toBe(input);
31
43
  });
32
44
 
33
45
  // Bug #33: findSection extends to lines.length when no subsequent recognised
@@ -37,7 +49,18 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
37
49
  availableToolsSection(["bash", "read"]),
38
50
  "Other content",
39
51
  );
40
- const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
52
+ const result = sanitizeAvailableToolsSection(input, ["read"]);
53
+ expect(result.prompt).toContain("Other content");
54
+ });
55
+
56
+ test("removes the whole section when no tool is allowed", () => {
57
+ const input = prompt(
58
+ availableToolsSection(["bash", "read"]),
59
+ "Other content",
60
+ );
61
+ const result = sanitizeAvailableToolsSection(input, []);
62
+ expect(result.removed).toBe(true);
63
+ expect(result.prompt).not.toContain("Available tools:");
41
64
  expect(result.prompt).toContain("Other content");
42
65
  });
43
66
 
@@ -48,15 +71,18 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
48
71
  expect(result.prompt).toBe(input);
49
72
  });
50
73
 
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:");
74
+ test("keeps non-tool boilerplate prose near the section", () => {
75
+ const input = [
76
+ "Available tools:",
77
+ "- read: Read file contents",
78
+ "- bash: Run shell commands",
79
+ "",
80
+ "In addition to the tools above, you may have access to other custom tools depending on the project.",
81
+ ].join("\n");
82
+ const result = sanitizeAvailableToolsSection(input, ["read"]);
83
+ expect(result.prompt).toContain("- read: Read file contents");
84
+ expect(result.prompt).not.toContain("- bash: Run shell commands");
85
+ expect(result.prompt).toContain("In addition to the tools above");
60
86
  });
61
87
 
62
88
  test("returns original prompt reference unchanged when nothing is removed", () => {
@@ -64,6 +90,47 @@ describe("sanitizeAvailableToolsSection — Available tools section", () => {
64
90
  const result = sanitizeAvailableToolsSection(input, []);
65
91
  expect(result.prompt).toBe(input);
66
92
  });
93
+
94
+ test("narrowing the full listing yields the already-narrowed listing (cache byte-stability)", () => {
95
+ const allowed = ["read", "edit", "write"];
96
+ const fullProse = [
97
+ "You are an assistant.",
98
+ "",
99
+ "Available tools:",
100
+ "- bash: Run shell commands",
101
+ "- read: Read file contents",
102
+ "- edit: Edit a file",
103
+ "- write: Write a file",
104
+ "",
105
+ "Guidelines:",
106
+ "- use bash for file operations like ls, rg, find",
107
+ "- use read to examine files instead of cat or sed.",
108
+ "- Be concise in your responses",
109
+ ].join("\n");
110
+ const narrowedProse = [
111
+ "You are an assistant.",
112
+ "",
113
+ "Available tools:",
114
+ "- read: Read file contents",
115
+ "- edit: Edit a file",
116
+ "- write: Write a file",
117
+ "",
118
+ "Guidelines:",
119
+ "- use read to examine files instead of cat or sed.",
120
+ "- Be concise in your responses",
121
+ ].join("\n");
122
+
123
+ const fromFull = sanitizeAvailableToolsSection(fullProse, allowed).prompt;
124
+ const fromNarrowed = sanitizeAvailableToolsSection(
125
+ narrowedProse,
126
+ allowed,
127
+ ).prompt;
128
+
129
+ // Idempotent on the already-narrowed input Pi feeds back on later turns.
130
+ expect(fromNarrowed).toBe(narrowedProse);
131
+ // Turn 1 (full) and turn 2+ (narrowed) produce identical wire bytes.
132
+ expect(fromFull).toBe(fromNarrowed);
133
+ });
67
134
  });
68
135
 
69
136
  describe("sanitizeAvailableToolsSection — Guidelines section", () => {
@@ -208,18 +275,18 @@ describe("sanitizeAvailableToolsSection — findSection boundary edge cases", ()
208
275
  expect(result.prompt).toContain("Important user note");
209
276
  });
210
277
 
211
- test("section at EOF with no trailing content still works", () => {
278
+ test("section at EOF is removed entirely when no tool is allowed", () => {
212
279
  const input = availableToolsSection(["bash", "read"]);
213
- const result = sanitizeAvailableToolsSection(input, ["bash", "read"]);
280
+ const result = sanitizeAvailableToolsSection(input, []);
214
281
  expect(result.removed).toBe(true);
215
282
  expect(result.prompt).toBe("");
216
283
  });
217
284
 
218
- test("section followed by blank lines then prose — prose survives", () => {
285
+ test("section followed by blank lines then prose — prose survives removal", () => {
219
286
  const input = ["Available tools:", "- bash", "", "", "Custom note"].join(
220
287
  "\n",
221
288
  );
222
- const result = sanitizeAvailableToolsSection(input, ["bash"]);
289
+ const result = sanitizeAvailableToolsSection(input, []);
223
290
  expect(result.removed).toBe(true);
224
291
  expect(result.prompt).toContain("Custom note");
225
292
  expect(result.prompt).not.toContain("Available tools:");
@@ -230,7 +297,7 @@ describe("sanitizeAvailableToolsSection — findSection boundary edge cases", ()
230
297
  // Moved from permission-system.test.ts catch-all (#342)
231
298
  // ---------------------------------------------------------------------------
232
299
 
233
- test("System prompt sanitizer removes the Available tools section and surrounding boilerplate", () => {
300
+ test("System prompt sanitizer keeps the active tools in the Available tools section", () => {
234
301
  const prompt = [
235
302
  "Available tools:",
236
303
  "- read: Read file contents",
@@ -245,11 +312,31 @@ test("System prompt sanitizer removes the Available tools section and surroundin
245
312
 
246
313
  const result = sanitizeAvailableToolsSection(prompt, ["read", "mcp"]);
247
314
 
248
- expect(result.removed).toBe(true);
249
- expect(result.prompt).not.toContain("Available tools:");
250
- expect(result.prompt).not.toContain("In addition to the tools above");
315
+ expect(result.removed).toBe(false);
316
+ expect(result.prompt).toContain("Available tools:");
317
+ expect(result.prompt).toContain("- read: Read file contents");
318
+ expect(result.prompt).toContain("- mcp: Discover");
319
+ expect(result.prompt).toContain("In addition to the tools above");
251
320
  expect(result.prompt).toMatch(/Guidelines:/);
252
- expect(result.prompt).toMatch(/Use mcp for MCP discovery first/i);
321
+ });
322
+
323
+ test("System prompt sanitizer drops a denied tool's line but keeps the section", () => {
324
+ const prompt = [
325
+ "Available tools:",
326
+ "- read: Read file contents",
327
+ "- mcp: Discover, inspect, and call MCP tools across configured servers",
328
+ "",
329
+ "Guidelines:",
330
+ "- Use mcp for MCP discovery first: search by capability, describe one exact tool name, then call it.",
331
+ "- Be concise in your responses",
332
+ ].join("\n");
333
+
334
+ const result = sanitizeAvailableToolsSection(prompt, ["read"]);
335
+
336
+ expect(result.removed).toBe(true);
337
+ expect(result.prompt).toContain("Available tools:");
338
+ expect(result.prompt).toContain("- read: Read file contents");
339
+ expect(result.prompt).not.toContain("- mcp: Discover");
253
340
  });
254
341
 
255
342
  test("System prompt sanitizer removes denied tool guidelines while keeping global guidance", () => {
@@ -1,37 +0,0 @@
1
- export interface BeforeAgentStartPromptStateInput {
2
- agentName: string | null;
3
- cwd: string;
4
- permissionStamp: string;
5
- systemPrompt: string;
6
- allowedToolNames: readonly string[];
7
- }
8
-
9
- function normalizeAgentName(agentName: string | null): string {
10
- return agentName ?? "";
11
- }
12
-
13
- function normalizePrompt(prompt: string): string {
14
- return prompt.replace(/\r\n/g, "\n");
15
- }
16
-
17
- function createCacheKey(parts: readonly unknown[]): string {
18
- return JSON.stringify(parts);
19
- }
20
-
21
- export function createActiveToolsCacheKey(
22
- allowedToolNames: readonly string[],
23
- ): string {
24
- return createCacheKey(allowedToolNames);
25
- }
26
-
27
- export function createBeforeAgentStartPromptStateKey(
28
- input: BeforeAgentStartPromptStateInput,
29
- ): string {
30
- return createCacheKey([
31
- normalizeAgentName(input.agentName),
32
- input.cwd,
33
- input.permissionStamp,
34
- createActiveToolsCacheKey(input.allowedToolNames),
35
- normalizePrompt(input.systemPrompt),
36
- ]);
37
- }
@@ -1,32 +0,0 @@
1
- /**
2
- * Owns a previous cache key and conditionally runs an effect when the key changes.
3
- *
4
- * Encapsulates the prev !== next comparison that previously lived in three places:
5
- * the session's inline `!==`, the handler's ask-then-tell orchestration, and the
6
- * (test-only-alive) `shouldApplyCachedAgentStartState` free function.
7
- *
8
- * Semantics:
9
- * - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value.
10
- * - On an unchanged key: skips `effect`, returns `undefined`.
11
- * - `reset()` re-arms the gate (used by session lifecycle: `resetForNewSession`,
12
- * `shutdown`, `reload`).
13
- *
14
- * Commit ordering is run-then-commit: the key is saved only after `effect` returns.
15
- * If `effect` throws, the key stays uncommitted and the next call retries.
16
- */
17
- export class CacheKeyGate {
18
- private previousKey: string | null = null;
19
-
20
- runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
21
- if (this.previousKey === nextKey) {
22
- return undefined;
23
- }
24
- const result = effect();
25
- this.previousKey = nextKey;
26
- return result;
27
- }
28
-
29
- reset(): void {
30
- this.previousKey = null;
31
- }
32
- }
@@ -1,59 +0,0 @@
1
- import { writeFileSync } from "node:fs";
2
- import { expect, test } from "vitest";
3
- import { createBeforeAgentStartPromptStateKey } from "#src/before-agent-start-cache";
4
- import { createManager } from "#test/helpers/manager-harness";
5
-
6
- test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
7
- const { manager, globalConfigPath, cleanup } = createManager({
8
- permission: { "*": "allow", write: "deny" },
9
- });
10
-
11
- try {
12
- const baselineStamp = manager.getPolicyCacheStamp();
13
- const baselineKey = createBeforeAgentStartPromptStateKey({
14
- agentName: null,
15
- cwd: "C:/workspace/project",
16
- permissionStamp: baselineStamp,
17
- systemPrompt: "Available tools:\n- read\n- write",
18
- allowedToolNames: ["read"],
19
- });
20
-
21
- expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
22
-
23
- const updatedConfig = `${JSON.stringify(
24
- { permission: { "*": "allow", write: "allow" } },
25
- null,
26
- 2,
27
- )}\n`;
28
-
29
- let updatedStamp = baselineStamp;
30
- for (
31
- let attempt = 0;
32
- attempt < 10 && updatedStamp === baselineStamp;
33
- attempt += 1
34
- ) {
35
- const waitUntil = Date.now() + 2;
36
- while (Date.now() < waitUntil) {
37
- // Wait for the filesystem timestamp granularity to advance.
38
- }
39
-
40
- writeFileSync(globalConfigPath, updatedConfig, "utf8");
41
- updatedStamp = manager.getPolicyCacheStamp();
42
- }
43
-
44
- expect(updatedStamp).not.toBe(baselineStamp);
45
-
46
- const invalidatedKey = createBeforeAgentStartPromptStateKey({
47
- agentName: null,
48
- cwd: "C:/workspace/project",
49
- permissionStamp: updatedStamp,
50
- systemPrompt: "Available tools:\n- read\n- write",
51
- allowedToolNames: ["read", "write"],
52
- });
53
-
54
- expect(invalidatedKey).not.toBe(baselineKey);
55
- expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
56
- } finally {
57
- cleanup();
58
- }
59
- });
@@ -1,85 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
-
3
- import { CacheKeyGate } from "#src/cache-key-gate";
4
-
5
- describe("CacheKeyGate", () => {
6
- describe("runIfChanged", () => {
7
- it("runs the effect and returns its value when the key is new (null previous)", () => {
8
- const gate = new CacheKeyGate();
9
- const effect = vi.fn(() => "result");
10
-
11
- const result = gate.runIfChanged("key-a", effect);
12
-
13
- expect(effect).toHaveBeenCalledOnce();
14
- expect(result).toBe("result");
15
- });
16
-
17
- it("commits the key so a second call with the same key skips the effect", () => {
18
- const gate = new CacheKeyGate();
19
- const effect = vi.fn(() => "result");
20
-
21
- gate.runIfChanged("key-a", effect);
22
- const result = gate.runIfChanged("key-a", effect);
23
-
24
- expect(effect).toHaveBeenCalledOnce();
25
- expect(result).toBeUndefined();
26
- });
27
-
28
- it("runs the effect when the key changes", () => {
29
- const gate = new CacheKeyGate();
30
- const effect = vi.fn((n: number) => n);
31
-
32
- gate.runIfChanged("key-a", () => effect(1));
33
- const result = gate.runIfChanged("key-b", () => effect(2));
34
-
35
- expect(effect).toHaveBeenCalledTimes(2);
36
- expect(result).toBe(2);
37
- });
38
-
39
- it("returns undefined when the key is unchanged", () => {
40
- const gate = new CacheKeyGate();
41
- gate.runIfChanged("key-a", vi.fn());
42
-
43
- const result = gate.runIfChanged("key-a", vi.fn());
44
-
45
- expect(result).toBeUndefined();
46
- });
47
-
48
- it("does not commit the key if the effect throws", () => {
49
- const gate = new CacheKeyGate();
50
- const throwing = vi.fn(() => {
51
- throw new Error("oops");
52
- });
53
- const fallback = vi.fn(() => "ok");
54
-
55
- expect(() => gate.runIfChanged("key-a", throwing)).toThrow("oops");
56
-
57
- // Same key should run again since the first call threw
58
- gate.runIfChanged("key-a", fallback);
59
- expect(fallback).toHaveBeenCalledOnce();
60
- });
61
- });
62
-
63
- describe("reset", () => {
64
- it("re-arms the gate so the same key runs again on the next call", () => {
65
- const gate = new CacheKeyGate();
66
- const effect = vi.fn(() => "ok");
67
-
68
- gate.runIfChanged("key-a", effect);
69
- gate.reset();
70
- gate.runIfChanged("key-a", effect);
71
-
72
- expect(effect).toHaveBeenCalledTimes(2);
73
- });
74
-
75
- it("is idempotent when called on a fresh gate", () => {
76
- const gate = new CacheKeyGate();
77
- gate.reset();
78
- const effect = vi.fn(() => "ok");
79
-
80
- gate.runIfChanged("key-a", effect);
81
-
82
- expect(effect).toHaveBeenCalledOnce();
83
- });
84
- });
85
- });