pi-permission-system 0.4.2 → 0.4.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,24 @@ 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
+ ## [Unreleased]
9
+
10
+ ## [0.4.3] - 2026-04-22
11
+
12
+ ### Added
13
+ - Added `src/skill-prompt-sanitizer.ts` to centralize skill prompt parsing and sanitization helpers
14
+
15
+ ### Changed
16
+ - Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to `^0.68.1`
17
+ - Refactored `src/index.ts` to reuse shared skill prompt sanitizer helpers
18
+
19
+ ### Fixed
20
+ - Skill prompt sanitization now removes denied skills from every `<available_skills>` block and drops fully denied blocks from the prompt
21
+ - Hidden skills are no longer retained for skill path matching after sanitization
22
+
23
+ ### Tests
24
+ - Added regression coverage for multi-block `<available_skills>` parsing, sanitization, and visible-skill path matching
25
+
8
26
  ## [0.4.2] - 2026-04-20
9
27
 
10
28
  ### Added
package/package.json CHANGED
@@ -1,65 +1,65 @@
1
- {
2
- "name": "pi-permission-system",
3
- "version": "0.4.2",
4
- "description": "Permission enforcement extension for the Pi coding agent.",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "tests",
14
- "config.json",
15
- "config/config.example.json",
16
- "schemas/permissions.schema.json",
17
- "README.md",
18
- "CHANGELOG.md",
19
- "LICENSE"
20
- ],
21
- "scripts": {
22
- "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
23
- "lint": "npm run build",
24
- "test": "bun ./tests/permission-system.test.ts && bun ./tests/config-modal.test.ts",
25
- "check": "npm run lint && npm run test"
26
- },
27
- "keywords": [
28
- "pi-package",
29
- "pi",
30
- "pi-extension",
31
- "pi-coding-agent",
32
- "coding-agent",
33
- "permissions",
34
- "policy",
35
- "access-control",
36
- "authorization",
37
- "security"
38
- ],
39
- "author": "MasuRii",
40
- "license": "MIT",
41
- "repository": {
42
- "type": "git",
43
- "url": "git+https://github.com/MasuRii/pi-permission-system.git"
44
- },
45
- "homepage": "https://github.com/MasuRii/pi-permission-system#readme",
46
- "bugs": {
47
- "url": "https://github.com/MasuRii/pi-permission-system/issues"
48
- },
49
- "engines": {
50
- "node": ">=20"
51
- },
52
- "publishConfig": {
53
- "access": "public"
54
- },
55
- "pi": {
56
- "extensions": [
57
- "./index.ts"
58
- ]
59
- },
60
- "peerDependencies": {
61
- "@mariozechner/pi-coding-agent": "^0.67.68",
62
- "@mariozechner/pi-tui": "^0.67.68",
63
- "@sinclair/typebox": "^0.34.49"
64
- }
65
- }
1
+ {
2
+ "name": "pi-permission-system",
3
+ "version": "0.4.3",
4
+ "description": "Permission enforcement extension for the Pi coding agent.",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "tests",
14
+ "config.json",
15
+ "config/config.example.json",
16
+ "schemas/permissions.schema.json",
17
+ "README.md",
18
+ "CHANGELOG.md",
19
+ "LICENSE"
20
+ ],
21
+ "scripts": {
22
+ "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
23
+ "lint": "npm run build",
24
+ "test": "bun ./tests/permission-system.test.ts && bun ./tests/config-modal.test.ts",
25
+ "check": "npm run lint && npm run test"
26
+ },
27
+ "keywords": [
28
+ "pi-package",
29
+ "pi",
30
+ "pi-extension",
31
+ "pi-coding-agent",
32
+ "coding-agent",
33
+ "permissions",
34
+ "policy",
35
+ "access-control",
36
+ "authorization",
37
+ "security"
38
+ ],
39
+ "author": "MasuRii",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/MasuRii/pi-permission-system.git"
44
+ },
45
+ "homepage": "https://github.com/MasuRii/pi-permission-system#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/MasuRii/pi-permission-system/issues"
48
+ },
49
+ "engines": {
50
+ "node": ">=20"
51
+ },
52
+ "publishConfig": {
53
+ "access": "public"
54
+ },
55
+ "pi": {
56
+ "extensions": [
57
+ "./index.ts"
58
+ ]
59
+ },
60
+ "peerDependencies": {
61
+ "@mariozechner/pi-coding-agent": "^0.68.1",
62
+ "@mariozechner/pi-tui": "^0.68.1",
63
+ "@sinclair/typebox": "^0.34.49"
64
+ }
65
+ }
package/src/index.ts CHANGED
@@ -36,9 +36,14 @@ import {
36
36
  type PermissionForwardingLocation,
37
37
  } from "./permission-forwarding.js";
