pi-file-permissions 0.1.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/README.md +96 -0
- package/extensions/pi-file-permissions.ts +493 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# pi-file-permissions
|
|
2
|
+
|
|
3
|
+
A [pi](https://github.com/badlogic/pi-mono) extension that enforces path-based file permissions for pi's built-in tools using a simple YAML config.
|
|
4
|
+
|
|
5
|
+
## Install / Uninstall
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:pi-file-permissions
|
|
9
|
+
pi remove npm:pi-file-permissions
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Background: Pi's Built-in Tools
|
|
13
|
+
|
|
14
|
+
Pi ships with **four default coding tools** and **three additional tools** (disabled by default):
|
|
15
|
+
|
|
16
|
+
| Tool | Default | Description |
|
|
17
|
+
|------|---------|-------------|
|
|
18
|
+
| `bash` | ✅ enabled | Execute shell commands |
|
|
19
|
+
| `read` | ✅ enabled | Read file contents |
|
|
20
|
+
| `write` | ✅ enabled | Create or overwrite files |
|
|
21
|
+
| `edit` | ✅ enabled | Edit files with exact text replacement |
|
|
22
|
+
| `find` | ❌ disabled | Find files by glob pattern |
|
|
23
|
+
| `grep` | ❌ disabled | Search file contents with ripgrep |
|
|
24
|
+
| `ls` | ❌ disabled | List directory contents |
|
|
25
|
+
|
|
26
|
+
By default, all enabled tools have unrestricted access to the filesystem. This extension adds permission boundaries so you can control which paths each tool can reach.
|
|
27
|
+
|
|
28
|
+
## How It Works
|
|
29
|
+
|
|
30
|
+
This extension is designed to do one simple thing: **restrict which paths the LLM's tools can access**. If you don't provide a `file-permissions.yaml` in your project root, nothing happens — pi runs completely normally.
|
|
31
|
+
|
|
32
|
+
Permission control is enforced at three levels:
|
|
33
|
+
|
|
34
|
+
### 1. System Prompt
|
|
35
|
+
|
|
36
|
+
On each prompt, the extension appends a **File Permission Policy** section to the system prompt. This tells the LLM upfront which paths are allowed and which tools can access them. It also instructs the LLM to never attempt workarounds (like using `bash` to run `find` or `grep`) when a tool is blocked.
|
|
37
|
+
|
|
38
|
+
### 2. Tool Descriptions
|
|
39
|
+
|
|
40
|
+
The extension overrides the built-in tool descriptions to include the allowed paths from your config. This way the LLM sees the restrictions directly in the tool definitions and avoids calling tools on paths it knows are denied.
|
|
41
|
+
|
|
42
|
+
The `bash` tool description is updated to explicitly forbid invoking file discovery or search commands (`find`, `grep`, `rg`, `ls`, `tree`, `fd`, `ag`, `ack`, `locate`). These should be done through the dedicated tools, which are subject to permission checks.
|
|
43
|
+
|
|
44
|
+
### 3. Tool Call Blocking
|
|
45
|
+
|
|
46
|
+
As a hard enforcement layer, the extension intercepts every tool call (`tool_call` event) and checks the target path against the config. If the path is not within an allowed domain, or the tool is not in that domain's permission list, the call is **blocked** before execution. The LLM receives the denial reason as the tool result.
|
|
47
|
+
|
|
48
|
+
For `bash`, the extension checks the command string for forbidden subcommands (like `find`, `grep`, `ls`, etc.) and blocks them to prevent workarounds.
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
Create a `file-permissions.yaml` in your project root:
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
domains:
|
|
56
|
+
- path: /Users/me/projects/frontend
|
|
57
|
+
permissions: [read, write, edit, find, grep, ls]
|
|
58
|
+
- path: /Users/me/projects/backend
|
|
59
|
+
permissions: [read, find, grep, ls]
|
|
60
|
+
- path: /Users/me/data/reports
|
|
61
|
+
permissions: [read]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Rules
|
|
65
|
+
|
|
66
|
+
- **Only "allow" semantics** — listed paths get the listed permissions; everything else is denied
|
|
67
|
+
- **No config file** — everything is allowed, pi runs normally with no modifications
|
|
68
|
+
- **Project root (cwd)** — always has full permission regardless of config
|
|
69
|
+
- **`~/.pi`** — always has full permission (pi's own config directory)
|
|
70
|
+
- **Trailing globs stripped** — `path: /foo/bar/**` is treated the same as `path: /foo/bar`
|
|
71
|
+
- **Most specific wins** — if a path matches multiple domains, the longest (most specific) path takes precedence
|
|
72
|
+
|
|
73
|
+
### Available Permissions
|
|
74
|
+
|
|
75
|
+
| Permission | Tool | Description |
|
|
76
|
+
|-----------|------|-------------|
|
|
77
|
+
| `read` | read | Read file contents |
|
|
78
|
+
| `write` | write | Create or overwrite files |
|
|
79
|
+
| `edit` | edit | Edit files with text replacement |
|
|
80
|
+
| `find` | find | Find files by glob pattern |
|
|
81
|
+
| `grep` | grep | Search file contents |
|
|
82
|
+
| `ls` | ls | List directory contents |
|
|
83
|
+
|
|
84
|
+
## Scope
|
|
85
|
+
|
|
86
|
+
This extension only controls access for pi's built-in file tools. It does not manage:
|
|
87
|
+
|
|
88
|
+
- **Skills** — on-demand capability packages loaded by the LLM
|
|
89
|
+
- **MCP servers** — external tool servers connected to pi
|
|
90
|
+
- **Custom extension tools** — tools registered by other extensions
|
|
91
|
+
|
|
92
|
+
If you need to control access to skills or MCP servers, install additional extensions designed for those purposes.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
createBashTool,
|
|
7
|
+
createEditTool,
|
|
8
|
+
createFindTool,
|
|
9
|
+
createGrepTool,
|
|
10
|
+
createLsTool,
|
|
11
|
+
createReadTool,
|
|
12
|
+
createWriteTool,
|
|
13
|
+
} from "@mariozechner/pi-coding-agent";
|
|
14
|
+
import YAML from "yaml";
|
|
15
|
+
import chalk from "chalk";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Constants
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const CONFIG_RELATIVE_PATH = "./file-permissions.yaml";
|
|
22
|
+
const GUARDED_TOOLS = ["read", "write", "edit", "find", "grep", "ls"] as const;
|
|
23
|
+
const OVERRIDDEN_TOOL_NAMES = ["read", "write", "edit", "find", "grep", "ls", "bash"] as const;
|
|
24
|
+
const BASH_FORBIDDEN_COMMANDS = ["find", "grep", "rg", "ls", "tree", "fd", "ag", "ack", "locate"] as const;
|
|
25
|
+
|
|
26
|
+
type GuardedToolName = (typeof GUARDED_TOOLS)[number];
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Config types (new simplified format)
|
|
30
|
+
//
|
|
31
|
+
// domains:
|
|
32
|
+
// - path: /absolute/path
|
|
33
|
+
// permissions: [read, write, find, ls]
|
|
34
|
+
// - path: /another/path
|
|
35
|
+
// permissions: [read]
|
|
36
|
+
//
|
|
37
|
+
// Only "allow" semantics. Everything not listed is denied.
|
|
38
|
+
// If the file doesn't exist, everything is allowed.
|
|
39
|
+
// Files directly in the project root always have full permission.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
type RawDomain = {
|
|
43
|
+
path: string;
|
|
44
|
+
permissions: string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type RawConfig = {
|
|
48
|
+
domains: RawDomain[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Domain = {
|
|
52
|
+
/** Normalised absolute path (no trailing slash except root) */
|
|
53
|
+
path: string;
|
|
54
|
+
/** Raw path from YAML (for display) */
|
|
55
|
+
raw: string;
|
|
56
|
+
/** Set of allowed tool names for this domain */
|
|
57
|
+
permissions: Set<GuardedToolName>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
type PermissionRules = {
|
|
61
|
+
configPath: string;
|
|
62
|
+
domains: Domain[];
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
type LoadedRules = {
|
|
66
|
+
rules: PermissionRules | null;
|
|
67
|
+
fingerprint: string | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Path helpers
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function normalizePath(value: string): string {
|
|
75
|
+
const normalized = path.resolve(value).replace(/\\/g, "/");
|
|
76
|
+
return normalized.length > 1 ? normalized.replace(/\/+$/, "") : normalized;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stripAtPrefix(value: string): string {
|
|
80
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isSameOrDescendant(parent: string, target: string): boolean {
|
|
84
|
+
return target === parent || target.startsWith(`${parent}/`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Config loading
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
function validatePermissions(perms: unknown, domainPath: string): Set<GuardedToolName> {
|
|
93
|
+
if (!Array.isArray(perms)) {
|
|
94
|
+
throw new Error(`permissions for "${domainPath}" must be an array`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = new Set<GuardedToolName>();
|
|
98
|
+
for (const p of perms) {
|
|
99
|
+
if (typeof p !== "string") {
|
|
100
|
+
throw new Error(`permissions for "${domainPath}" must contain only strings`);
|
|
101
|
+
}
|
|
102
|
+
const lower = p.toLowerCase();
|
|
103
|
+
if (!(GUARDED_TOOLS as readonly string[]).includes(lower)) {
|
|
104
|
+
throw new Error(`Unknown permission "${p}" for "${domainPath}". Valid: ${GUARDED_TOOLS.join(", ")}`);
|
|
105
|
+
}
|
|
106
|
+
result.add(lower as GuardedToolName);
|
|
107
|
+
}
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function loadRules(cwd: string): Promise<LoadedRules> {
|
|
112
|
+
const configPath = path.join(cwd, CONFIG_RELATIVE_PATH);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const raw = await readFile(configPath, "utf8");
|
|
116
|
+
const parsed = YAML.parse(raw) as RawConfig;
|
|
117
|
+
|
|
118
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
119
|
+
throw new Error("Top-level YAML document must be a mapping");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!Array.isArray(parsed.domains)) {
|
|
123
|
+
throw new Error("Expected a 'domains' array in config");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const domains: Domain[] = [];
|
|
127
|
+
for (const entry of parsed.domains) {
|
|
128
|
+
if (typeof entry !== "object" || entry === null || typeof entry.path !== "string") {
|
|
129
|
+
throw new Error("Each domain must have a 'path' string");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Strip trailing glob patterns — paths are always treated as directory prefixes
|
|
133
|
+
const cleanPath = entry.path.replace(/\/\*\*$/, "").replace(/\/\*$/, "");
|
|
134
|
+
|
|
135
|
+
domains.push({
|
|
136
|
+
path: normalizePath(cleanPath),
|
|
137
|
+
raw: entry.path,
|
|
138
|
+
permissions: validatePermissions(entry.permissions, entry.path),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
fingerprint: raw,
|
|
144
|
+
rules: { configPath, domains },
|
|
145
|
+
};
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
148
|
+
return { rules: null, fingerprint: null };
|
|
149
|
+
}
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Access evaluation
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
function getTargetPath(toolName: GuardedToolName, input: Record<string, unknown>, cwd: string): string | null {
|
|
159
|
+
const rawPath = typeof input.path === "string" && input.path.trim().length > 0 ? input.path : cwd;
|
|
160
|
+
|
|
161
|
+
switch (toolName) {
|
|
162
|
+
case "read":
|
|
163
|
+
case "write":
|
|
164
|
+
case "edit":
|
|
165
|
+
if (typeof input.path !== "string" || input.path.trim().length === 0) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return normalizePath(path.resolve(cwd, stripAtPrefix(input.path)));
|
|
169
|
+
|
|
170
|
+
case "find":
|
|
171
|
+
case "grep":
|
|
172
|
+
case "ls":
|
|
173
|
+
return normalizePath(path.resolve(cwd, stripAtPrefix(rawPath)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function findMatchingDomain(rules: PermissionRules, targetPath: string): Domain | undefined {
|
|
178
|
+
// Find the most specific (longest path) matching domain
|
|
179
|
+
let best: Domain | undefined;
|
|
180
|
+
for (const domain of rules.domains) {
|
|
181
|
+
if (isSameOrDescendant(domain.path, targetPath)) {
|
|
182
|
+
if (!best || domain.path.length > best.path.length) {
|
|
183
|
+
best = domain;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return best;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function evaluateAccess(rules: PermissionRules, toolName: GuardedToolName, targetPath: string, cwd: string): { allowed: boolean; reason?: string } {
|
|
191
|
+
// Everything in the project folder (cwd) has full permission
|
|
192
|
+
if (isSameOrDescendant(normalizePath(cwd), targetPath)) {
|
|
193
|
+
return { allowed: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ~/.pi always has full permission
|
|
197
|
+
const piDir = normalizePath(path.join(os.homedir(), ".pi"));
|
|
198
|
+
if (isSameOrDescendant(piDir, targetPath)) {
|
|
199
|
+
return { allowed: true };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const domain = findMatchingDomain(rules, targetPath);
|
|
203
|
+
if (!domain) {
|
|
204
|
+
return {
|
|
205
|
+
allowed: false,
|
|
206
|
+
reason: `Path "${targetPath}" is not within any allowed domain in ${CONFIG_RELATIVE_PATH}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!domain.permissions.has(toolName)) {
|
|
211
|
+
return {
|
|
212
|
+
allowed: false,
|
|
213
|
+
reason: `"${toolName}" is not permitted on "${domain.raw}" (allowed: ${[...domain.permissions].join(", ")})`,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { allowed: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// Bash validation
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
function hasCommandLike(command: string, name: string): boolean {
|
|
225
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
226
|
+
const pattern = new RegExp(`(^|[\\s;|&()])(?:[^\\s;|&()]+/)?${escaped}(?=($|[\\s;|&()]))`);
|
|
227
|
+
return pattern.test(command);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function validateBashCommand(command: string): { allowed: boolean; reason?: string } {
|
|
231
|
+
const matched = BASH_FORBIDDEN_COMMANDS.find((name) => hasCommandLike(command, name));
|
|
232
|
+
if (!matched) {
|
|
233
|
+
return { allowed: true };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
allowed: false,
|
|
238
|
+
reason: `bash may not invoke ${matched}; use the dedicated ${matched === "rg" ? "grep" : matched} tool instead. If that tool is restricted, stop and report the limitation without any workaround.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// Summary / prompt helpers
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
function buildPermissionSummary(rules: PermissionRules): string {
|
|
247
|
+
const lines = [`File permissions active from ${CONFIG_RELATIVE_PATH}:`];
|
|
248
|
+
for (const domain of rules.domains) {
|
|
249
|
+
lines.push(` ${domain.raw} → [${[...domain.permissions].join(", ")}]`);
|
|
250
|
+
}
|
|
251
|
+
lines.push("Everything not listed is denied.");
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function buildSystemPromptNotice(rules: PermissionRules): string {
|
|
256
|
+
const domainLines = rules.domains.map(
|
|
257
|
+
(d) => `- ${d.raw}: ${[...d.permissions].join(", ")}`
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
return [
|
|
261
|
+
"## File Permission Policy",
|
|
262
|
+
`Permissions are controlled by ${CONFIG_RELATIVE_PATH}.`,
|
|
263
|
+
"Only the following paths and tools are allowed:",
|
|
264
|
+
...domainLines,
|
|
265
|
+
"",
|
|
266
|
+
"Everything in the current project folder and ~/.pi is always accessible.",
|
|
267
|
+
"Everything else is denied.",
|
|
268
|
+
"If a tool reports a permission restriction, NEVER try a workaround via bash, alternate tools, broader parent directories, globbing, or search/discovery commands.",
|
|
269
|
+
"Stop immediately and report the limitation instead.",
|
|
270
|
+
].join("\n");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function buildToolDescription(baseDesc: string, toolName: GuardedToolName, rules: PermissionRules): string {
|
|
274
|
+
const matching = rules.domains.filter((d) => d.permissions.has(toolName));
|
|
275
|
+
if (matching.length === 0) {
|
|
276
|
+
return `${baseDesc} This tool is not permitted on any configured path.`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const paths = matching.map((d) => d.raw).join(", ");
|
|
280
|
+
return `${baseDesc} Allowed paths: ${paths}. All other paths are denied. If denied, stop and report the restriction. Never use bash as a workaround.`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function createPromptGuidelines(toolName: GuardedToolName, rules: PermissionRules): string[] {
|
|
284
|
+
const matching = rules.domains.filter((d) => d.permissions.has(toolName));
|
|
285
|
+
const guidelines = [
|
|
286
|
+
`Only use this tool on paths allowed by ${CONFIG_RELATIVE_PATH}.`,
|
|
287
|
+
"If blocked by permissions, stop and explain the restriction.",
|
|
288
|
+
"Never use bash or another tool as a workaround for a denied path.",
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
if (matching.length > 0) {
|
|
292
|
+
guidelines.push(`Allowed: ${matching.map((d) => d.raw).join(", ")}`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return guidelines;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Tool registration
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
function registerScopedOverrides(pi: ExtensionAPI, cwd: string, rules: PermissionRules): void {
|
|
303
|
+
const bashTool = createBashTool(cwd);
|
|
304
|
+
const readTool = createReadTool(cwd);
|
|
305
|
+
const writeTool = createWriteTool(cwd);
|
|
306
|
+
const editTool = createEditTool(cwd);
|
|
307
|
+
const findTool = createFindTool(cwd);
|
|
308
|
+
const grepTool = createGrepTool(cwd);
|
|
309
|
+
const lsTool = createLsTool(cwd);
|
|
310
|
+
|
|
311
|
+
pi.registerTool({
|
|
312
|
+
...bashTool,
|
|
313
|
+
description:
|
|
314
|
+
"Execute project-local bash commands. Never use bash for file discovery or content search. Do not invoke find, grep, rg, ls, tree, fd, ag, ack, or locate from bash; use the dedicated tools instead. If those tools are blocked by permissions, stop and report the restriction without any workaround.",
|
|
315
|
+
promptSnippet: "Run project-local CLI commands, but never use bash for search, file discovery, grep, or ls-style listing.",
|
|
316
|
+
promptGuidelines: [
|
|
317
|
+
"Do not call find, grep, rg, ls, tree, fd, ag, ack, or locate from bash.",
|
|
318
|
+
"Use the dedicated read/find/grep/ls tools instead.",
|
|
319
|
+
"If those tools are blocked by permissions, stop and report the limitation instead of trying a workaround.",
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
pi.registerTool({
|
|
324
|
+
...readTool,
|
|
325
|
+
description: buildToolDescription("Read file contents.", "read", rules),
|
|
326
|
+
promptSnippet: `Read file contents only on permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
327
|
+
promptGuidelines: createPromptGuidelines("read", rules),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
pi.registerTool({
|
|
331
|
+
...writeTool,
|
|
332
|
+
description: buildToolDescription("Create or overwrite files.", "write", rules),
|
|
333
|
+
promptSnippet: `Create or overwrite files only on permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
334
|
+
promptGuidelines: createPromptGuidelines("write", rules),
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
pi.registerTool({
|
|
338
|
+
...editTool,
|
|
339
|
+
description: buildToolDescription("Edit a single file using exact text replacement.", "edit", rules),
|
|
340
|
+
promptSnippet: `Edit files only on permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
341
|
+
promptGuidelines: createPromptGuidelines("edit", rules),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
pi.registerTool({
|
|
345
|
+
...findTool,
|
|
346
|
+
description: buildToolDescription("Find files by glob pattern.", "find", rules),
|
|
347
|
+
promptSnippet: `Find filenames only inside permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
348
|
+
promptGuidelines: createPromptGuidelines("find", rules),
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
pi.registerTool({
|
|
352
|
+
...grepTool,
|
|
353
|
+
description: buildToolDescription("Search file contents with ripgrep.", "grep", rules),
|
|
354
|
+
promptSnippet: `Search file contents only inside permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
355
|
+
promptGuidelines: createPromptGuidelines("grep", rules),
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
pi.registerTool({
|
|
359
|
+
...lsTool,
|
|
360
|
+
description: buildToolDescription("List directory contents.", "ls", rules),
|
|
361
|
+
promptSnippet: `List directories only inside permitted paths from ${CONFIG_RELATIVE_PATH}.`,
|
|
362
|
+
promptGuidelines: createPromptGuidelines("ls", rules),
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const activeToolNames = new Set(pi.getActiveTools());
|
|
366
|
+
for (const toolName of OVERRIDDEN_TOOL_NAMES) {
|
|
367
|
+
activeToolNames.add(toolName);
|
|
368
|
+
}
|
|
369
|
+
pi.setActiveTools([...activeToolNames]);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
// Extension entry point
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
export default function scopedGuardedTools(pi: ExtensionAPI) {
|
|
377
|
+
let lastFingerprint: string | null | undefined;
|
|
378
|
+
let registeredForCwd: string | undefined;
|
|
379
|
+
|
|
380
|
+
async function refreshOverrides(cwd: string): Promise<PermissionRules | null> {
|
|
381
|
+
const { rules, fingerprint } = await loadRules(cwd);
|
|
382
|
+
if (!rules) {
|
|
383
|
+
lastFingerprint = fingerprint;
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (fingerprint !== lastFingerprint || registeredForCwd !== cwd) {
|
|
388
|
+
registerScopedOverrides(pi, cwd, rules);
|
|
389
|
+
lastFingerprint = fingerprint;
|
|
390
|
+
registeredForCwd = cwd;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return rules;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
397
|
+
try {
|
|
398
|
+
const rules = await refreshOverrides(ctx.cwd);
|
|
399
|
+
if (rules) {
|
|
400
|
+
console.log(`${chalk.blue("[file-permissions]")} Loaded ${CONFIG_RELATIVE_PATH}`);
|
|
401
|
+
for (const domain of rules.domains) {
|
|
402
|
+
console.log(` ${domain.raw} → [${[...domain.permissions].join(", ")}]`);
|
|
403
|
+
}
|
|
404
|
+
console.log(" Everything not listed is denied.");
|
|
405
|
+
console.log(" ");
|
|
406
|
+
} else {
|
|
407
|
+
console.log(`${chalk.blue("[file-permissions]")} ${CONFIG_RELATIVE_PATH} not found — all paths allowed\n`);
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.log(`${chalk.red("[file-permissions]")} Failed to load ${CONFIG_RELATIVE_PATH}: ${(error as Error).message}\n`);
|
|
411
|
+
if (ctx.hasUI) {
|
|
412
|
+
ctx.ui.notify(`Failed to load ${CONFIG_RELATIVE_PATH}: ${(error as Error).message}`, "error");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// before_agent_start: modify system prompt (ctx.ui.notify does not work here)
|
|
418
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
419
|
+
try {
|
|
420
|
+
const rules = await refreshOverrides(ctx.cwd);
|
|
421
|
+
if (!rules) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildSystemPromptNotice(rules)}`,
|
|
427
|
+
};
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// agent_start: notify user about active permissions (UI is ready here)
|
|
434
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
435
|
+
try {
|
|
436
|
+
const rules = await refreshOverrides(ctx.cwd);
|
|
437
|
+
if (rules) {
|
|
438
|
+
ctx.ui.notify(buildPermissionSummary(rules), "info");
|
|
439
|
+
}
|
|
440
|
+
} catch (error) {
|
|
441
|
+
ctx.ui.notify(`Failed to load ${CONFIG_RELATIVE_PATH}: ${(error as Error).message}`, "error");
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
446
|
+
let rules: PermissionRules | null;
|
|
447
|
+
try {
|
|
448
|
+
rules = await refreshOverrides(ctx.cwd);
|
|
449
|
+
} catch (error) {
|
|
450
|
+
const reason = `Failed to parse ${CONFIG_RELATIVE_PATH}: ${(error as Error).message}`;
|
|
451
|
+
if (ctx.hasUI) {
|
|
452
|
+
ctx.ui.notify(reason, "error");
|
|
453
|
+
}
|
|
454
|
+
return { block: true, reason };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (!rules) {
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (event.toolName === "bash") {
|
|
462
|
+
const command = typeof event.input.command === "string" ? event.input.command : "";
|
|
463
|
+
const bashCheck = validateBashCommand(command);
|
|
464
|
+
if (!bashCheck.allowed) {
|
|
465
|
+
if (ctx.hasUI) {
|
|
466
|
+
ctx.ui.notify(bashCheck.reason ?? "Blocked bash command", "warning");
|
|
467
|
+
}
|
|
468
|
+
return { block: true, reason: bashCheck.reason };
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (!GUARDED_TOOLS.includes(event.toolName as GuardedToolName)) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const toolName = event.toolName as GuardedToolName;
|
|
478
|
+
const targetPath = getTargetPath(toolName, event.input as Record<string, unknown>, ctx.cwd);
|
|
479
|
+
if (!targetPath) {
|
|
480
|
+
return { block: true, reason: `${toolName} requires a path` };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const result = evaluateAccess(rules, toolName, targetPath, ctx.cwd);
|
|
484
|
+
if (!result.allowed) {
|
|
485
|
+
if (ctx.hasUI) {
|
|
486
|
+
ctx.ui.notify(result.reason ?? `Blocked ${toolName} on ${targetPath}`, "warning");
|
|
487
|
+
}
|
|
488
|
+
return { block: true, reason: result.reason };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return undefined;
|
|
492
|
+
});
|
|
493
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-file-permissions",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that enforces file-level permissions via a YAML config. Controls which paths each tool (read, write, edit, find, grep, ls) can access.",
|
|
5
|
+
"keywords": ["pi-package"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": ["./extensions"]
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"chalk": "^5.6.2",
|
|
12
|
+
"yaml": "^2.8.1"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
16
|
+
"@mariozechner/pi-ai": "*",
|
|
17
|
+
"@sinclair/typebox": "*"
|
|
18
|
+
}
|
|
19
|
+
}
|