@gotgenes/pi-permission-system 31.1.2 → 31.1.3

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,18 @@ 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
+ ## [31.1.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.2...pi-permission-system-v31.1.3) (2026-09-08)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **pi-permission-system:** state each session's own tool list instead of editing the inherited one ([190e4c7](https://github.com/gotgenes/pi-packages/commit/190e4c7dc7e1a34e2e3c3ce4b413cd1e3ecd74c7)), closes [#890](https://github.com/gotgenes/pi-packages/issues/890)
14
+
15
+ ### Documentation
16
+
17
+ * record that the inherited region is shared parts, not shared bytes ([#890](https://github.com/gotgenes/pi-packages/issues/890)) ([5755a9a](https://github.com/gotgenes/pi-packages/commit/5755a9a8ea34bf5c7e08a4cb8abc988c12eb3a59))
18
+ * correct the sub-agent bridge description and the header-match residual ([#890](https://github.com/gotgenes/pi-packages/issues/890)) ([b0db764](https://github.com/gotgenes/pi-packages/commit/b0db764e0ffa484eb18bad2883710e77d93ec99b)), closes [#890](https://github.com/gotgenes/pi-packages/issues/890)
19
+
8
20
  ## [31.1.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.1...pi-permission-system-v31.1.2) (2026-09-07)
9
21
 
10
22
 
@@ -1155,11 +1155,11 @@ permission:
1155
1155
 
1156
1156
  The extension integrates via Pi's lifecycle hooks:
1157
1157
 
1158
- | Hook | Behavior |
1159
- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
1160
- | `before_agent_start` | Filters the active tool set (restrict-only), narrows the `Available tools:` system-prompt listing to match, and hides denied skills |
1161
- | `tool_call` | Enforces permissions for every tool invocation |
1162
- | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1158
+ | Hook | Behavior |
1159
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1160
+ | `before_agent_start` | Filters the active tool set (restrict-only), restates the `Available tools:` and `Guidelines:` sections at the end of the system prompt to match, and hides denied skills |
1161
+ | `tool_call` | Enforces permissions for every tool invocation |
1162
+ | `input` | Intercepts `/skill:<name>` requests and enforces skill policy |
1163
1163
 
1164
1164
  Additional behaviors:
1165
1165
 
@@ -1167,10 +1167,12 @@ Additional behaviors:
1167
1167
  - Tool filtering is restrict-only: the active set starts from pi's already-active tools (`pi.getActiveTools()`) and only ever has denied tools removed — the permission system never activates a tool pi left off by default (e.g. `find`, `grep`, `ls`)
1168
1168
  - Policy is applied to the tool surface pi has activated over the session, not to the previous turn's filtered result, so removing a `deny` rule restores the tool it had hidden without restarting pi.
1169
1169
  A tool that stops being active for any other reason (another extension deactivating it, pi unregistering it) is not restored.
1170
- - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt an extension receives before the extension runs, so the line is only regenerated once the restored tool is already active
1170
+ - On the turn a tool is restored, it is callable immediately but its `Available tools:` line reappears one turn later: pi builds the prompt parts an extension receives before the extension runs, so the restored tool has no one-line description to render until it is already active
1171
1171
  - A tool is removed only when every value under its surface resolves to `deny`; a surface with any reachable `allow` or `ask` pattern stays available (see [Tool Surfaces](#tool-surfaces))
1172
- - The `Available tools:` system prompt section is narrowed to match the filtered active tool set: denied tools' lines are dropped, the rest are kept, and the section is removed entirely only when no tool is allowed
1173
- - The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1172
+ - The `Available tools:` and `Guidelines:` sections are **relocated** rather than edited in place: the copies pi wrote are removed, and this session's own are rendered at the end of the system prompt, after pi's `Current working directory:` footer.
1173
+ Each session states its own tool surface, which is what keeps a subagent child's inherited prompt byte-identical to its parent's (see [ADR 0014](decisions/0014-tool-surface-is-node-local-prose.md)); the tool list moves to the end of the prompt for every session, whether or not anything is denied
1174
+ - The rendered sections follow pi's own rules: a tool is listed only when pi supplied a one-line description for it, and the guideline bullets are the allowed tools' own contributions around pi's built-in ones
1175
+ - The prompt is recomputed and returned on every turn but is stable across turns for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn.
1174
1176
  A policy change is an intentional cache transition, as a mid-session agent switch already is.
1175
1177
  - Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
1176
1178
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "31.1.2",
3
+ "version": "31.1.3",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -9,6 +9,44 @@ export interface ToolRegistry {
9
9
  setActive(names: string[]): void;
10
10
  }
11
11
 
12
+ /** Tool names and their guideline bullets, read from one pass over a registry. */
13
+ export interface RegisteredTools {
14
+ /** Resolvable tool names, in registry order. */
15
+ readonly names: string[];
16
+ /** Guideline bullets per tool; a tool declaring none has no entry. */
17
+ readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
18
+ }
19
+
20
+ /**
21
+ * Read a registry listing once, yielding both the answers this package needs.
22
+ *
23
+ * `getActive()` returns bare names and `getAll()` returns `ToolInfo` records,
24
+ * so both are accepted: a listing carrying no guidelines simply produces an
25
+ * empty map.
26
+ */
27
+ export function readRegisteredTools(
28
+ tools: readonly unknown[],
29
+ ): RegisteredTools {
30
+ const names: string[] = [];
31
+ const guidelinesByTool = new Map<string, readonly string[]>();
32
+
33
+ for (const tool of tools) {
34
+ const name = getToolNameFromValue(tool);
35
+ if (!name) {
36
+ continue;
37
+ }
38
+
39
+ names.push(name);
40
+
41
+ const guidelines = getToolPromptGuidelinesFromValue(tool);
42
+ if (guidelines.length > 0) {
43
+ guidelinesByTool.set(name, guidelines);
44
+ }
45
+ }
46
+
47
+ return { names, guidelinesByTool };
48
+ }
49
+
12
50
  export type ToolRegistrationCheckResult =
13
51
  | {
14
52
  status: "missing-tool-name";
@@ -98,6 +136,35 @@ export function getToolNameFromValue(value: unknown): string | null {
98
136
  return null;
99
137
  }
100
138
 
139
+ /**
140
+ * The guideline bullets a registered tool contributes to the system prompt.
141
+ *
142
+ * Pi carries them per tool on `ToolInfo.promptGuidelines` and flattens them
143
+ * into one `Guidelines:` block when it builds the prompt. Reading them per tool
144
+ * is what lets this package rebuild that block for the allowed set alone,
145
+ * rather than matching Pi's rendered sentences by literal text.
146
+ *
147
+ * Kept defensively wide, like {@link getToolNameFromValue}: anything that is
148
+ * not a non-empty string is dropped, and a value that is not an array of them
149
+ * yields no guidelines rather than throwing.
150
+ */
151
+ export function getToolPromptGuidelinesFromValue(value: unknown): string[] {
152
+ const guidelines = toRecord(value).promptGuidelines;
153
+ if (!Array.isArray(guidelines)) {
154
+ return [];
155
+ }
156
+
157
+ const bullets: string[] = [];
158
+ for (const entry of guidelines) {
159
+ const bullet = getNonEmptyString(entry);
160
+ if (bullet) {
161
+ bullets.push(bullet);
162
+ }
163
+ }
164
+
165
+ return bullets;
166
+ }
167
+
101
168
  export function checkRequestedToolRegistration(
102
169
  requestedToolName: string | null,
103
170
  registeredTools: readonly unknown[],
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The tool-surface region of a system prompt: which tools this session may
3
+ * call, and the guidance those tools contribute.
4
+ *
5
+ * Pi writes that region near the top of its preamble, a few hundred characters
6
+ * in. `@gotgenes/pi-subagents` copies everything ahead of the skills catalogue
7
+ * into a child's prompt verbatim, so the child's leading bytes match its
8
+ * parent's for prefix-reusing inference engines — which means editing the
9
+ * region in place ends that shared prefix for every child whose allowed set
10
+ * differs from its parent's (#890).
11
+ *
12
+ * So the region is *relocated* rather than narrowed: the sections Pi wrote are
13
+ * removed wherever they sit, and this node's own are rendered at the end of
14
+ * the prompt, past everything a child inherits. Each session then states its
15
+ * own tool surface and no session edits another's bytes.
16
+ *
17
+ * Rendering follows `buildSystemPrompt`'s own rules — a tool is listed only
18
+ * when it has a snippet, and the guideline bullets are the allowed tools' own
19
+ * `promptGuidelines` around Pi's built-in ones — so the block reads as the one
20
+ * Pi would have written for this session's real surface.
21
+ */
22
+
23
+ /** What a session's tool surface renders from. */
24
+ export interface ToolSurfaceInputs {
25
+ /** Tools this session may call, in the order they should be listed. */
26
+ readonly allowedTools: readonly string[];
27
+ /** Pi's one-line tool descriptions, keyed by tool name. */
28
+ readonly toolSnippets: Readonly<Record<string, string>>;
29
+ /** Guideline bullets each tool contributes, keyed by tool name. */
30
+ readonly guidelinesByTool: ReadonlyMap<string, readonly string[]>;
31
+ }
32
+
33
+ type LineSection = {
34
+ start: number;
35
+ end: number;
36
+ };
37
+
38
+ const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
39
+ const GUIDELINES_SECTION_HEADER = "Guidelines:";
40
+
41
+ /**
42
+ * Pi's filler sentence between the tool list and the guidelines.
43
+ *
44
+ * It refers to "the tools above", so it belongs with the list rather than with
45
+ * the text the list is being moved out of.
46
+ */
47
+ const CUSTOM_TOOLS_FILLER_PREFIX = "In addition to the tools above";
48
+
49
+ /** Pi's two unconditional guideline bullets, in the order it writes them. */
50
+ const UNIVERSAL_GUIDELINES: readonly string[] = [
51
+ "Be concise in your responses",
52
+ "Show file paths clearly when working with files",
53
+ ];
54
+
55
+ /**
56
+ * Relocate the tool surface: drop the sections Pi wrote, append this session's.
57
+ *
58
+ * The result always carries a tool-surface block, so a child whose inherited
59
+ * identity has none — its parent's node having already relocated it — still
60
+ * describes its own tools.
61
+ */
62
+ export function renderToolSurface(
63
+ systemPrompt: string,
64
+ inputs: ToolSurfaceInputs,
65
+ ): string {
66
+ const lines = removeToolSurfaceSections(
67
+ normalizePrompt(systemPrompt).split("\n"),
68
+ );
69
+ const body = collapseExtraBlankLines(lines.join("\n"));
70
+ const block = renderToolSurfaceBlock(inputs);
71
+
72
+ return body.length > 0 ? `${body}\n\n${block}` : block;
73
+ }
74
+
75
+ /**
76
+ * Remove the `Available tools:` and `Guidelines:` sections, and the filler
77
+ * sentence between them.
78
+ *
79
+ * Each section is located by its own header, so the two are removed whether
80
+ * they sit adjacent in Pi's preamble or alone in a prompt something downstream
81
+ * rewrote — including a prompt this function already produced, which is what
82
+ * makes it safe to apply to its own output.
83
+ */
84
+ function removeToolSurfaceSections(lines: readonly string[]): string[] {
85
+ let remaining = [...lines];
86
+ for (const header of [
87
+ AVAILABLE_TOOLS_SECTION_HEADER,
88
+ GUIDELINES_SECTION_HEADER,
89
+ ]) {
90
+ const section = findSection(remaining, header);
91
+ if (section) {
92
+ remaining = [
93
+ ...remaining.slice(0, section.start),
94
+ ...remaining.slice(section.end),
95
+ ];
96
+ }
97
+ }
98
+
99
+ return remaining.filter(
100
+ (line) => !line.trimStart().startsWith(CUSTOM_TOOLS_FILLER_PREFIX),
101
+ );
102
+ }
103
+
104
+ /** This session's tool surface, as Pi would have rendered it. */
105
+ function renderToolSurfaceBlock(inputs: ToolSurfaceInputs): string {
106
+ const sections: string[] = [];
107
+
108
+ const toolList = renderAvailableTools(inputs);
109
+ if (toolList) {
110
+ sections.push(toolList);
111
+ }
112
+ sections.push(renderGuidelines(inputs));
113
+
114
+ return sections.join("\n\n");
115
+ }
116
+
117
+ /**
118
+ * The `Available tools:` section for the allowed set, or `null` when none of
119
+ * those tools has a snippet.
120
+ *
121
+ * Pi lists a tool only when the caller supplied a one-line snippet for it, so
122
+ * a tool without one is left unlisted here too rather than rendered bare.
123
+ */
124
+ function renderAvailableTools(inputs: ToolSurfaceInputs): string | null {
125
+ const bullets = inputs.allowedTools
126
+ .map((toolName) => ({ toolName, snippet: inputs.toolSnippets[toolName] }))
127
+ .filter((tool) => Boolean(tool.snippet))
128
+ .map((tool) => `- ${tool.toolName}: ${tool.snippet}`);
129
+
130
+ return bullets.length > 0
131
+ ? [AVAILABLE_TOOLS_SECTION_HEADER, ...bullets].join("\n")
132
+ : null;
133
+ }
134
+
135
+ /**
136
+ * The `Guidelines:` section for the allowed set.
137
+ *
138
+ * Mirrors `buildSystemPrompt`'s assembly: its conditional file-exploration
139
+ * bullet first, then each allowed tool's own contributions, then its two
140
+ * unconditional bullets — de-duplicated in first-seen order, as Pi does.
141
+ */
142
+ function renderGuidelines(inputs: ToolSurfaceInputs): string {
143
+ const bullets: string[] = [];
144
+ const seen = new Set<string>();
145
+ const addGuideline = (guideline: string): void => {
146
+ const normalized = guideline.trim();
147
+ if (normalized.length === 0 || seen.has(normalized)) {
148
+ return;
149
+ }
150
+ seen.add(normalized);
151
+ bullets.push(normalized);
152
+ };
153
+
154
+ const fileOperations = fileExplorationGuideline(new Set(inputs.allowedTools));
155
+ if (fileOperations) {
156
+ addGuideline(fileOperations);
157
+ }
158
+
159
+ for (const toolName of inputs.allowedTools) {
160
+ for (const guideline of inputs.guidelinesByTool.get(toolName) ?? []) {
161
+ addGuideline(guideline);
162
+ }
163
+ }
164
+
165
+ for (const guideline of UNIVERSAL_GUIDELINES) {
166
+ addGuideline(guideline);
167
+ }
168
+
169
+ return [
170
+ GUIDELINES_SECTION_HEADER,
171
+ ...bullets.map((bullet) => `- ${bullet}`),
172
+ ].join("\n");
173
+ }
174
+
175
+ /**
176
+ * Pi's shell-only file-exploration bullet, or `null` when it does not apply.
177
+ *
178
+ * Pi writes it only when a shell is available and none of the dedicated
179
+ * exploration tools is, so a session holding `grep`/`find`/`ls` is not told to
180
+ * reach for the shell instead.
181
+ */
182
+ function fileExplorationGuideline(
183
+ allowedTools: ReadonlySet<string>,
184
+ ): string | null {
185
+ const hasBash = allowedTools.has("bash");
186
+ const hasPowerShell = allowedTools.has("powershell");
187
+ const hasExplorationTool =
188
+ allowedTools.has("grep") ||
189
+ allowedTools.has("find") ||
190
+ allowedTools.has("ls");
191
+
192
+ if ((!hasBash && !hasPowerShell) || hasExplorationTool) {
193
+ return null;
194
+ }
195
+ if (hasBash && hasPowerShell) {
196
+ return "Use bash or PowerShell for file operations like listing, searching, and finding files";
197
+ }
198
+ if (hasPowerShell) {
199
+ return "Use PowerShell for file operations like listing, searching, and finding files";
200
+ }
201
+ return "Use bash for file operations like ls, rg, find";
202
+ }
203
+
204
+ function normalizePrompt(prompt: string): string {
205
+ return (prompt || "").replace(/\r\n/g, "\n");
206
+ }
207
+
208
+ function collapseExtraBlankLines(text: string): string {
209
+ return text.replace(/\n{3,}/g, "\n\n").trimEnd();
210
+ }
211
+
212
+ function isTopLevelSectionHeader(line: string): boolean {
213
+ const trimmed = line.trim();
214
+ return (
215
+ trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
216
+ );
217
+ }
218
+
219
+ function isSectionBodyLine(line: string): boolean {
220
+ const trimmed = line.trim();
221
+ if (trimmed.length === 0) return true; // blank line
222
+ if (trimmed.startsWith("- ")) return true; // bullet
223
+ if (line !== line.trimStart()) return true; // indented
224
+ return false;
225
+ }
226
+
227
+ function findSection(
228
+ lines: readonly string[],
229
+ header: string,
230
+ ): LineSection | null {
231
+ const start = lines.findIndex((line) => line.trim() === header);
232
+ if (start === -1) {
233
+ return null;
234
+ }
235
+
236
+ // If a subsequent recognised section header exists, use it as the boundary.
237
+ // This preserves the original behaviour for the common case where sections
238
+ // are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
239
+ // ensures any prose continuation between the two headers is also removed.
240
+ for (let index = start + 1; index < lines.length; index += 1) {
241
+ if (isTopLevelSectionHeader(lines[index])) {
242
+ return { start, end: index };
243
+ }
244
+ }
245
+
246
+ // No subsequent section header — stop at the first non-body line so that
247
+ // content after the section (e.g. custom user notes) is not silently deleted.
248
+ let end = start + 1;
249
+ for (let index = start + 1; index < lines.length; index += 1) {
250
+ if (!isSectionBodyLine(lines[index])) {
251
+ end = index;
252
+ break;
253
+ }
254
+ end = index + 1;
255
+ }
256
+
257
+ return { start, end };
258
+ }
@@ -3,12 +3,13 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { resolveSkillPromptEntries } from "#src/exposure/skill-prompt-sanitizer";
6
- import { sanitizeAvailableToolsSection } from "#src/exposure/system-prompt-sanitizer";
7
6
  import {
8
- getToolNameFromValue,
7
+ type RegisteredTools,
8
+ readRegisteredTools,
9
9
  type ToolRegistry,
10
10
  } from "#src/exposure/tool-registry";
11
11
  import type { ToolSurfaceObservation } from "#src/exposure/tool-surface-baseline";
12
+ import { renderToolSurface } from "#src/exposure/tool-surface-prompt";
12
13
  import type { DebugLogger } from "#src/logging/session-logger";
13
14
  import type { PermissionResolver } from "#src/policy/permission-resolver";
14
15
  import type { PermissionSession } from "#src/session/permission-session";
@@ -17,6 +18,14 @@ import type { TurnPreparation } from "./session-turn-prep";
17
18
  /** Minimal subset of BeforeAgentStartEvent used by this handler. */
18
19
  interface BeforeAgentStartPayload {
19
20
  systemPrompt: string;
21
+ /**
22
+ * The parts Pi assembled the prompt from. `toolSnippets` is what lets this
23
+ * handler render the session's own tool list instead of editing the one Pi
24
+ * wrote — including in a child, whose inherited identity carries none.
25
+ */
26
+ systemPromptOptions?: {
27
+ toolSnippets?: Record<string, string>;
28
+ };
20
29
  }
21
30
 
22
31
  /**
@@ -39,8 +48,12 @@ export function shouldExposeTool(
39
48
  *
40
49
  * Recomputes the active tool set and the returned system-prompt override on
41
50
  * every fire (no memoization): the override must be returned each turn so that
42
- * skill filtering is reapplied and the wire prompt stays byte-stable, rather
43
- * than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
51
+ * skill filtering is reapplied and the wire prompt stays stable across turns,
52
+ * rather than letting Pi reset to its skill-unfiltered base prompt on a cache
53
+ * hit.
54
+ *
55
+ * The tool surface is relocated rather than edited in place, so a subagent
56
+ * child's inherited identity stays byte-identical to its parent's (#890).
44
57
  *
45
58
  * Constructor deps:
46
59
  * - `turnPrep` — brings the node up to date for the turn before anything reads
@@ -70,8 +83,9 @@ export class AgentPrepHandler {
70
83
  this.turnPrep.prepare(ctx);
71
84
 
72
85
  const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
86
+ const registered = readRegisteredTools(this.toolRegistry.getAll());
73
87
  const surface = this.session.resolveExposedTools(
74
- this.observeToolSurface(),
88
+ this.observeToolSurface(registered),
75
89
  (toolName) =>
76
90
  shouldExposeTool(toolName, agentName, (t, a) =>
77
91
  this.resolver.isToolFullyDenied(t, a),
@@ -88,12 +102,13 @@ export class AgentPrepHandler {
88
102
  });
89
103
  }
90
104
 
91
- const toolPromptResult = sanitizeAvailableToolsSection(
92
- event.systemPrompt,
105
+ const toolSurfacePrompt = renderToolSurface(event.systemPrompt, {
93
106
  allowedTools,
94
- );
107
+ toolSnippets: event.systemPromptOptions?.toolSnippets ?? {},
108
+ guidelinesByTool: registered.guidelinesByTool,
109
+ });
95
110
  const skillPromptResult = resolveSkillPromptEntries(
96
- toolPromptResult.prompt,
111
+ toolSurfacePrompt,
97
112
  this.resolver,
98
113
  agentName,
99
114
  this.session.getPathNormalizer(),
@@ -104,21 +119,12 @@ export class AgentPrepHandler {
104
119
  : {};
105
120
  }
106
121
 
107
- private observeToolSurface(): ToolSurfaceObservation {
122
+ private observeToolSurface(
123
+ registered: RegisteredTools,
124
+ ): ToolSurfaceObservation {
108
125
  return {
109
- active: toolNamesOf(this.toolRegistry.getActive()),
110
- registered: new Set(toolNamesOf(this.toolRegistry.getAll())),
126
+ active: readRegisteredTools(this.toolRegistry.getActive()).names,
127
+ registered: new Set(registered.names),
111
128
  };
112
129
  }
113
130
  }
114
-
115
- function toolNamesOf(tools: readonly unknown[]): string[] {
116
- const names: string[] = [];
117
- for (const tool of tools) {
118
- const toolName = getToolNameFromValue(tool);
119
- if (toolName) {
120
- names.push(toolName);
121
- }
122
- }
123
- return names;
124
- }
@@ -1,274 +0,0 @@
1
- export interface SanitizeSystemPromptResult {
2
- prompt: string;
3
- removed: boolean;
4
- }
5
-
6
- type LineSection = {
7
- start: number;
8
- end: number;
9
- };
10
-
11
- type GuidelineRule = {
12
- matches: (guideline: string) => boolean;
13
- shouldKeep: (allowedTools: ReadonlySet<string>) => boolean;
14
- };
15
-
16
- const AVAILABLE_TOOLS_SECTION_HEADER = "Available tools:";
17
- const GUIDELINES_SECTION_HEADER = "Guidelines:";
18
-
19
- const TOOL_GUIDELINE_RULES: readonly GuidelineRule[] = [
20
- {
21
- matches: (guideline) =>
22
- guideline === "use bash for file operations like ls, rg, find",
23
- shouldKeep: (allowedTools) => allowedTools.has("bash"),
24
- },
25
- {
26
- matches: (guideline) =>
27
- guideline ===
28
- "prefer grep/find/ls tools over bash for file exploration (faster, respects .gitignore)",
29
- shouldKeep: (allowedTools) =>
30
- allowedTools.has("bash") &&
31
- (allowedTools.has("grep") ||
32
- allowedTools.has("find") ||
33
- allowedTools.has("ls")),
34
- },
35
- {
36
- matches: (guideline) =>
37
- guideline ===
38
- "use read to examine files before editing. you must use this tool instead of cat or sed." ||
39
- guideline === "use read to examine files instead of cat or sed.",
40
- shouldKeep: (allowedTools) => allowedTools.has("read"),
41
- },
42
- {
43
- matches: (guideline) =>
44
- guideline ===
45
- "use edit for precise changes (old text must match exactly)",
46
- shouldKeep: (allowedTools) => allowedTools.has("edit"),
47
- },
48
- {
49
- matches: (guideline) =>
50
- guideline === "use write only for new files or complete rewrites",
51
- shouldKeep: (allowedTools) => allowedTools.has("write"),
52
- },
53
- {
54
- matches: (guideline) =>
55
- guideline ===
56
- "when summarizing your actions, output plain text directly - do not use cat or bash to display what you did",
57
- shouldKeep: (allowedTools) =>
58
- allowedTools.has("edit") || allowedTools.has("write"),
59
- },
60
- {
61
- matches: (guideline) =>
62
- guideline ===
63
- "use task when work should be delegated to one or more specialized agents instead of handled entirely in the current session.",
64
- shouldKeep: (allowedTools) => allowedTools.has("task"),
65
- },
66
- {
67
- matches: (guideline) =>
68
- guideline ===
69
- "use mcp for mcp discovery first: search by capability, describe one exact tool name, then call it.",
70
- shouldKeep: (allowedTools) => allowedTools.has("mcp"),
71
- },
72
- ];
73
-
74
- function normalizePrompt(prompt: string): string {
75
- return (prompt || "").replace(/\r\n/g, "\n");
76
- }
77
-
78
- function collapseExtraBlankLines(text: string): string {
79
- return text.replace(/\n{3,}/g, "\n\n").trimEnd();
80
- }
81
-
82
- function normalizeGuidelineText(line: string): string {
83
- return line
84
- .trim()
85
- .replace(/^[-*]\s+/, "")
86
- .replace(/\s+/g, " ")
87
- .toLowerCase();
88
- }
89
-
90
- function isTopLevelSectionHeader(line: string): boolean {
91
- const trimmed = line.trim();
92
- return (
93
- trimmed.length > 0 && trimmed.endsWith(":") && !trimmed.startsWith("-")
94
- );
95
- }
96
-
97
- function isSectionBodyLine(line: string): boolean {
98
- const trimmed = line.trim();
99
- if (trimmed.length === 0) return true; // blank line
100
- if (trimmed.startsWith("- ")) return true; // bullet
101
- if (line !== line.trimStart()) return true; // indented
102
- return false;
103
- }
104
-
105
- function findSection(
106
- lines: readonly string[],
107
- header: string,
108
- ): LineSection | null {
109
- const start = lines.findIndex((line) => line.trim() === header);
110
- if (start === -1) {
111
- return null;
112
- }
113
-
114
- // If a subsequent recognised section header exists, use it as the boundary.
115
- // This preserves the original behaviour for the common case where sections
116
- // are adjacent (e.g. "Available tools:" followed by "Guidelines:") and
117
- // ensures any prose continuation between the two headers is also removed.
118
- for (let index = start + 1; index < lines.length; index += 1) {
119
- if (isTopLevelSectionHeader(lines[index])) {
120
- return { start, end: index };
121
- }
122
- }
123
-
124
- // No subsequent section header — stop at the first non-body line so that
125
- // content after the section (e.g. custom user notes) is not silently deleted.
126
- let end = start + 1;
127
- for (let index = start + 1; index < lines.length; index += 1) {
128
- if (!isSectionBodyLine(lines[index])) {
129
- end = index;
130
- break;
131
- }
132
- end = index + 1;
133
- }
134
-
135
- return { start, end };
136
- }
137
-
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(
156
- lines: readonly string[],
157
- allowedTools: ReadonlySet<string>,
158
- ): { lines: string[]; removed: boolean } {
159
- const section = findSection(lines, AVAILABLE_TOOLS_SECTION_HEADER);
160
- if (!section) {
161
- return { lines: [...lines], removed: false };
162
- }
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
-
189
- return {
190
- lines: [...before, header, ...filteredBody, ...after],
191
- removed: true,
192
- };
193
- }
194
-
195
- function shouldKeepGuideline(
196
- line: string,
197
- allowedTools: ReadonlySet<string>,
198
- ): boolean {
199
- const normalized = normalizeGuidelineText(line);
200
-
201
- for (const rule of TOOL_GUIDELINE_RULES) {
202
- if (rule.matches(normalized)) {
203
- return rule.shouldKeep(allowedTools);
204
- }
205
- }
206
-
207
- return true;
208
- }
209
-
210
- function sanitizeGuidelinesSection(
211
- lines: readonly string[],
212
- allowedTools: ReadonlySet<string>,
213
- ): { lines: string[]; removed: boolean } {
214
- const section = findSection(lines, GUIDELINES_SECTION_HEADER);
215
- if (!section) {
216
- return { lines: [...lines], removed: false };
217
- }
218
-
219
- const before = lines.slice(0, section.start + 1);
220
- const after = lines.slice(section.end);
221
- const body = lines.slice(section.start + 1, section.end);
222
- const filteredBody = body.filter((line) => {
223
- const trimmed = line.trim();
224
- if (!trimmed.startsWith("- ")) {
225
- return true;
226
- }
227
-
228
- return shouldKeepGuideline(line, allowedTools);
229
- });
230
-
231
- const removed = filteredBody.length !== body.length;
232
- if (!removed) {
233
- return { lines: [...lines], removed: false };
234
- }
235
-
236
- const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
237
- if (!hasBullet) {
238
- return {
239
- lines: [...lines.slice(0, section.start), ...after],
240
- removed: true,
241
- };
242
- }
243
-
244
- return {
245
- lines: [...before, ...filteredBody, ...after],
246
- removed: true,
247
- };
248
- }
249
-
250
- export function sanitizeAvailableToolsSection(
251
- systemPrompt: string,
252
- allowedToolNames: readonly string[],
253
- ): SanitizeSystemPromptResult {
254
- const allowedTools = new Set(
255
- allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
256
- );
257
- const normalizedLines = normalizePrompt(systemPrompt).split("\n");
258
- const narrowedToolsSection = narrowAvailableToolsSection(
259
- normalizedLines,
260
- allowedTools,
261
- );
262
- const sanitizedGuidelines = sanitizeGuidelinesSection(
263
- narrowedToolsSection.lines,
264
- allowedTools,
265
- );
266
- const removed = narrowedToolsSection.removed || sanitizedGuidelines.removed;
267
-
268
- return {
269
- prompt: removed
270
- ? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
271
- : systemPrompt,
272
- removed,
273
- };
274
- }