38
38
  import { PermissionManager } from "./permission-manager.js";
39
+ import {
40
+ findSkillPathMatch,
41
+ resolveSkillPromptEntries,
42
+ type SkillPromptEntry,
43
+ } from "./skill-prompt-sanitizer.js";
39
44
  import { sanitizeAvailableToolsSection } from "./system-prompt-sanitizer.js";
40
45
  import { checkRequestedToolRegistration, getToolNameFromValue } from "./tool-registry.js";
41
- import type { PermissionCheckResult, PermissionState } from "./types.js";
46
+ import type { PermissionCheckResult } from "./types.js";
42
47
  import { PERMISSION_SYSTEM_STATUS_KEY, syncPermissionSystemStatus } from "./status.js";
43
48
  import { canResolveAskPermissionRequest, shouldAutoApprovePermissionState } from "./yolo-mode.js";
44
49
 
@@ -47,29 +52,8 @@ const SESSIONS_DIR = join(PI_AGENT_DIR, "sessions");
47
52
  const SUBAGENT_SESSIONS_DIR = join(PI_AGENT_DIR, "subagent-sessions");
48
53
  const PERMISSION_FORWARDING_DIR = join(SESSIONS_DIR, "permission-forwarding");
49
54
 
50
- const AVAILABLE_SKILLS_OPEN_TAG = "<available_skills>";
51
- const AVAILABLE_SKILLS_CLOSE_TAG = "</available_skills>";
52
- const SKILL_BLOCK_PATTERN = "<skill>([\\s\\S]*?)<\\/skill>";
53
- const SKILL_NAME_REGEX = /<name>([\s\S]*?)<\/name>/;
54
- const SKILL_DESCRIPTION_REGEX = /<description>([\s\S]*?)<\/description>/;
55
- const SKILL_LOCATION_REGEX = /<location>([\s\S]*?)<\/location>/;
56
55
  const ACTIVE_AGENT_TAG_REGEX = /<active_agent\s+name=["']([^"']+)["'][^>]*>/i;
57
56
 
58
- type SkillPromptEntry = {
59
- name: string;
60
- description: string;
61
- location: string;
62
- state: PermissionState;
63
- normalizedLocation: string;
64
- normalizedBaseDir: string;
65
- };
66
-
67
- type SkillPromptSection = {
68
- start: number;
69
- end: number;
70
- entries: Array<{ name: string; description: string; location: string }>;
71
- };
72
-
73
57
  type PermissionRequestSource = "tool_call" | "skill_input" | "skill_read";
74
58
  type PermissionRequestState = "waiting" | "approved" | "denied";
75
59
 
@@ -127,24 +111,6 @@ function writeReviewLog(event: string, details: Record<string, unknown> = {}): v
127
111
  }
128
112
  }
129
113
 
