@gotgenes/pi-permission-system 31.1.1 → 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,25 @@ 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
|
+
|
|
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)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Miscellaneous Chores
|
|
24
|
+
|
|
25
|
+
* **pi-permission-system:** add path false-positive measurement script ([464c6a1](https://github.com/gotgenes/pi-packages/commit/464c6a10a2795cbae5fac4e518d0b60ed90c7eaf)), closes [#863](https://github.com/gotgenes/pi-packages/issues/863)
|
|
26
|
+
|
|
8
27
|
## [31.1.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v31.1.0...pi-permission-system-v31.1.1) (2026-09-05)
|
|
9
28
|
|
|
10
29
|
|
package/docs/configuration.md
CHANGED
|
@@ -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),
|
|
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
|
|
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:`
|
|
1173
|
-
|
|
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.
|
|
3
|
+
"version": "31.1.3",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"test:watch": "vitest",
|
|
91
91
|
"verify:public-types": "bash scripts/verify-public-types.sh",
|
|
92
92
|
"measure:core-coverage": "node scripts/measure-core-coverage.mjs",
|
|
93
|
+
"measure:path-false-positives": "node scripts/measure-path-false-positives.mjs",
|
|
93
94
|
"measure:statement-descent": "node scripts/measure-statement-descent.mjs",
|
|
94
95
|
"measure:statement-operands": "node scripts/measure-statement-operands.mjs",
|
|
95
96
|
"measure:unresolved-redirects": "node scripts/measure-unresolved-redirects.mjs",
|
|
@@ -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
|
-
|
|
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
|
|
43
|
-
* than letting Pi reset to its skill-unfiltered base prompt on a cache
|
|
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
|
|
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
|
-
|
|
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(
|
|
122
|
+
private observeToolSurface(
|
|
123
|
+
registered: RegisteredTools,
|
|
124
|
+
): ToolSurfaceObservation {
|
|
108
125
|
return {
|
|
109
|
-
active:
|
|
110
|
-
registered: new Set(
|
|
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
|
-
}
|