@gotgenes/pi-permission-system 0.7.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 +384 -0
- package/LICENSE +21 -0
- package/README.md +606 -0
- package/config/config.example.json +27 -0
- package/index.ts +3 -0
- package/package.json +85 -0
- package/schemas/permissions.schema.json +88 -0
- package/src/bash-filter.ts +51 -0
- package/src/before-agent-start-cache.ts +44 -0
- package/src/common.ts +88 -0
- package/src/config-modal.ts +282 -0
- package/src/config-reporter.ts +26 -0
- package/src/extension-config.ts +203 -0
- package/src/index.ts +1983 -0
- package/src/logging.ts +118 -0
- package/src/model-option-compatibility.ts +182 -0
- package/src/permission-dialog.ts +89 -0
- package/src/permission-forwarding.ts +126 -0
- package/src/permission-manager.ts +989 -0
- package/src/skill-prompt-sanitizer.ts +344 -0
- package/src/status.ts +35 -0
- package/src/system-prompt-sanitizer.ts +210 -0
- package/src/tool-registry.ts +139 -0
- package/src/types.ts +50 -0
- package/src/wildcard-matcher.ts +84 -0
- package/src/yolo-mode.ts +29 -0
- package/src/zellij-modal.ts +1117 -0
- package/tests/config-modal.test.ts +248 -0
- package/tests/config-reporter.test.ts +139 -0
- package/tests/extension-config.test.ts +120 -0
- package/tests/permission-system.test.ts +2356 -0
- package/tests/session-start.test.ts +139 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { dirname, join, normalize, resolve, sep } from "node:path";
|
|
3
|
+
|
|
4
|
+
import type { PermissionManager } from "./permission-manager.js";
|
|
5
|
+
import type { PermissionState } from "./types.js";
|
|
6
|
+
|
|
7
|
+
const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
|
|
8
|
+
const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
|
|
9
|
+
const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
|
|
10
|
+
const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
|
|
11
|
+
const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
|
|
12
|
+
const SKILL_LOCATION_REGEX = /<location>([\s\S]*?)<\/location>/;
|
|
13
|
+
|
|
14
|
+
type ParsedSkillPromptEntry = {
|
|
15
|
+
name: string;
|
|
16
|
+
description: string;
|
|
17
|
+
location: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SkillPromptEntry = {
|
|
21
|
+
name: string;
|
|
22
|
+
description: string;
|
|
23
|
+
location: string;
|
|
24
|
+
state: PermissionState;
|
|
25
|
+
normalizedLocation: string;
|
|
26
|
+
normalizedBaseDir: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type SkillPromptSection = {
|
|
30
|
+
start: number;
|
|
31
|
+
end: number;
|
|
32
|
+
entries: ParsedSkillPromptEntry[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function decodeXml(value: string): string {
|
|
36
|
+
return value
|
|
37
|
+
.replace(/</g, "<")
|
|
38
|
+
.replace(/>/g, ">")
|
|
39
|
+
.replace(/"/g, '"')
|
|
40
|
+
.replace(/'/g, "'")
|
|
41
|
+
.replace(/&/g, "&");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encodeXml(value: string): string {
|
|
45
|
+
return value
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">")
|
|
49
|
+
.replace(/"/g, """)
|
|
50
|
+
.replace(/'/g, "'");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizePathForComparison(pathValue: string, cwd: string): string {
|
|
54
|
+
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
55
|
+
if (!trimmed) {
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
60
|
+
|
|
61
|
+
if (normalizedPath === "~") {
|
|
62
|
+
normalizedPath = homedir();
|
|
63
|
+
} else if (
|
|
64
|
+
normalizedPath.startsWith("~/") ||
|
|
65
|
+
normalizedPath.startsWith("~\\")
|
|
66
|
+
) {
|
|
67
|
+
normalizedPath = join(homedir(), normalizedPath.slice(2));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const absolutePath = resolve(cwd, normalizedPath);
|
|
71
|
+
const normalizedAbsolutePath = normalize(absolutePath);
|
|
72
|
+
return process.platform === "win32"
|
|
73
|
+
? normalizedAbsolutePath.toLowerCase()
|
|
74
|
+
: normalizedAbsolutePath;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPathWithinDirectory(pathValue: string, directory: string): boolean {
|
|
78
|
+
if (!pathValue || !directory) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (pathValue === directory) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
87
|
+
return pathValue.startsWith(prefix);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
|
|
91
|
+
const entries: ParsedSkillPromptEntry[] = [];
|
|
92
|
+
const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
|
|
93
|
+
|
|
94
|
+
for (const match of sectionBody.matchAll(skillBlockRegex)) {
|
|
95
|
+
const block = match[1];
|
|
96
|
+
const nameMatch = block.match(SKILL_NAME_REGEX);
|
|
97
|
+
const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
|
|
98
|
+
const locationMatch = block.match(SKILL_LOCATION_REGEX);
|
|
99
|
+
|
|
100
|
+
if (!nameMatch || !descriptionMatch || !locationMatch) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const name = decodeXml(nameMatch[1].trim());
|
|
105
|
+
const description = decodeXml(descriptionMatch[1].trim());
|
|
106
|
+
const location = decodeXml(locationMatch[1].trim());
|
|
107
|
+
|
|
108
|
+
if (!name || !location) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
entries.push({ name, description, location });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return entries;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function parseSkillPromptSection(
|
|
119
|
+
prompt: string,
|
|
120
|
+
): SkillPromptSection | null {
|
|
121
|
+
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG);
|
|
122
|
+
if (start === -1) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const closeStart = prompt.indexOf(
|
|
127
|
+
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
128
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
129
|
+
);
|
|
130
|
+
if (closeStart === -1) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
|
|
135
|
+
const sectionBody = prompt.slice(
|
|
136
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
137
|
+
closeStart,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
start,
|
|
142
|
+
end,
|
|
143
|
+
entries: parseSkillEntries(sectionBody),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseAllSkillPromptSections(
|
|
148
|
+
prompt: string,
|
|
149
|
+
): SkillPromptSection[] {
|
|
150
|
+
const sections: SkillPromptSection[] = [];
|
|
151
|
+
let searchStart = 0;
|
|
152
|
+
|
|
153
|
+
while (searchStart < prompt.length) {
|
|
154
|
+
const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart);
|
|
155
|
+
if (start === -1) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const closeStart = prompt.indexOf(
|
|
160
|
+
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
161
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
162
|
+
);
|
|
163
|
+
if (closeStart === -1) {
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
|
|
168
|
+
const sectionBody = prompt.slice(
|
|
169
|
+
start + AVAILABLE_SKILLS_OPEN_TAG.length,
|
|
170
|
+
closeStart,
|
|
171
|
+
);
|
|
172
|
+
sections.push({
|
|
173
|
+
start,
|
|
174
|
+
end,
|
|
175
|
+
entries: parseSkillEntries(sectionBody),
|
|
176
|
+
});
|
|
177
|
+
searchStart = end;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return sections;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function resolvePermissionState(
|
|
184
|
+
skillName: string,
|
|
185
|
+
permissionManager: PermissionManager,
|
|
186
|
+
agentName: string | null,
|
|
187
|
+
cache: Map<string, PermissionState>,
|
|
188
|
+
): PermissionState {
|
|
189
|
+
const cachedState = cache.get(skillName);
|
|
190
|
+
if (cachedState) {
|
|
191
|
+
return cachedState;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const state = permissionManager.checkPermission(
|
|
195
|
+
"skill",
|
|
196
|
+
{ name: skillName },
|
|
197
|
+
agentName ?? undefined,
|
|
198
|
+
).state;
|
|
199
|
+
cache.set(skillName, state);
|
|
200
|
+
return state;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function createResolvedSkillEntry(
|
|
204
|
+
entry: ParsedSkillPromptEntry,
|
|
205
|
+
state: PermissionState,
|
|
206
|
+
cwd: string,
|
|
207
|
+
): SkillPromptEntry {
|
|
208
|
+
return {
|
|
209
|
+
name: entry.name,
|
|
210
|
+
description: entry.description,
|
|
211
|
+
location: entry.location,
|
|
212
|
+
state,
|
|
213
|
+
normalizedLocation: normalizePathForComparison(entry.location, cwd),
|
|
214
|
+
normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function renderAvailableSkillsSection(
|
|
219
|
+
entries: readonly SkillPromptEntry[],
|
|
220
|
+
): string {
|
|
221
|
+
return [
|
|
222
|
+
AVAILABLE_SKILLS_OPEN_TAG,
|
|
223
|
+
...entries.flatMap((entry) => [
|
|
224
|
+
" <skill>",
|
|
225
|
+
` <name>${encodeXml(entry.name)}</name>`,
|
|
226
|
+
` <description>${encodeXml(entry.description)}</description>`,
|
|
227
|
+
` <location>${encodeXml(entry.location)}</location>`,
|
|
228
|
+
" </skill>",
|
|
229
|
+
]),
|
|
230
|
+
AVAILABLE_SKILLS_CLOSE_TAG,
|
|
231
|
+
].join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function removePromptRange(prompt: string, start: number, end: number): string {
|
|
235
|
+
const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
|
|
236
|
+
const afterSection = prompt.slice(end);
|
|
237
|
+
return `${beforeSection}${afterSection}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function resolveSkillPromptEntries(
|
|
241
|
+
prompt: string,
|
|
242
|
+
permissionManager: PermissionManager,
|
|
243
|
+
agentName: string | null,
|
|
244
|
+
cwd: string,
|
|
245
|
+
): { prompt: string; entries: SkillPromptEntry[] } {
|
|
246
|
+
const sections = parseAllSkillPromptSections(prompt);
|
|
247
|
+
if (sections.length === 0) {
|
|
248
|
+
return { prompt, entries: [] };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const permissionCache = new Map<string, PermissionState>();
|
|
252
|
+
const visibleEntries: SkillPromptEntry[] = [];
|
|
253
|
+
const replacements: Array<{ start: number; end: number; content: string }> =
|
|
254
|
+
[];
|
|
255
|
+
|
|
256
|
+
for (const section of sections) {
|
|
257
|
+
const resolvedEntries = section.entries.map((entry) => {
|
|
258
|
+
const state = resolvePermissionState(
|
|
259
|
+
entry.name,
|
|
260
|
+
permissionManager,
|
|
261
|
+
agentName,
|
|
262
|
+
permissionCache,
|
|
263
|
+
);
|
|
264
|
+
return createResolvedSkillEntry(entry, state, cwd);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const visibleSectionEntries = resolvedEntries.filter(
|
|
268
|
+
(entry) => entry.state !== "deny",
|
|
269
|
+
);
|
|
270
|
+
visibleEntries.push(...visibleSectionEntries);
|
|
271
|
+
|
|
272
|
+
if (visibleSectionEntries.length === resolvedEntries.length) {
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
replacements.push({
|
|
277
|
+
start: section.start,
|
|
278
|
+
end: section.end,
|
|
279
|
+
content:
|
|
280
|
+
visibleSectionEntries.length > 0
|
|
281
|
+
? renderAvailableSkillsSection(visibleSectionEntries)
|
|
282
|
+
: "",
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (replacements.length === 0) {
|
|
287
|
+
return { prompt, entries: visibleEntries };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let sanitizedPrompt = prompt;
|
|
291
|
+
for (let i = replacements.length - 1; i >= 0; i--) {
|
|
292
|
+
const replacement = replacements[i];
|
|
293
|
+
sanitizedPrompt =
|
|
294
|
+
replacement.content.length > 0
|
|
295
|
+
? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
|
|
296
|
+
: removePromptRange(
|
|
297
|
+
sanitizedPrompt,
|
|
298
|
+
replacement.start,
|
|
299
|
+
replacement.end,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
prompt: sanitizedPrompt,
|
|
305
|
+
entries: visibleEntries,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function findSkillPathMatch(
|
|
310
|
+
normalizedPath: string,
|
|
311
|
+
entries: readonly SkillPromptEntry[],
|
|
312
|
+
): SkillPromptEntry | null {
|
|
313
|
+
if (!normalizedPath || entries.length === 0) {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const entry of entries) {
|
|
318
|
+
if (
|
|
319
|
+
entry.normalizedLocation &&
|
|
320
|
+
normalizedPath === entry.normalizedLocation
|
|
321
|
+
) {
|
|
322
|
+
return entry;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let bestMatch: SkillPromptEntry | null = null;
|
|
327
|
+
for (const entry of entries) {
|
|
328
|
+
if (
|
|
329
|
+
!entry.normalizedBaseDir ||
|
|
330
|
+
!isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)
|
|
331
|
+
) {
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (
|
|
336
|
+
!bestMatch ||
|
|
337
|
+
entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length
|
|
338
|
+
) {
|
|
339
|
+
bestMatch = entry;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return bestMatch;
|
|
344
|
+
}
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionCommandContext,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EXTENSION_ID,
|
|
8
|
+
type PermissionSystemExtensionConfig,
|
|
9
|
+
} from "./extension-config.js";
|
|
10
|
+
import { isYoloModeEnabled } from "./yolo-mode.js";
|
|
11
|
+
|
|
12
|
+
export const PERMISSION_SYSTEM_STATUS_KEY = EXTENSION_ID;
|
|
13
|
+
export const PERMISSION_SYSTEM_YOLO_STATUS_VALUE = "yolo";
|
|
14
|
+
|
|
15
|
+
type PermissionStatusContext =
|
|
16
|
+
| Pick<ExtensionContext, "hasUI" | "ui">
|
|
17
|
+
| Pick<ExtensionCommandContext, "ui">;
|
|
18
|
+
|
|
19
|
+
export function getPermissionSystemStatus(
|
|
20
|
+
config: PermissionSystemExtensionConfig,
|
|
21
|
+
): string | undefined {
|
|
22
|
+
return isYoloModeEnabled(config)
|
|
23
|
+
? PERMISSION_SYSTEM_YOLO_STATUS_VALUE
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function syncPermissionSystemStatus(
|
|
28
|
+
ctx: PermissionStatusContext,
|
|
29
|
+
config: PermissionSystemExtensionConfig,
|
|
30
|
+
): void {
|
|
31
|
+
ctx.ui.setStatus(
|
|
32
|
+
PERMISSION_SYSTEM_STATUS_KEY,
|
|
33
|
+
getPermissionSystemStatus(config),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
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 findSection(
|
|
98
|
+
lines: readonly string[],
|
|
99
|
+
header: string,
|
|
100
|
+
): LineSection | null {
|
|
101
|
+
const start = lines.findIndex((line) => line.trim() === header);
|
|
102
|
+
if (start === -1) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let end = lines.length;
|
|
107
|
+
for (let index = start + 1; index < lines.length; index += 1) {
|
|
108
|
+
if (isTopLevelSectionHeader(lines[index])) {
|
|
109
|
+
end = index;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { start, end };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function removeLineSection(
|
|
118
|
+
lines: readonly string[],
|
|
119
|
+
section: LineSection | null,
|
|
120
|
+
): { lines: string[]; removed: boolean } {
|
|
121
|
+
if (!section) {
|
|
122
|
+
return { lines: [...lines], removed: false };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
lines: [...lines.slice(0, section.start), ...lines.slice(section.end)],
|
|
127
|
+
removed: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function shouldKeepGuideline(
|
|
132
|
+
line: string,
|
|
133
|
+
allowedTools: ReadonlySet<string>,
|
|
134
|
+
): boolean {
|
|
135
|
+
const normalized = normalizeGuidelineText(line);
|
|
136
|
+
|
|
137
|
+
for (const rule of TOOL_GUIDELINE_RULES) {
|
|
138
|
+
if (rule.matches(normalized)) {
|
|
139
|
+
return rule.shouldKeep(allowedTools);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function sanitizeGuidelinesSection(
|
|
147
|
+
lines: readonly string[],
|
|
148
|
+
allowedTools: ReadonlySet<string>,
|
|
149
|
+
): { lines: string[]; removed: boolean } {
|
|
150
|
+
const section = findSection(lines, GUIDELINES_SECTION_HEADER);
|
|
151
|
+
if (!section) {
|
|
152
|
+
return { lines: [...lines], removed: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const before = lines.slice(0, section.start + 1);
|
|
156
|
+
const after = lines.slice(section.end);
|
|
157
|
+
const body = lines.slice(section.start + 1, section.end);
|
|
158
|
+
const filteredBody = body.filter((line) => {
|
|
159
|
+
const trimmed = line.trim();
|
|
160
|
+
if (!trimmed.startsWith("- ")) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return shouldKeepGuideline(line, allowedTools);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const removed = filteredBody.length !== body.length;
|
|
168
|
+
if (!removed) {
|
|
169
|
+
return { lines: [...lines], removed: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hasBullet = filteredBody.some((line) => line.trim().startsWith("- "));
|
|
173
|
+
if (!hasBullet) {
|
|
174
|
+
return {
|
|
175
|
+
lines: [...lines.slice(0, section.start), ...after],
|
|
176
|
+
removed: true,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
lines: [...before, ...filteredBody, ...after],
|
|
182
|
+
removed: true,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function sanitizeAvailableToolsSection(
|
|
187
|
+
systemPrompt: string,
|
|
188
|
+
allowedToolNames: readonly string[],
|
|
189
|
+
): SanitizeSystemPromptResult {
|
|
190
|
+
const allowedTools = new Set(
|
|
191
|
+
allowedToolNames.map((toolName) => toolName.trim()).filter(Boolean),
|
|
192
|
+
);
|
|
193
|
+
const normalizedLines = normalizePrompt(systemPrompt).split("\n");
|
|
194
|
+
const removedToolsSection = removeLineSection(
|
|
195
|
+
normalizedLines,
|
|
196
|
+
findSection(normalizedLines, AVAILABLE_TOOLS_SECTION_HEADER),
|
|
197
|
+
);
|
|
198
|
+
const sanitizedGuidelines = sanitizeGuidelinesSection(
|
|
199
|
+
removedToolsSection.lines,
|
|
200
|
+
allowedTools,
|
|
201
|
+
);
|
|
202
|
+
const removed = removedToolsSection.removed || sanitizedGuidelines.removed;
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
prompt: removed
|
|
206
|
+
? collapseExtraBlankLines(sanitizedGuidelines.lines.join("\n"))
|
|
207
|
+
: systemPrompt,
|
|
208
|
+
removed,
|
|
209
|
+
};
|
|
210
|
+
}
|