130
- function decodeXml(value: string): string {
131
- return value
132
- .replace(/&lt;/g, "<")
133
- .replace(/&gt;/g, ">")
134
- .replace(/&quot;/g, '"')
135
- .replace(/&apos;/g, "'")
136
- .replace(/&amp;/g, "&");
137
- }
138
-
139
- function encodeXml(value: string): string {
140
- return value
141
- .replace(/&/g, "&amp;")
142
- .replace(/</g, "&lt;")
143
- .replace(/>/g, "&gt;")
144
- .replace(/"/g, "&quot;")
145
- .replace(/'/g, "&apos;");
146
- }
147
-
148
114
  function normalizePathForComparison(pathValue: string, cwd: string): string {
149
115
  const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
150
116
  if (!trimmed) {
@@ -177,122 +143,6 @@ function isPathWithinDirectory(pathValue: string, directory: string): boolean {
177
143
  return pathValue.startsWith(prefix);
178
144
  }
179
145
 
180
- function parseSkillPromptSection(prompt: string): SkillPromptSection | null {
181
- const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG);
182
- if (start === -1) {
183
- return null;
184
- }
185
-
186
- const closeStart = prompt.indexOf(AVAILABLE_SKILLS_CLOSE_TAG, start + AVAILABLE_SKILLS_OPEN_TAG.length);
187
- if (closeStart === -1) {
188
- return null;
189
- }
190
-
191
- const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
192
- const sectionBody = prompt.slice(start + AVAILABLE_SKILLS_OPEN_TAG.length, closeStart);
193
- const entries: Array<{ name: string; description: string; location: string }> = [];
194
-
195
- const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
196
- for (const match of sectionBody.matchAll(skillBlockRegex)) {
197
- const block = match[1];
198
- const nameMatch = block.match(SKILL_NAME_REGEX);
199
- const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
200
- const locationMatch = block.match(SKILL_LOCATION_REGEX);
201
-
202
- if (!nameMatch || !descriptionMatch || !locationMatch) {
203
- continue;
204
- }
205
-
206
- const name = decodeXml(nameMatch[1].trim());
207
- const description = decodeXml(descriptionMatch[1].trim());
208
- const location = decodeXml(locationMatch[1].trim());
209
-
210
- if (!name || !location) {
211
- continue;
212
- }
213
-
214
- entries.push({ name, description, location });
215
- }
216
-
217
- return {
218
- start,
219
- end,
220
- entries,
221
- };
222
- }
223
-
224
- function resolveSkillPromptEntries(
225
- prompt: string,
226
- permissionManager: PermissionManager,
227
- agentName: string | null,
228
- cwd: string,
229
- ): { prompt: string; entries: SkillPromptEntry[] } {
230
- const section = parseSkillPromptSection(prompt);
231
- if (!section) {
232
- return { prompt, entries: [] };
233
- }
234
-
235
- const resolvedEntries: SkillPromptEntry[] = section.entries.map((entry) => {
236
- const check = permissionManager.checkPermission("skill", { name: entry.name }, agentName ?? undefined);
237
- const state: PermissionState = check.state;
238
- return {
239
- name: entry.name,
240
- description: entry.description,
241
- location: entry.location,
242
- state,
243
- normalizedLocation: normalizePathForComparison(entry.location, cwd),
244
- normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
245
- };
246
- });
247
-
248
- const visibleEntries = resolvedEntries.filter((entry) => entry.state !== "deny");
249
- if (visibleEntries.length === resolvedEntries.length) {
250
- return { prompt, entries: resolvedEntries };
251
- }
252
-
253
- const replacement = [
254
- AVAILABLE_SKILLS_OPEN_TAG,
255
- ...visibleEntries.flatMap((entry) => [
256
- " <skill>",
257
- ` <name>${encodeXml(entry.name)}</name>`,
258
- ` <description>${encodeXml(entry.description)}</description>`,
259
- ` <location>${encodeXml(entry.location)}</location>`,
260
- " </skill>",
261
- ]),
262
- AVAILABLE_SKILLS_CLOSE_TAG,
263
- ].join("\n");
264
-
265
- return {
266
- prompt: `${prompt.slice(0, section.start)}${replacement}${prompt.slice(section.end)}`,
267
- entries: resolvedEntries,
268
- };
269
- }
270
-
271
- function findSkillPathMatch(normalizedPath: string, entries: readonly SkillPromptEntry[]): SkillPromptEntry | null {
272
- if (!normalizedPath || entries.length === 0) {
273
- return null;
274
- }
275
-
276
- for (const entry of entries) {
277
- if (entry.normalizedLocation && normalizedPath === entry.normalizedLocation) {
278
- return entry;
279
- }
280
- }
281
-
282
- let bestMatch: SkillPromptEntry | null = null;
283
- for (const entry of entries) {
284
- if (!entry.normalizedBaseDir || !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)) {
285
- continue;
286
- }
287
-
288
- if (!bestMatch || entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length) {
289
- bestMatch = entry;
290
- }
291
- }
292
-
293
- return bestMatch;
294
- }
295
-
296
146
  function extractSkillNameFromInput(text: string): string | null {
297
147
  const trimmed = text.trim();
298
148
  if (!trimmed.startsWith("/skill:")) {
@@ -0,0 +1,289 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join, normalize, resolve, sep } from "node:path";
3
+
4
+ import { 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(/&lt;/g, "<")
38
+ .replace(/&gt;/g, ">")
39
+ .replace(/&quot;/g, '"')
40
+ .replace(/&apos;/g, "'")
41
+ .replace(/&amp;/g, "&");
42
+ }
43
+
44
+ function encodeXml(value: string): string {
45
+ return value
46
+ .replace(/&/g, "&amp;")
47
+ .replace(/</g, "&lt;")
48
+ .replace(/>/g, "&gt;")
49
+ .replace(/"/g, "&quot;")
50
+ .replace(/'/g, "&apos;");
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 (normalizedPath.startsWith("~/") || normalizedPath.startsWith("~\\")) {
64
+ normalizedPath = join(homedir(), normalizedPath.slice(2));
65
+ }
66
+
67
+ const absolutePath = resolve(cwd, normalizedPath);
68
+ const normalizedAbsolutePath = normalize(absolutePath);
69
+ return process.platform === "win32" ? normalizedAbsolutePath.toLowerCase() : normalizedAbsolutePath;
70
+ }
71
+
72
+ function isPathWithinDirectory(pathValue: string, directory: string): boolean {
73
+ if (!pathValue || !directory) {
74
+ return false;
75
+ }
76
+
77
+ if (pathValue === directory) {
78
+ return true;
79
+ }
80
+
81
+ const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
82
+ return pathValue.startsWith(prefix);
83
+ }
84
+
85
+ function parseSkillEntries(sectionBody: string): ParsedSkillPromptEntry[] {
86
+ const entries: ParsedSkillPromptEntry[] = [];
87
+ const skillBlockRegex = new RegExp(SKILL_BLOCK_PATTERN, "g");
88
+
89
+ for (const match of sectionBody.matchAll(skillBlockRegex)) {
90
+ const block = match[1];
91
+ const nameMatch = block.match(SKILL_NAME_REGEX);
92
+ const descriptionMatch = block.match(SKILL_DESCRIPTION_REGEX);
93
+ const locationMatch = block.match(SKILL_LOCATION_REGEX);
94
+
95
+ if (!nameMatch || !descriptionMatch || !locationMatch) {
96
+ continue;
97
+ }
98
+
99
+ const name = decodeXml(nameMatch[1].trim());
100
+ const description = decodeXml(descriptionMatch[1].trim());
101
+ const location = decodeXml(locationMatch[1].trim());
102
+
103
+ if (!name || !location) {
104
+ continue;
105
+ }
106
+
107
+ entries.push({ name, description, location });
108
+ }
109
+
110
+ return entries;
111
+ }
112
+
113
+ export function parseSkillPromptSection(prompt: string): SkillPromptSection | null {
114
+ const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG);
115
+ if (start === -1) {
116
+ return null;
117
+ }
118
+
119
+ const closeStart = prompt.indexOf(AVAILABLE_SKILLS_CLOSE_TAG, start + AVAILABLE_SKILLS_OPEN_TAG.length);
120
+ if (closeStart === -1) {
121
+ return null;
122
+ }
123
+
124
+ const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
125
+ const sectionBody = prompt.slice(start + AVAILABLE_SKILLS_OPEN_TAG.length, closeStart);
126
+
127
+ return {
128
+ start,
129
+ end,
130
+ entries: parseSkillEntries(sectionBody),
131
+ };
132
+ }
133
+
134
+ export function parseAllSkillPromptSections(prompt: string): SkillPromptSection[] {
135
+ const sections: SkillPromptSection[] = [];
136
+ let searchStart = 0;
137
+
138
+ while (searchStart < prompt.length) {
139
+ const start = prompt.indexOf(AVAILABLE_SKILLS_OPEN_TAG, searchStart);
140
+ if (start === -1) {
141
+ break;
142
+ }
143
+
144
+ const closeStart = prompt.indexOf(AVAILABLE_SKILLS_CLOSE_TAG, start + AVAILABLE_SKILLS_OPEN_TAG.length);
145
+ if (closeStart === -1) {
146
+ break;
147
+ }
148
+
149
+ const end = closeStart + AVAILABLE_SKILLS_CLOSE_TAG.length;
150
+ const sectionBody = prompt.slice(start + AVAILABLE_SKILLS_OPEN_TAG.length, closeStart);
151
+ sections.push({
152
+ start,
153
+ end,
154
+ entries: parseSkillEntries(sectionBody),
155
+ });
156
+ searchStart = end;
157
+ }
158
+
159
+ return sections;
160
+ }
161
+
162
+ function resolvePermissionState(
163
+ skillName: string,
164
+ permissionManager: PermissionManager,
165
+ agentName: string | null,
166
+ cache: Map<string, PermissionState>,
167
+ ): PermissionState {
168
+ const cachedState = cache.get(skillName);
169
+ if (cachedState) {
170
+ return cachedState;
171
+ }
172
+
173
+ const state = permissionManager.checkPermission("skill", { name: skillName }, agentName ?? undefined).state;
174
+ cache.set(skillName, state);
175
+ return state;
176
+ }
177
+
178
+ function createResolvedSkillEntry(
179
+ entry: ParsedSkillPromptEntry,
180
+ state: PermissionState,
181
+ cwd: string,
182
+ ): SkillPromptEntry {
183
+ return {
184
+ name: entry.name,
185
+ description: entry.description,
186
+ location: entry.location,
187
+ state,
188
+ normalizedLocation: normalizePathForComparison(entry.location, cwd),
189
+ normalizedBaseDir: normalizePathForComparison(dirname(entry.location), cwd),
190
+ };
191
+ }
192
+
193
+ function renderAvailableSkillsSection(entries: readonly SkillPromptEntry[]): string {
194
+ return [
195
+ AVAILABLE_SKILLS_OPEN_TAG,
196
+ ...entries.flatMap((entry) => [
197
+ " <skill>",
198
+ ` <name>${encodeXml(entry.name)}</name>`,
199
+ ` <description>${encodeXml(entry.description)}</description>`,
200
+ ` <location>${encodeXml(entry.location)}</location>`,
201
+ " </skill>",
202
+ ]),
203
+ AVAILABLE_SKILLS_CLOSE_TAG,
204
+ ].join("\n");
205
+ }
206
+
207
+ function removePromptRange(prompt: string, start: number, end: number): string {
208
+ const beforeSection = prompt.slice(0, start).replace(/\n+$/, "");
209
+ const afterSection = prompt.slice(end);
210
+ return `${beforeSection}${afterSection}`;
211
+ }
212
+
213
+ export function resolveSkillPromptEntries(
214
+ prompt: string,
215
+ permissionManager: PermissionManager,
216
+ agentName: string | null,
217
+ cwd: string,
218
+ ): { prompt: string; entries: SkillPromptEntry[] } {
219
+ const sections = parseAllSkillPromptSections(prompt);
220
+ if (sections.length === 0) {
221
+ return { prompt, entries: [] };
222
+ }
223
+
224
+ const permissionCache = new Map<string, PermissionState>();
225
+ const visibleEntries: SkillPromptEntry[] = [];
226
+ const replacements: Array<{ start: number; end: number; content: string }> = [];
227
+
228
+ for (const section of sections) {
229
+ const resolvedEntries = section.entries.map((entry) => {
230
+ const state = resolvePermissionState(entry.name, permissionManager, agentName, permissionCache);
231
+ return createResolvedSkillEntry(entry, state, cwd);
232
+ });
233
+
234
+ const visibleSectionEntries = resolvedEntries.filter((entry) => entry.state !== "deny");
235
+ visibleEntries.push(...visibleSectionEntries);
236
+
237
+ if (visibleSectionEntries.length === resolvedEntries.length) {
238
+ continue;
239
+ }
240
+
241
+ replacements.push({
242
+ start: section.start,
243
+ end: section.end,
244
+ content: visibleSectionEntries.length > 0 ? renderAvailableSkillsSection(visibleSectionEntries) : "",
245
+ });
246
+ }
247
+
248
+ if (replacements.length === 0) {
249
+ return { prompt, entries: visibleEntries };
250
+ }
251
+
252
+ let sanitizedPrompt = prompt;
253
+ for (let i = replacements.length - 1; i >= 0; i--) {
254
+ const replacement = replacements[i];
255
+ sanitizedPrompt = replacement.content.length > 0
256
+ ? `${sanitizedPrompt.slice(0, replacement.start)}${replacement.content}${sanitizedPrompt.slice(replacement.end)}`
257
+ : removePromptRange(sanitizedPrompt, replacement.start, replacement.end);
258
+ }
259
+
260
+ return {
261
+ prompt: sanitizedPrompt,
262
+ entries: visibleEntries,
263
+ };
264
+ }
265
+
266
+ export function findSkillPathMatch(normalizedPath: string, entries: readonly SkillPromptEntry[]): SkillPromptEntry | null {
267
+ if (!normalizedPath || entries.length === 0) {
268
+ return null;
269
+ }
270
+
271
+ for (const entry of entries) {
272
+ if (entry.normalizedLocation && normalizedPath === entry.normalizedLocation) {
273
+ return entry;
274
+ }
275
+ }
276
+
277
+ let bestMatch: SkillPromptEntry | null = null;
278
+ for (const entry of entries) {
279
+ if (!entry.normalizedBaseDir || !isPathWithinDirectory(normalizedPath, entry.normalizedBaseDir)) {
280
+ continue;
281
+ }
282
+
283
+ if (!bestMatch || entry.normalizedBaseDir.length > bestMatch.normalizedBaseDir.length) {
284
+ bestMatch = entry;
285
+ }
286
+ }
287
+
288
+ return bestMatch;
289
+ }
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
 
6
6
  import { BashFilter } from "../src/bash-filter.js";
7
7
  import {
@@ -17,6 +17,11 @@ import {
17
17
  resolvePermissionForwardingTargetSessionId,
18
18
  } from "../src/permission-forwarding.js";
19
19
  import { PermissionManager } from "../src/permission-manager.js";
20
+ import {
21
+ parseAllSkillPromptSections,
22
+ resolveSkillPromptEntries,
23
+ findSkillPathMatch,
24
+ } from "../src/skill-prompt-sanitizer.js";
20
25
  import { checkRequestedToolRegistration, getToolNameFromValue } from "../src/tool-registry.js";
21
26
  import { getPermissionSystemStatus } from "../src/status.js";
22
27
  import { sanitizeAvailableToolsSection } from "../src/system-prompt-sanitizer.js";
@@ -1414,4 +1419,123 @@ runTest("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () =
1414
1419
  }
1415
1420
  });
1416
1421
 
1422
+ // ---------------------------------------------------------------------------
1423
+ // Skill prompt sanitization - multi-block regression tests
1424
+ // ---------------------------------------------------------------------------
1425
+
1426
+ runTest("parseAllSkillPromptSections finds every available_skills block", () => {
1427
+ const prompt = [
1428
+ "Some preamble",
1429
+ "<available_skills>",
1430
+ " <skill>",
1431
+ " <name>skill-one</name>",
1432
+ " <description>First skill</description>",
1433
+ " <location>/path/to/one</location>",
1434
+ " </skill>",
1435
+ "</available_skills>",
1436
+ "Some content between",
1437
+ "<available_skills>",
1438
+ " <skill>",
1439
+ " <name>skill-two</name>",
1440
+ " <description>Second skill</description>",
1441
+ " <location>/path/to/two</location>",
1442
+ " </skill>",
1443
+ "</available_skills>",
1444
+ "Footer",
1445
+ ].join("\n");
1446
+
1447
+ const sections = parseAllSkillPromptSections(prompt);
1448
+
1449
+ assert.equal(sections.length, 2);
1450
+ assert.equal(sections[0].entries[0]?.name, "skill-one");
1451
+ assert.equal(sections[1].entries[0]?.name, "skill-two");
1452
+ });
1453
+
1454
+ runTest("REGRESSION: resolveSkillPromptEntries sanitizes every available_skills block", () => {
1455
+ const { manager, cleanup } = createManager({
1456
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "ask", special: "ask" },
1457
+ skills: {
1458
+ "denied-skill": "deny",
1459
+ },
1460
+ });
1461
+
1462
+ try {
1463
+ const prompt = [
1464
+ "System prompt start",
1465
+ "<available_skills>",
1466
+ " <skill>",
1467
+ " <name>visible-skill</name>",
1468
+ " <description>Allowed skill</description>",
1469
+ " <location>/skills/visible/index.ts</location>",
1470
+ " </skill>",
1471
+ " <skill>",
1472
+ " <name>denied-skill</name>",
1473
+ " <description>Denied in first block</description>",
1474
+ " <location>/skills/blocked/one.ts</location>",
1475
+ " </skill>",
1476
+ "</available_skills>",
1477
+ "Agent identity section",
1478
+ "<available_skills>",
1479
+ " <skill>",
1480
+ " <name>denied-skill</name>",
1481
+ " <description>Denied in second block</description>",
1482
+ " <location>/skills/blocked/two.ts</location>",
1483
+ " </skill>",
1484
+ "</available_skills>",
1485
+ "System prompt end",
1486
+ ].join("\n");
1487
+
1488
+ const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
1489
+
1490
+ assert.equal(result.prompt.includes("denied-skill"), false, "Denied skill should be removed from every block");
1491
+ assert.equal(result.prompt.includes("visible-skill"), true, "Visible skill should remain in the prompt");
1492
+ assert.equal((result.prompt.match(/<available_skills>/g) || []).length, 1, "Fully denied blocks should be removed");
1493
+ assert.deepEqual(result.entries.map((entry) => entry.name), ["visible-skill"], "Tracked skill entries should exclude denied skills");
1494
+ } finally {
1495
+ cleanup();
1496
+ }
1497
+ });
1498
+
1499
+ runTest("REGRESSION: resolveSkillPromptEntries keeps only visible skills available for path matching", () => {
1500
+ const { manager, cleanup } = createManager({
1501
+ defaultPolicy: { tools: "ask", bash: "ask", mcp: "ask", skills: "ask", special: "ask" },
1502
+ skills: {
1503
+ "blocked-skill": "deny",
1504
+ },
1505
+ });
1506
+
1507
+ try {
1508
+ const prompt = [
1509
+ "System prompt start",
1510
+ "<available_skills>",
1511
+ " <skill>",
1512
+ " <name>blocked-skill</name>",
1513
+ " <description>Blocked skill</description>",
1514
+ " <location>@./skills/blocked/entry.ts</location>",
1515
+ " </skill>",
1516
+ "</available_skills>",
1517
+ "Middle section",
1518
+ "<available_skills>",
1519
+ " <skill>",
1520
+ " <name>visible-skill</name>",
1521
+ " <description>Visible skill</description>",
1522
+ " <location>@./skills/visible/entry.ts</location>",
1523
+ " </skill>",
1524
+ "</available_skills>",
1525
+ "System prompt end",
1526
+ ].join("\n");
1527
+
1528
+ const result = resolveSkillPromptEntries(prompt, manager, null, "/cwd");
1529
+ const visiblePath = resolve("/cwd", "./skills/visible/file.ts");
1530
+ const blockedPath = resolve("/cwd", "./skills/blocked/file.ts");
1531
+ const matchedVisibleSkill = findSkillPathMatch(process.platform === "win32" ? visiblePath.toLowerCase() : visiblePath, result.entries);
1532
+ const matchedBlockedSkill = findSkillPathMatch(process.platform === "win32" ? blockedPath.toLowerCase() : blockedPath, result.entries);
1533
+
1534
+ assert.equal(matchedVisibleSkill?.name, "visible-skill");
1535
+ assert.equal(matchedBlockedSkill, null, "Denied skills should not remain in tracked entries");
1536
+ } finally {
1537
+ cleanup();
1538
+ }
1539
+ });
1540
+
1417
1541
  console.log("All permission system tests passed.");