pi-permission-system 0.4.2 → 0.4.4
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 +24 -0
- package/README.md +31 -6
- package/config.json +1 -1
- package/package.json +3 -3
- package/src/index.ts +266 -153
- package/src/skill-prompt-sanitizer.ts +289 -0
- package/tests/permission-system.test.ts +542 -4
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,30 @@ 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.4] - 2026-04-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Added runtime enforcement for the `external_directory` special permission on path-bearing tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) before normal tool permission checks (thanks to @gotgenes for PR #9)
|
|
14
|
+
- Added readable `ask` prompt summaries for built-in file tools and bounded input previews for generic extension tools so users can make informed approval decisions (thanks to @beantownbytes for PR #8)
|
|
15
|
+
- Added `skill-prompt-sanitizer.ts` to parse and sanitize every `<available_skills>` block, including prompts with multiple skill sections
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Updated `@mariozechner/pi-coding-agent` and `@mariozechner/pi-tui` peer dependencies to `^0.70.2`
|
|
19
|
+
- Refactored skill prompt filtering out of `src/index.ts` into a dedicated module for clearer ownership and reuse
|
|
20
|
+
- Permission prompts for `edit`, `write`, `read`, `find`, `grep`, and `ls` now show concise path/action summaries instead of raw multiline JSON
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Denied skills are now removed from all available-skill prompt blocks instead of only the first block
|
|
24
|
+
- Denied skill entries are no longer retained for later skill-read path matching after prompt sanitization
|
|
25
|
+
- External path access now honors `special.external_directory: deny` and blocks `ask` decisions when no UI or forwarding channel is available
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
- Added runtime `tool_call` coverage for external directory deny, ask-without-UI, ask approval, internal path allow, and optional path omission
|
|
29
|
+
- Added prompt regression coverage for generic tool input previews and readable built-in file-tool approval summaries
|
|
30
|
+
- Added multi-block skill prompt sanitizer regression coverage
|
|
31
|
+
|
|
8
32
|
## [0.4.2] - 2026-04-20
|
|
9
33
|
|
|
10
34
|
### Added
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 🔐 pi-permission-system
|
|
2
2
|
|
|
3
|
-
[](package.json)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
6
|
Permission enforcement extension for the Pi coding agent that provides centralized, deterministic permission gates for tool, bash, MCP, skill, and special operations.
|
|
@@ -12,15 +12,16 @@ Permission enforcement extension for the Pi coding agent that provides centraliz
|
|
|
12
12
|
|
|
13
13
|
- **Tool Filtering** — Hides disallowed tools from the agent before it starts (reduces "try another tool" behavior)
|
|
14
14
|
- **System Prompt Sanitization** — Removes denied tool entries from the `Available tools:` system prompt section so the agent only sees tools it can actually call
|
|
15
|
-
- **Runtime Enforcement** — Blocks/asks/allows at tool call time with UI confirmation dialogs
|
|
15
|
+
- **Runtime Enforcement** — Blocks/asks/allows at tool call time with UI confirmation dialogs and readable approval summaries
|
|
16
16
|
- **Bash Command Control** — Wildcard pattern matching for granular bash command permissions
|
|
17
17
|
- **MCP Access Control** — Server and tool-level permissions for MCP operations
|
|
18
|
-
- **Skill Protection** — Controls which skills can be loaded or read from disk
|
|
18
|
+
- **Skill Protection** — Controls which skills can be loaded or read from disk, including multi-block prompt sanitization
|
|
19
19
|
- **Per-Agent Overrides** — Agent-specific permission policies via YAML frontmatter
|
|
20
20
|
- **Subagent Permission Forwarding** — Forwards `ask` confirmations from non-UI subagents back to the main interactive session
|
|
21
21
|
- **File-Based Review Logging** — Writes permission request/denial review entries to a file by default for later auditing
|
|
22
22
|
- **Optional Debug Logging** — Keeps verbose extension diagnostics in a separate file when enabled in `config.json`
|
|
23
23
|
- **JSON Schema Validation** — Full schema for editor autocomplete and config validation
|
|
24
|
+
- **External Directory Guard** — Enforces `special.external_directory` for path-bearing file tools that target paths outside the active working directory
|
|
24
25
|
|
|
25
26
|
## Installation
|
|
26
27
|
|
|
@@ -84,6 +85,8 @@ The extension integrates via Pi's lifecycle hooks:
|
|
|
84
85
|
- The `Available tools:` system prompt section is rewritten to match the filtered active tool set
|
|
85
86
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name instead of private built-in hardcodes
|
|
86
87
|
- When a subagent hits an `ask` permission without direct UI access, the request can be forwarded to the main interactive session for confirmation
|
|
88
|
+
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries instead of raw multiline JSON
|
|
89
|
+
- Path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) evaluate `special.external_directory` before their normal tool permission when an explicit path points outside `ctx.cwd`
|
|
87
90
|
|
|
88
91
|
## Configuration
|
|
89
92
|
|
|
@@ -122,7 +125,7 @@ The policy file is a JSON object with these sections:
|
|
|
122
125
|
| `bash` | Command pattern permissions |
|
|
123
126
|
| `mcp` | MCP server/tool permissions for calls routed through a registered `mcp` tool |
|
|
124
127
|
| `skills` | Skill name pattern permissions |
|
|
125
|
-
| `special` | Reserved permission checks
|
|
128
|
+
| `special` | Reserved permission checks such as external directory access |
|
|
126
129
|
|
|
127
130
|
> **Note:** Trailing commas are **not** supported. If parsing fails, the extension falls back to `ask` for all categories.
|
|
128
131
|
|
|
@@ -310,7 +313,7 @@ Reserved permission checks:
|
|
|
310
313
|
| Key | Description |
|
|
311
314
|
|----------------------|------------------------------------------|
|
|
312
315
|
| `doom_loop` | Controls doom loop detection behavior |
|
|
313
|
-
| `external_directory` |
|
|
316
|
+
| `external_directory` | Enforces ask/allow/deny decisions for path-bearing built-in tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) when they target paths outside the active working directory |
|
|
314
317
|
| `tool_call_limit` | *(schema only, not enforced yet)* |
|
|
315
318
|
|
|
316
319
|
```jsonc
|
|
@@ -322,6 +325,8 @@ Reserved permission checks:
|
|
|
322
325
|
}
|
|
323
326
|
```
|
|
324
327
|
|
|
328
|
+
`external_directory` is evaluated before the normal tool permission check. For example, `tools.read: "allow"` can permit ordinary reads while `special.external_directory: "ask"` still requires confirmation before reading `../outside.txt` or an absolute path outside `ctx.cwd`. Optional-path search tools (`find`, `grep`, `ls`) skip this check when no `path` is provided because they default to the active working directory.
|
|
329
|
+
|
|
325
330
|
---
|
|
326
331
|
|
|
327
332
|
## Common Recipes
|
|
@@ -390,6 +395,21 @@ permission:
|
|
|
390
395
|
|
|
391
396
|
## Technical Details
|
|
392
397
|
|
|
398
|
+
### Permission Prompt Summaries
|
|
399
|
+
|
|
400
|
+
When a tool permission resolves to `ask`, the prompt is designed to be readable enough for an informed approval decision:
|
|
401
|
+
|
|
402
|
+
- `bash` prompts show the command and matched bash pattern when available.
|
|
403
|
+
- `mcp` prompts show the derived MCP target and matched rule when available.
|
|
404
|
+
- Built-in file tools show concise summaries, such as the target path and edit/write line counts, instead of raw multiline JSON.
|
|
405
|
+
- Unknown or third-party extension tools show a bounded single-line JSON preview of the input so users are not asked to approve a blind tool name.
|
|
406
|
+
|
|
407
|
+
Example edit approval prompt:
|
|
408
|
+
|
|
409
|
+
```text
|
|
410
|
+
Current agent requested tool 'edit' for '.gitignore' (1 replacement: edit #1 replaces 5 lines with 2 lines). Allow this call?
|
|
411
|
+
```
|
|
412
|
+
|
|
393
413
|
### Subagent Permission Forwarding
|
|
394
414
|
|
|
395
415
|
When a delegated or routed subagent runs without direct UI access, `ask` permissions can still be enforced by forwarding the confirmation request through Pi session directories. The main interactive session polls for forwarded requests, shows the confirmation prompt, writes the response, and the subagent resumes once that decision is available.
|
|
@@ -413,10 +433,11 @@ Actual global logs directory: $PI_CODING_AGENT_DIR/extensions/pi-permission-syst
|
|
|
413
433
|
```
|
|
414
434
|
index.ts → Root Pi entrypoint shim
|
|
415
435
|
src/
|
|
416
|
-
├── index.ts → Extension bootstrap, permission checks, review logging, reload handling, and subagent forwarding
|
|
436
|
+
├── index.ts → Extension bootstrap, permission checks, readable prompts, review logging, reload handling, and subagent forwarding
|
|
417
437
|
├── extension-config.ts → Extension-local config loading and default creation
|
|
418
438
|
├── logging.ts → File-only debug/review logging helpers
|
|
419
439
|
├── permission-manager.ts → Global/project policy loading, merging, and resolution with caching
|
|
440
|
+
├── skill-prompt-sanitizer.ts → Skill prompt parsing, multi-block sanitization, and skill-read path matching
|
|
420
441
|
├── bash-filter.ts → Bash command wildcard pattern matching
|
|
421
442
|
├── wildcard-matcher.ts → Shared wildcard pattern compilation and matching
|
|
422
443
|
├── common.ts → Shared utilities (YAML parsing, type guards, etc.)
|
|
@@ -442,6 +463,7 @@ The extension uses a modular architecture with shared utilities:
|
|
|
442
463
|
| `wildcard-matcher.ts` | Compile-once wildcard patterns with specificity sorting: `compileWildcardPatterns()`, `findCompiledWildcardMatch()` |
|
|
443
464
|
| `permission-manager.ts` | Policy resolution with file stamp caching for performance |
|
|
444
465
|
| `bash-filter.ts` | Uses shared wildcard matcher for bash command patterns |
|
|
466
|
+
| `skill-prompt-sanitizer.ts` | Parses all available skill prompt blocks, removes denied skills, and tracks visible skill paths for read protection |
|
|
445
467
|
|
|
446
468
|
#### Performance Optimizations
|
|
447
469
|
|
|
@@ -457,6 +479,7 @@ The extension uses a modular architecture with shared utilities:
|
|
|
457
479
|
- Agent calling tools it shouldn't use (e.g., `write`, dangerous `bash`)
|
|
458
480
|
- Tool switching attempts (calling non-existent tool names)
|
|
459
481
|
- Accidental escalation via skill loading
|
|
482
|
+
- Unapproved path-bearing tool access outside the active working directory when `external_directory` is `ask` or `deny`
|
|
460
483
|
|
|
461
484
|
**Limitations:**
|
|
462
485
|
- If a dangerous action is possible via an allowed tool, policy must explicitly restrict it
|
|
@@ -484,6 +507,8 @@ npx --yes ajv-cli@5 validate \
|
|
|
484
507
|
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
|
|
485
508
|
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
|
|
486
509
|
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skills` policy (global/project/agent layers). Active agent context is optional in the main session; `ask` still requires UI or forwarded confirmation. |
|
|
510
|
+
| External file path blocked | `special.external_directory` is `ask` without UI or `deny` | Allow/ask the special permission or keep file tools inside the active working directory. |
|
|
511
|
+
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
|
|
487
512
|
|
|
488
513
|
---
|
|
489
514
|
|
package/config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-permission-system",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -58,8 +58,8 @@
|
|
|
58
58
|
]
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
62
|
-
"@mariozechner/pi-tui": "^0.
|
|
61
|
+
"@mariozechner/pi-coding-agent": "^0.70.2",
|
|
62
|
+
"@mariozechner/pi-tui": "^0.70.2",
|
|
63
63
|
"@sinclair/typebox": "^0.34.49"
|
|
64
64
|
}
|
|
65
65
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getAgentDir, isToolCallEventType, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
4
|
+
import { join, normalize, resolve, sep } from "node:path";
|
|
5
5
|
|
|
6
|
-
import { toRecord } from "./common.js";
|
|
6
|
+
import { getNonEmptyString, toRecord } from "./common.js";
|
|
7
7
|
import {
|
|
8
8
|
createActiveToolsCacheKey,
|
|
9
9
|
createBeforeAgentStartPromptStateKey,
|
|
@@ -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
|
|
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
|
|
|
@@ -88,6 +72,7 @@ type PermissionRequestEvent = {
|
|
|
88
72
|
};
|
|
89
73
|
|
|
90
74
|
const PERMISSION_REQUEST_EVENT_CHANNEL = "pi-permission-system:permission-request";
|
|
75
|
+
const PATH_BEARING_TOOLS = new Set(["read", "write", "edit", "find", "grep", "ls"]);
|
|
91
76
|
|
|
92
77
|
let extensionConfig: PermissionSystemExtensionConfig = { ...DEFAULT_EXTENSION_CONFIG };
|
|
93
78
|
const extensionLogger = createPermissionSystemLogger({
|
|
@@ -127,24 +112,6 @@ function writeReviewLog(event: string, details: Record<string, unknown> = {}): v
|
|
|
127
112
|
}
|
|
128
113
|
}
|
|
129
114
|
|
|
130
|
-
function decodeXml(value: string): string {
|
|
131
|
-
return value
|
|
132
|
-
.replace(/</g, "<")
|
|
133
|
-
.replace(/>/g, ">")
|
|
134
|
-
.replace(/"/g, '"')
|
|
135
|
-
.replace(/'/g, "'")
|
|
136
|
-
.replace(/&/g, "&");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
function encodeXml(value: string): string {
|
|
140
|
-
return value
|
|
141
|
-
.replace(/&/g, "&")
|
|
142
|
-
.replace(/</g, "<")
|
|
143
|
-
.replace(/>/g, ">")
|
|
144
|
-
.replace(/"/g, """)
|
|
145
|
-
.replace(/'/g, "'");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
115
|
function normalizePathForComparison(pathValue: string, cwd: string): string {
|
|
149
116
|
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
150
117
|
if (!trimmed) {
|
|
@@ -177,121 +144,20 @@ function isPathWithinDirectory(pathValue: string, directory: string): boolean {
|
|
|
177
144
|
return pathValue.startsWith(prefix);
|
|
178
145
|
}
|
|
179
146
|
|
|
180
|
-
function
|
|
181
|
-
|
|
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) {
|
|
147
|
+
function getPathBearingToolPath(toolName: string, input: unknown): string | null {
|
|
148
|
+
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
188
149
|
return null;
|
|
189
150
|
}
|
|
190
151
|
|
|
191
|
-
|
|
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
|
-
};
|
|
152
|
+
return getNonEmptyString(toRecord(input).path);
|
|
222
153
|
}
|
|
223
154
|
|
|
224
|
-
function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
};
|
|
155
|
+
function isPathOutsideWorkingDirectory(pathValue: string, cwd: string): boolean {
|
|
156
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
157
|
+
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
158
|
+
return Boolean(normalizedCwd && normalizedPath && !isPathWithinDirectory(normalizedPath, normalizedCwd));
|
|
269
159
|
}
|
|
270
160
|
|
|
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
161
|
|
|
296
162
|
function extractSkillNameFromInput(text: string): string | null {
|
|
297
163
|
const trimmed = text.trim();
|
|
@@ -430,7 +296,147 @@ function formatUserDeniedReason(result: PermissionCheckResult, denialReason?: st
|
|
|
430
296
|
return `${base}${reasonSuffix} ${formatPermissionHardStopHint(result)}`;
|
|
431
297
|
}
|
|
432
298
|
|
|
433
|
-
|
|
299
|
+
const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
300
|
+
const TOOL_TEXT_SUMMARY_MAX_LENGTH = 80;
|
|
301
|
+
|
|
302
|
+
function truncateInlineText(value: string, maxLength: number): string {
|
|
303
|
+
return value.length > maxLength ? `${value.slice(0, maxLength)}…` : value;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function sanitizeInlineText(value: string, maxLength = TOOL_TEXT_SUMMARY_MAX_LENGTH): string {
|
|
307
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
308
|
+
return normalized ? truncateInlineText(normalized, maxLength) : "empty text";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function countTextLines(value: string): number {
|
|
312
|
+
if (!value) {
|
|
313
|
+
return 0;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return value.split(/\r\n|\r|\n/).length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatCount(value: number, singular: string, plural: string): string {
|
|
320
|
+
return `${value} ${value === 1 ? singular : plural}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getPromptPath(input: Record<string, unknown>): string | null {
|
|
324
|
+
return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function formatEditInputForPrompt(input: Record<string, unknown>): string {
|
|
328
|
+
const path = getPromptPath(input);
|
|
329
|
+
const rawEdits = Array.isArray(input.edits)
|
|
330
|
+
? input.edits
|
|
331
|
+
: typeof input.oldText === "string" && typeof input.newText === "string"
|
|
332
|
+
? [{ oldText: input.oldText, newText: input.newText }]
|
|
333
|
+
: [];
|
|
334
|
+
|
|
335
|
+
const edits = rawEdits
|
|
336
|
+
.map((edit) => toRecord(edit))
|
|
337
|
+
.filter((edit) => typeof edit.oldText === "string" && typeof edit.newText === "string");
|
|
338
|
+
|
|
339
|
+
const pathPart = path ? `for '${path}'` : "";
|
|
340
|
+
if (edits.length === 0) {
|
|
341
|
+
return pathPart ? `${pathPart} with edit input` : "with edit input";
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const firstEdit = edits[0];
|
|
345
|
+
const oldText = String(firstEdit.oldText);
|
|
346
|
+
const newText = String(firstEdit.newText);
|
|
347
|
+
const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
|
|
348
|
+
const extraEdits = edits.length > 1 ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}` : "";
|
|
349
|
+
const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
|
|
350
|
+
return pathPart ? `${pathPart} ${summary}` : summary;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatWriteInputForPrompt(input: Record<string, unknown>): string {
|
|
354
|
+
const path = getPromptPath(input);
|
|
355
|
+
const content = typeof input.content === "string" ? input.content : "";
|
|
356
|
+
const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
|
|
357
|
+
return path ? `for '${path}' ${summary}` : summary;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function formatReadInputForPrompt(input: Record<string, unknown>): string {
|
|
361
|
+
const path = getPromptPath(input);
|
|
362
|
+
const parts = path ? [`path '${path}'`] : [];
|
|
363
|
+
if (typeof input.offset === "number") {
|
|
364
|
+
parts.push(`offset ${input.offset}`);
|
|
365
|
+
}
|
|
366
|
+
if (typeof input.limit === "number") {
|
|
367
|
+
parts.push(`limit ${input.limit}`);
|
|
368
|
+
}
|
|
369
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function formatSearchInputForPrompt(toolName: string, input: Record<string, unknown>): string {
|
|
373
|
+
const parts: string[] = [];
|
|
374
|
+
const path = getPromptPath(input);
|
|
375
|
+
const pattern = getNonEmptyString(input.pattern);
|
|
376
|
+
const glob = getNonEmptyString(input.glob);
|
|
377
|
+
|
|
378
|
+
if (pattern) {
|
|
379
|
+
parts.push(`pattern '${sanitizeInlineText(pattern)}'`);
|
|
380
|
+
}
|
|
381
|
+
if (glob) {
|
|
382
|
+
parts.push(`glob '${sanitizeInlineText(glob)}'`);
|
|
383
|
+
}
|
|
384
|
+
if (path) {
|
|
385
|
+
parts.push(`path '${path}'`);
|
|
386
|
+
} else if (toolName === "find" || toolName === "grep" || toolName === "ls") {
|
|
387
|
+
parts.push("current working directory");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return parts.length > 0 ? `for ${parts.join(", ")}` : "";
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function formatJsonInputForPrompt(input: unknown): string {
|
|
394
|
+
if (input === undefined || input === null) {
|
|
395
|
+
return "";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (typeof input === "object" && !Array.isArray(input) && Object.keys(input as Record<string, unknown>).length === 0) {
|
|
399
|
+
return "";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let serialized: string;
|
|
403
|
+
try {
|
|
404
|
+
serialized = JSON.stringify(input);
|
|
405
|
+
} catch {
|
|
406
|
+
return "";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
410
|
+
return "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const inline = serialized
|
|
414
|
+
.replace(/\\r\\n|\\n|\\r|\\t/g, " ")
|
|
415
|
+
.replace(/\s+/g, " ")
|
|
416
|
+
.trim();
|
|
417
|
+
return inline ? `with input ${truncateInlineText(inline, TOOL_INPUT_PREVIEW_MAX_LENGTH)}` : "";
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function formatToolInputForPrompt(toolName: string, input: unknown): string {
|
|
421
|
+
const inputRecord = toRecord(input);
|
|
422
|
+
|
|
423
|
+
switch (toolName) {
|
|
424
|
+
case "edit":
|
|
425
|
+
return formatEditInputForPrompt(inputRecord);
|
|
426
|
+
case "write":
|
|
427
|
+
return formatWriteInputForPrompt(inputRecord);
|
|
428
|
+
case "read":
|
|
429
|
+
return formatReadInputForPrompt(inputRecord);
|
|
430
|
+
case "find":
|
|
431
|
+
case "grep":
|
|
432
|
+
case "ls":
|
|
433
|
+
return formatSearchInputForPrompt(toolName, inputRecord);
|
|
434
|
+
default:
|
|
435
|
+
return formatJsonInputForPrompt(input);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function formatAskPrompt(result: PermissionCheckResult, agentName?: string, input?: unknown): string {
|
|
434
440
|
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
435
441
|
|
|
436
442
|
if (result.toolName === "bash") {
|
|
@@ -444,7 +450,9 @@ function formatAskPrompt(result: PermissionCheckResult, agentName?: string): str
|
|
|
444
450
|
}
|
|
445
451
|
|
|
446
452
|
const patternInfo = result.matchedPattern ? ` (matched '${result.matchedPattern}')` : "";
|
|
447
|
-
|
|
453
|
+
const inputPreview = formatToolInputForPrompt(result.toolName, input);
|
|
454
|
+
const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
|
|
455
|
+
return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
|
|
448
456
|
}
|
|
449
457
|
|
|
450
458
|
function formatSkillAskPrompt(skillName: string, agentName?: string): string {
|
|
@@ -462,6 +470,39 @@ function formatSkillPathDenyReason(skill: SkillPromptEntry, readPath: string, ag
|
|
|
462
470
|
return `${subject} is not permitted to access skill '${skill.name}' via '${readPath}'.`;
|
|
463
471
|
}
|
|
464
472
|
|
|
473
|
+
function formatExternalDirectoryHardStopHint(): string {
|
|
474
|
+
return "Hard stop: this external directory permission denial is policy-enforced. Do not retry this path, do not attempt a filesystem bypass, and report the block to the user.";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function formatExternalDirectoryAskPrompt(
|
|
478
|
+
toolName: string,
|
|
479
|
+
pathValue: string,
|
|
480
|
+
cwd: string,
|
|
481
|
+
agentName?: string,
|
|
482
|
+
): string {
|
|
483
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
484
|
+
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function formatExternalDirectoryDenyReason(
|
|
488
|
+
toolName: string,
|
|
489
|
+
pathValue: string,
|
|
490
|
+
cwd: string,
|
|
491
|
+
agentName?: string,
|
|
492
|
+
): string {
|
|
493
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
494
|
+
return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function formatExternalDirectoryUserDeniedReason(
|
|
498
|
+
toolName: string,
|
|
499
|
+
pathValue: string,
|
|
500
|
+
denialReason?: string,
|
|
501
|
+
): string {
|
|
502
|
+
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
503
|
+
return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
504
|
+
}
|
|
505
|
+
|
|
465
506
|
function getPermissionLogContext(result: PermissionCheckResult): { command?: string; target?: string } {
|
|
466
507
|
return {
|
|
467
508
|
command: result.command,
|
|
@@ -1432,6 +1473,78 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1432
1473
|
}
|
|
1433
1474
|
|
|
1434
1475
|
const input = getEventInput(event);
|
|
1476
|
+
const externalDirectoryPath = ctx.cwd ? getPathBearingToolPath(toolName, input) : null;
|
|
1477
|
+
|
|
1478
|
+
if (ctx.cwd && externalDirectoryPath && isPathOutsideWorkingDirectory(externalDirectoryPath, ctx.cwd)) {
|
|
1479
|
+
const extCheck = permissionManager.checkPermission("external_directory", {}, agentName ?? undefined);
|
|
1480
|
+
|
|
1481
|
+
if (extCheck.state === "deny") {
|
|
1482
|
+
writeReviewLog("permission_request.blocked", {
|
|
1483
|
+
source: "tool_call",
|
|
1484
|
+
toolCallId: event.toolCallId,
|
|
1485
|
+
toolName,
|
|
1486
|
+
agentName,
|
|
1487
|
+
path: externalDirectoryPath,
|
|
1488
|
+
resolution: "policy_denied",
|
|
1489
|
+
});
|
|
1490
|
+
return {
|
|
1491
|
+
block: true,
|
|
1492
|
+
reason: formatExternalDirectoryDenyReason(
|
|
1493
|
+
toolName,
|
|
1494
|
+
externalDirectoryPath,
|
|
1495
|
+
ctx.cwd,
|
|
1496
|
+
agentName ?? undefined,
|
|
1497
|
+
),
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
if (extCheck.state === "ask") {
|
|
1502
|
+
const message = formatExternalDirectoryAskPrompt(
|
|
1503
|
+
toolName,
|
|
1504
|
+
externalDirectoryPath,
|
|
1505
|
+
ctx.cwd,
|
|
1506
|
+
agentName ?? undefined,
|
|
1507
|
+
);
|
|
1508
|
+
if (!canRequestPermissionConfirmation(ctx)) {
|
|
1509
|
+
writeReviewLog("permission_request.blocked", {
|
|
1510
|
+
source: "tool_call",
|
|
1511
|
+
toolCallId: event.toolCallId,
|
|
1512
|
+
toolName,
|
|
1513
|
+
agentName,
|
|
1514
|
+
path: externalDirectoryPath,
|
|
1515
|
+
message,
|
|
1516
|
+
resolution: "confirmation_unavailable",
|
|
1517
|
+
});
|
|
1518
|
+
return {
|
|
1519
|
+
block: true,
|
|
1520
|
+
reason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const extDecision = await promptPermission(ctx, {
|
|
1525
|
+
requestId: event.toolCallId,
|
|
1526
|
+
source: "tool_call",
|
|
1527
|
+
agentName,
|
|
1528
|
+
message,
|
|
1529
|
+
toolCallId: event.toolCallId,
|
|
1530
|
+
toolName,
|
|
1531
|
+
path: externalDirectoryPath,
|
|
1532
|
+
});
|
|
1533
|
+
|
|
1534
|
+
if (!extDecision.approved) {
|
|
1535
|
+
return {
|
|
1536
|
+
block: true,
|
|
1537
|
+
reason: formatExternalDirectoryUserDeniedReason(
|
|
1538
|
+
toolName,
|
|
1539
|
+
externalDirectoryPath,
|
|
1540
|
+
extDecision.denialReason,
|
|
1541
|
+
),
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
// state === "allow" → fall through to normal permission check
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1435
1548
|
const check = permissionManager.checkPermission(toolName, input, agentName ?? undefined);
|
|
1436
1549
|
const permissionLogContext = getPermissionLogContext(check);
|
|
1437
1550
|
|
|
@@ -1454,7 +1567,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
1454
1567
|
? "Using tool 'mcp' requires approval, but no interactive UI is available."
|
|
1455
1568
|
: `Using tool '${toolName}' requires approval, but no interactive UI is available.`;
|
|
1456
1569
|
|
|
1457
|
-
const message = formatAskPrompt(check, agentName ?? undefined);
|
|
1570
|
+
const message = formatAskPrompt(check, agentName ?? undefined, input);
|
|
1458
1571
|
if (!canRequestPermissionConfirmation(ctx)) {
|
|
1459
1572
|
writeReviewLog("permission_request.blocked", {
|
|
1460
1573
|
source: "tool_call",
|