@gotgenes/pi-permission-system 4.7.0 → 4.9.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 +35 -0
- package/README.md +43 -8
- package/config/config.example.json +6 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +12 -2
- package/src/expand-home.ts +28 -0
- package/src/extension-config.ts +13 -1
- package/src/external-directory.ts +96 -1
- package/src/handlers/tool-call.ts +87 -61
- package/src/runtime.ts +17 -0
- package/src/wildcard-matcher.ts +4 -1
- package/tests/bash-external-directory.test.ts +50 -0
- package/tests/expand-home.test.ts +93 -0
- package/tests/handlers/tool-call.test.ts +147 -0
- package/tests/permission-manager-unified.test.ts +74 -1
- package/tests/pi-infrastructure-read.test.ts +245 -0
- package/tests/runtime.test.ts +45 -0
- package/tests/wildcard-matcher.test.ts +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,41 @@ 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
|
+
## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* bypass external_directory gate for Pi infrastructure reads ([229a352](https://github.com/gotgenes/pi-permission-system/commit/229a35222dd47f1d0c079f0bcd34760569e912f3))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* skip regex patterns in bash external-directory path extraction ([9fe4ba6](https://github.com/gotgenes/pi-permission-system/commit/9fe4ba6d259c25aa0a9e3a5508884d26a303cac3))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Documentation
|
|
22
|
+
|
|
23
|
+
* document piInfrastructureReadPaths config and infrastructure auto-allow ([65e0ac8](https://github.com/gotgenes/pi-permission-system/commit/65e0ac8ef8a4973c261628e026c3772faa0849ab))
|
|
24
|
+
* plan auto-allow reads from Pi infrastructure directories ([#48](https://github.com/gotgenes/pi-permission-system/issues/48)) ([06b8d44](https://github.com/gotgenes/pi-permission-system/commit/06b8d441d569b1c2893f5c434357eb8b2fc9180f))
|
|
25
|
+
* **retro:** add retro notes for issue [#53](https://github.com/gotgenes/pi-permission-system/issues/53) ([1988d7a](https://github.com/gotgenes/pi-permission-system/commit/1988d7ab09432df09825c560ba377233e0d3ab33))
|
|
26
|
+
|
|
27
|
+
## [4.8.0](https://github.com/gotgenes/pi-permission-system/compare/v4.7.0...v4.8.0) (2026-05-05)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Features
|
|
31
|
+
|
|
32
|
+
* add expandHomePath utility for ~ and $HOME expansion ([18264e1](https://github.com/gotgenes/pi-permission-system/commit/18264e104f6aa12ca004a127d0f7b09b9e4fb740))
|
|
33
|
+
* expand ~ and $HOME in wildcard patterns at compile time ([3c7e0c2](https://github.com/gotgenes/pi-permission-system/commit/3c7e0c2ab92c1e6bb58fdab32cfd9ae2c72e100a))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
### Documentation
|
|
37
|
+
|
|
38
|
+
* document ~/$HOME pattern expansion in schema, example config, and README ([8ad5190](https://github.com/gotgenes/pi-permission-system/commit/8ad51909512ca294c83876868407866290234882))
|
|
39
|
+
* plan home directory expansion in permission patterns ([#53](https://github.com/gotgenes/pi-permission-system/issues/53)) ([b5b77b6](https://github.com/gotgenes/pi-permission-system/commit/b5b77b640006b67b51420135be8fb78484c9d9a1))
|
|
40
|
+
* **retro:** add retro notes for issue [#52](https://github.com/gotgenes/pi-permission-system/issues/52) ([7fc8113](https://github.com/gotgenes/pi-permission-system/commit/7fc8113390fd1dd9cf09c05e903d597c16d80104))
|
|
41
|
+
* sleep before pulling release commit and tag ([af701b5](https://github.com/gotgenes/pi-permission-system/commit/af701b543b20f274ca9f8aa904af0a39bc232c26))
|
|
42
|
+
|
|
8
43
|
## [4.7.0](https://github.com/gotgenes/pi-permission-system/compare/v4.6.0...v4.7.0) (2026-05-05)
|
|
9
44
|
|
|
10
45
|
|
package/README.md
CHANGED
|
@@ -118,6 +118,7 @@ The config file combines runtime knobs and permission policy in one object:
|
|
|
118
118
|
"debugLog": false,
|
|
119
119
|
"permissionReviewLog": true,
|
|
120
120
|
"yoloMode": false,
|
|
121
|
+
"piInfrastructureReadPaths": [], // extra dirs to auto-allow for reads
|
|
121
122
|
|
|
122
123
|
// Flat permission policy
|
|
123
124
|
"permission": {
|
|
@@ -134,11 +135,12 @@ The config file combines runtime knobs and permission policy in one object:
|
|
|
134
135
|
|
|
135
136
|
#### Runtime knobs
|
|
136
137
|
|
|
137
|
-
| Key
|
|
138
|
-
|
|
|
139
|
-
| `debugLog`
|
|
140
|
-
| `permissionReviewLog`
|
|
141
|
-
| `yoloMode`
|
|
138
|
+
| Key | Default | Description |
|
|
139
|
+
| ---------------------------- | ------- | ------------------------------------------------------------------------------------------------------- |
|
|
140
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
141
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl` |
|
|
142
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
143
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate (supports `~`) |
|
|
142
144
|
|
|
143
145
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
144
146
|
No debug output is printed to the terminal.
|
|
@@ -156,7 +158,7 @@ The `permission` object maps surface names to actions:
|
|
|
156
158
|
| `bash` | string or object | Bash catch-all or `{ pattern: action }` map |
|
|
157
159
|
| `mcp` | string or object | MCP catch-all or `{ pattern: action }` map |
|
|
158
160
|
| `skill` | string or object | Skill catch-all or `{ pattern: action }` map |
|
|
159
|
-
| `external_directory` | string | Controls access to paths outside `cwd
|
|
161
|
+
| `external_directory` | string or object | Controls access to paths outside `cwd`; supports `~/` and `$HOME/` patterns |
|
|
160
162
|
|
|
161
163
|
> **Note:** Trailing commas are **not** supported. If parsing fails, the extension falls back to `ask` for all categories.
|
|
162
164
|
|
|
@@ -330,14 +332,36 @@ Skill name patterns use `*` wildcards (note: surface is `skill`, not `skills`):
|
|
|
330
332
|
}
|
|
331
333
|
```
|
|
332
334
|
|
|
335
|
+
### Home directory expansion in patterns
|
|
336
|
+
|
|
337
|
+
Pattern keys in any permission surface can start with `~/` or `$HOME/` (or be exactly `~` / `$HOME`).
|
|
338
|
+
They are expanded to the OS home directory at match time, so configs are portable across machines and users.
|
|
339
|
+
|
|
340
|
+
```jsonc
|
|
341
|
+
{
|
|
342
|
+
"permission": {
|
|
343
|
+
"external_directory": {
|
|
344
|
+
"*": "ask",
|
|
345
|
+
"~/development/*": "allow"
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The pattern is stored and displayed as written (e.g. `~/development/*`) in logs and approval dialogs.
|
|
352
|
+
|
|
333
353
|
### `external_directory` surface
|
|
334
354
|
|
|
335
|
-
Controls access to paths outside the active working directory
|
|
355
|
+
Controls access to paths outside the active working directory.
|
|
356
|
+
Use a pattern map to allow specific directories without opening all external access:
|
|
336
357
|
|
|
337
358
|
```jsonc
|
|
338
359
|
{
|
|
339
360
|
"permission": {
|
|
340
|
-
"external_directory":
|
|
361
|
+
"external_directory": {
|
|
362
|
+
"*": "ask",
|
|
363
|
+
"~/development/*": "allow"
|
|
364
|
+
}
|
|
341
365
|
}
|
|
342
366
|
}
|
|
343
367
|
```
|
|
@@ -350,6 +374,17 @@ Quoted strings are stripped first to reduce false positives.
|
|
|
350
374
|
This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
|
|
351
375
|
OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
|
|
352
376
|
|
|
377
|
+
**Pi infrastructure read auto-allow** — Read-only tools (`read`, `find`, `grep`, `ls`) targeting Pi infrastructure directories are automatically allowed without triggering the gate, even when `external_directory` is `ask` or `deny`.
|
|
378
|
+
Infrastructure directories include:
|
|
379
|
+
|
|
380
|
+
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
|
|
381
|
+
2. Git-cloned global packages (`<agentDir>/git/`)
|
|
382
|
+
3. The global `node_modules` root (auto-discovered from the extension's own install path — works for npm, pnpm, bun, Homebrew)
|
|
383
|
+
4. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
|
384
|
+
5. Any paths listed in `piInfrastructureReadPaths`
|
|
385
|
+
|
|
386
|
+
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
|
387
|
+
|
|
353
388
|
---
|
|
354
389
|
|
|
355
390
|
## Common Recipes
|
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
"permissionReviewLog": true,
|
|
6
6
|
"yoloMode": false,
|
|
7
7
|
|
|
8
|
+
"piInfrastructureReadPaths": [],
|
|
9
|
+
|
|
8
10
|
"permission": {
|
|
9
11
|
"*": "ask",
|
|
10
12
|
"read": "allow",
|
|
@@ -17,6 +19,9 @@
|
|
|
17
19
|
},
|
|
18
20
|
"mcp": { "*": "ask", "mcp_status": "allow", "mcp_list": "allow" },
|
|
19
21
|
"skill": { "*": "ask" },
|
|
20
|
-
"external_directory":
|
|
22
|
+
"external_directory": {
|
|
23
|
+
"*": "ask",
|
|
24
|
+
"~/development/*": "allow"
|
|
25
|
+
}
|
|
21
26
|
}
|
|
22
27
|
}
|
package/package.json
CHANGED
|
@@ -29,6 +29,16 @@
|
|
|
29
29
|
"type": "boolean",
|
|
30
30
|
"default": false
|
|
31
31
|
},
|
|
32
|
+
"piInfrastructureReadPaths": {
|
|
33
|
+
"description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion. Directory prefixes only (no globs).",
|
|
34
|
+
"markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root, `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient.\n\nSupports `~` expansion. Directory prefixes only — no glob patterns.",
|
|
35
|
+
"type": "array",
|
|
36
|
+
"items": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"minLength": 1
|
|
39
|
+
},
|
|
40
|
+
"default": []
|
|
41
|
+
},
|
|
32
42
|
"permission": {
|
|
33
43
|
"description": "Flat permission policy. Each key is a surface name; values are a PermissionState string (catch-all) or a pattern→action map.",
|
|
34
44
|
"markdownDescription": "Flat permission policy.\n\nEach top-level key is a surface name:\n- `\"*\"` — universal fallback (replaces `defaultPolicy.tools` from the legacy format)\n- Tool names (`read`, `write`, `bash`, `mcp`, `skill`, `external_directory`, etc.)\n\nA **string** value is shorthand for `{ \"*\": action }` (surface-level catch-all).\nAn **object** value maps wildcard patterns to actions — last matching pattern wins.\n\n**Merge order (lowest → highest precedence):** global → project → per-agent frontmatter.",
|
|
@@ -88,10 +98,10 @@
|
|
|
88
98
|
},
|
|
89
99
|
"permissionMap": {
|
|
90
100
|
"description": "A map of wildcard patterns to permission states. Last matching pattern wins.",
|
|
91
|
-
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.",
|
|
101
|
+
"markdownDescription": "A map of wildcard patterns to permission states.\n\nUse `*` for wildcard matching. When multiple patterns match, the **last matching rule wins** — put broad catch-alls first and specific overrides after them.\n\nPattern keys support home directory expansion:\n- `~/path` or `$HOME/path` — expanded to the OS home directory at match time.\n- `~` or `$HOME` alone — expands to the home directory itself.\n\nThe stored pattern is always shown in logs and approval dialogs as written (e.g. `~/dev/*`).",
|
|
92
102
|
"type": "object",
|
|
93
103
|
"propertyNames": {
|
|
94
|
-
"description": "A non-empty pattern string. Use * for wildcard matching.",
|
|
104
|
+
"description": "A non-empty pattern string. Use * for wildcard matching. Prefix with ~/ or $HOME/ for home-relative paths.",
|
|
95
105
|
"type": "string",
|
|
96
106
|
"minLength": 1
|
|
97
107
|
},
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Expand `~` and `$HOME` prefixes in a pattern to the OS home directory.
|
|
6
|
+
*
|
|
7
|
+
* Supported forms:
|
|
8
|
+
* - `~` → `homedir()`
|
|
9
|
+
* - `~/path` → `homedir()/path`
|
|
10
|
+
* - `~\path` → `homedir()\path` (Windows)
|
|
11
|
+
* - `$HOME` → `homedir()`
|
|
12
|
+
* - `$HOME/path` → `homedir()/path`
|
|
13
|
+
* - `$HOME\path` → `homedir()\path` (Windows)
|
|
14
|
+
*
|
|
15
|
+
* All other patterns are returned unchanged.
|
|
16
|
+
*/
|
|
17
|
+
export function expandHomePath(pattern: string): string {
|
|
18
|
+
if (pattern === "~" || pattern === "$HOME") {
|
|
19
|
+
return homedir();
|
|
20
|
+
}
|
|
21
|
+
if (pattern.startsWith("~/") || pattern.startsWith("~\\")) {
|
|
22
|
+
return join(homedir(), pattern.slice(2));
|
|
23
|
+
}
|
|
24
|
+
if (pattern.startsWith("$HOME/") || pattern.startsWith("$HOME\\")) {
|
|
25
|
+
return join(homedir(), pattern.slice(6));
|
|
26
|
+
}
|
|
27
|
+
return pattern;
|
|
28
|
+
}
|
package/src/extension-config.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface PermissionSystemExtensionConfig {
|
|
|
17
17
|
debugLog: boolean;
|
|
18
18
|
permissionReviewLog: boolean;
|
|
19
19
|
yoloMode: boolean;
|
|
20
|
+
/** Additional directories to auto-allow for reads as Pi infrastructure. */
|
|
21
|
+
piInfrastructureReadPaths?: string[];
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
export interface PermissionSystemConfigLoadResult {
|
|
@@ -81,11 +83,21 @@ export function normalizePermissionSystemConfig(
|
|
|
81
83
|
raw: unknown,
|
|
82
84
|
): PermissionSystemExtensionConfig {
|
|
83
85
|
const record = toRecord(raw);
|
|
84
|
-
|
|
86
|
+
const rawPaths = record.piInfrastructureReadPaths;
|
|
87
|
+
const piInfrastructureReadPaths: string[] | undefined =
|
|
88
|
+
Array.isArray(rawPaths) &&
|
|
89
|
+
rawPaths.every((p): p is string => typeof p === "string")
|
|
90
|
+
? rawPaths
|
|
91
|
+
: undefined;
|
|
92
|
+
const result: PermissionSystemExtensionConfig = {
|
|
85
93
|
debugLog: record.debugLog === true,
|
|
86
94
|
permissionReviewLog: record.permissionReviewLog !== false,
|
|
87
95
|
yoloMode: record.yoloMode === true,
|
|
88
96
|
};
|
|
97
|
+
if (piInfrastructureReadPaths !== undefined) {
|
|
98
|
+
result.piInfrastructureReadPaths = piInfrastructureReadPaths;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
function ensureConfigDirectory(configPath: string): void {
|
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, normalize, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, dirname, join, normalize, resolve, sep } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
|
|
5
6
|
import { getNonEmptyString, toRecord } from "./common";
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Discover the global node_modules root by walking up from the given file URL
|
|
10
|
+
* (defaults to this module's own `import.meta.url`).
|
|
11
|
+
*
|
|
12
|
+
* Works regardless of package manager (npm, pnpm, bun, Homebrew) because the
|
|
13
|
+
* extension itself is installed inside the directory we want to find.
|
|
14
|
+
* Returns `null` when the file is not inside any node_modules tree, or when
|
|
15
|
+
* the URL cannot be parsed — callers must degrade gracefully.
|
|
16
|
+
*/
|
|
17
|
+
export function discoverGlobalNodeModulesRoot(
|
|
18
|
+
fromUrl = import.meta.url,
|
|
19
|
+
): string | null {
|
|
20
|
+
try {
|
|
21
|
+
const thisFile = fileURLToPath(fromUrl);
|
|
22
|
+
let dir = dirname(thisFile);
|
|
23
|
+
// Walk up until we find a directory named "node_modules" or hit the root.
|
|
24
|
+
while (dir !== dirname(dir)) {
|
|
25
|
+
if (basename(dir) === "node_modules") {
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
dir = dirname(dir);
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
7
36
|
/**
|
|
8
37
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
9
38
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -23,6 +52,60 @@ export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
|
23
52
|
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
24
53
|
}
|
|
25
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Returns true if the given tool + normalized path combination qualifies for
|
|
57
|
+
* automatic allow as a Pi infrastructure read.
|
|
58
|
+
*
|
|
59
|
+
* A path qualifies when:
|
|
60
|
+
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
61
|
+
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
62
|
+
* OR within the project-local Pi package directories
|
|
63
|
+
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
64
|
+
*
|
|
65
|
+
* `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
|
|
66
|
+
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
67
|
+
* follow working-directory changes without a runtime rebuild.
|
|
68
|
+
*/
|
|
69
|
+
export function isPiInfrastructureRead(
|
|
70
|
+
toolName: string,
|
|
71
|
+
normalizedPath: string,
|
|
72
|
+
infrastructureDirs: readonly string[],
|
|
73
|
+
cwd: string,
|
|
74
|
+
): boolean {
|
|
75
|
+
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
for (const dir of infrastructureDirs) {
|
|
80
|
+
if (isPathWithinDirectory(normalizedPath, dir)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
86
|
+
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
87
|
+
const projectGitDir = join(cwd, ".pi", "git");
|
|
88
|
+
if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* File tools that only read — never write — the filesystem.
|
|
100
|
+
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
101
|
+
*/
|
|
102
|
+
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
103
|
+
"read",
|
|
104
|
+
"find",
|
|
105
|
+
"grep",
|
|
106
|
+
"ls",
|
|
107
|
+
]);
|
|
108
|
+
|
|
26
109
|
export const PATH_BEARING_TOOLS = new Set([
|
|
27
110
|
"read",
|
|
28
111
|
"write",
|
|
@@ -352,6 +435,13 @@ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
|
352
435
|
*/
|
|
353
436
|
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
354
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Regex metacharacter sequences that are never found in real filesystem paths.
|
|
440
|
+
* If a token contains any of these, it is almost certainly a regex pattern
|
|
441
|
+
* (e.g. a grep argument) rather than a path.
|
|
442
|
+
*/
|
|
443
|
+
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
444
|
+
|
|
355
445
|
/**
|
|
356
446
|
* Determines whether a token looks like a path candidate worth resolving.
|
|
357
447
|
* Returns the raw token string if it's a candidate, or null to skip.
|
|
@@ -380,6 +470,11 @@ function classifyTokenAsPathCandidate(token: string): string | null {
|
|
|
380
470
|
// and are never meaningful path arguments in practice.
|
|
381
471
|
if (/^\/+$/.test(token)) return null;
|
|
382
472
|
|
|
473
|
+
// Skip tokens that contain regex metacharacter sequences — these are almost
|
|
474
|
+
// certainly grep/sed/awk patterns, not filesystem paths.
|
|
475
|
+
// Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
|
|
476
|
+
if (REGEX_METACHAR_PATTERN.test(token)) return null;
|
|
477
|
+
|
|
383
478
|
// Must look like a path: starts with /, ~/, or contains ..
|
|
384
479
|
if (token.startsWith("/")) return token;
|
|
385
480
|
if (token.startsWith("~/")) return token;
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
formatExternalDirectoryUserDeniedReason,
|
|
16
16
|
getPathBearingToolPath,
|
|
17
17
|
isPathOutsideWorkingDirectory,
|
|
18
|
+
isPiInfrastructureRead,
|
|
18
19
|
normalizePathForComparison,
|
|
19
20
|
PATH_BEARING_TOOLS,
|
|
20
21
|
} from "../external-directory";
|
|
@@ -170,82 +171,107 @@ export async function handleToolCall(
|
|
|
170
171
|
externalDirectoryPath,
|
|
171
172
|
ctx.cwd,
|
|
172
173
|
);
|
|
173
|
-
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
174
|
-
"external_directory",
|
|
175
|
-
{ path: normalizedExtPath },
|
|
176
|
-
agentName ?? undefined,
|
|
177
|
-
deps.runtime.sessionRules.getRuleset(),
|
|
178
|
-
);
|
|
179
174
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
175
|
+
// ── Pi infrastructure read bypass ──────────────────────────────────
|
|
176
|
+
// Auto-allow read-only tools targeting Pi infrastructure directories
|
|
177
|
+
// (agent dir, global node_modules, project-local .pi/npm|git, and
|
|
178
|
+
// any user-configured extras). Writes are never bypassed.
|
|
179
|
+
const allInfraDirs = [
|
|
180
|
+
...deps.runtime.piInfrastructureDirs,
|
|
181
|
+
...(deps.runtime.config.piInfrastructureReadPaths ?? []),
|
|
182
|
+
];
|
|
183
|
+
if (
|
|
184
|
+
isPiInfrastructureRead(toolName, normalizedExtPath, allInfraDirs, ctx.cwd)
|
|
185
|
+
) {
|
|
186
|
+
deps.runtime.writeReviewLog(
|
|
187
|
+
"permission_request.infrastructure_auto_allowed",
|
|
188
|
+
{
|
|
189
|
+
source: "tool_call",
|
|
190
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
191
|
+
toolName,
|
|
192
|
+
agentName,
|
|
193
|
+
path: externalDirectoryPath,
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
// Fall through to normal tool-permission check.
|
|
191
197
|
} else {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
externalDirectoryPath,
|
|
196
|
-
ctx.cwd,
|
|
198
|
+
const extCheck = deps.runtime.permissionManager.checkPermission(
|
|
199
|
+
"external_directory",
|
|
200
|
+
{ path: normalizedExtPath },
|
|
197
201
|
agentName ?? undefined,
|
|
202
|
+
deps.runtime.sessionRules.getRuleset(),
|
|
198
203
|
);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
promptForApproval: async () => {
|
|
203
|
-
const decision = await deps.promptPermission(ctx, {
|
|
204
|
-
requestId: (event as { toolCallId: string }).toolCallId,
|
|
205
|
-
source: "tool_call",
|
|
206
|
-
agentName,
|
|
207
|
-
message: extDirMessage,
|
|
208
|
-
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
209
|
-
toolName,
|
|
210
|
-
path: externalDirectoryPath,
|
|
211
|
-
});
|
|
212
|
-
extDirDecision = decision;
|
|
213
|
-
return decision;
|
|
214
|
-
},
|
|
215
|
-
writeLog: deps.runtime.writeReviewLog,
|
|
216
|
-
logContext: {
|
|
204
|
+
|
|
205
|
+
if (extCheck.source === "session") {
|
|
206
|
+
deps.runtime.writeReviewLog("permission_request.session_approved", {
|
|
217
207
|
source: "tool_call",
|
|
218
208
|
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
219
209
|
toolName,
|
|
220
210
|
agentName,
|
|
221
211
|
path: externalDirectoryPath,
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
212
|
+
resolution: "session_approved",
|
|
213
|
+
sessionApprovalPattern: extCheck.matchedPattern,
|
|
214
|
+
});
|
|
215
|
+
// Fall through to normal permission check
|
|
216
|
+
} else {
|
|
217
|
+
let extDirDecision: PermissionPromptDecision | null = null;
|
|
218
|
+
const extDirMessage = formatExternalDirectoryAskPrompt(
|
|
219
|
+
toolName,
|
|
220
|
+
externalDirectoryPath,
|
|
221
|
+
ctx.cwd,
|
|
222
|
+
agentName ?? undefined,
|
|
223
|
+
);
|
|
224
|
+
const extDirGate = await applyPermissionGate({
|
|
225
|
+
state: extCheck.state,
|
|
226
|
+
canConfirm: deps.canRequestPermissionConfirmation(ctx),
|
|
227
|
+
promptForApproval: async () => {
|
|
228
|
+
const decision = await deps.promptPermission(ctx, {
|
|
229
|
+
requestId: (event as { toolCallId: string }).toolCallId,
|
|
230
|
+
source: "tool_call",
|
|
231
|
+
agentName,
|
|
232
|
+
message: extDirMessage,
|
|
233
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
234
|
+
toolName,
|
|
235
|
+
path: externalDirectoryPath,
|
|
236
|
+
});
|
|
237
|
+
extDirDecision = decision;
|
|
238
|
+
return decision;
|
|
239
|
+
},
|
|
240
|
+
writeLog: deps.runtime.writeReviewLog,
|
|
241
|
+
logContext: {
|
|
242
|
+
source: "tool_call",
|
|
243
|
+
toolCallId: (event as { toolCallId: string }).toolCallId,
|
|
226
244
|
toolName,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
formatExternalDirectoryUserDeniedReason(
|
|
245
|
+
agentName,
|
|
246
|
+
path: externalDirectoryPath,
|
|
247
|
+
message: extDirMessage,
|
|
248
|
+
},
|
|
249
|
+
messages: {
|
|
250
|
+
denyReason: formatExternalDirectoryDenyReason(
|
|
234
251
|
toolName,
|
|
235
252
|
externalDirectoryPath,
|
|
236
|
-
|
|
253
|
+
ctx.cwd,
|
|
254
|
+
agentName ?? undefined,
|
|
237
255
|
),
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
256
|
+
unavailableReason: `Accessing '${externalDirectoryPath}' outside the working directory requires approval, but no interactive UI is available.`,
|
|
257
|
+
userDeniedReason: (decision) =>
|
|
258
|
+
formatExternalDirectoryUserDeniedReason(
|
|
259
|
+
toolName,
|
|
260
|
+
externalDirectoryPath,
|
|
261
|
+
decision.denialReason,
|
|
262
|
+
),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
if (extDirGate.action === "block") {
|
|
266
|
+
return { block: true, reason: extDirGate.reason };
|
|
267
|
+
}
|
|
243
268
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
269
|
+
if (extDirDecision?.state === "approved_for_session") {
|
|
270
|
+
const pattern = deriveApprovalPattern(normalizedExtPath);
|
|
271
|
+
deps.runtime.sessionRules.approve("external_directory", pattern);
|
|
272
|
+
}
|
|
247
273
|
}
|
|
248
|
-
}
|
|
274
|
+
} // end else (not Pi infrastructure read)
|
|
249
275
|
// Fall through to normal permission check
|
|
250
276
|
}
|
|
251
277
|
|
package/src/runtime.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
normalizePermissionSystemConfig,
|
|
35
35
|
type PermissionSystemExtensionConfig,
|
|
36
36
|
} from "./extension-config";
|
|
37
|
+
import { discoverGlobalNodeModulesRoot } from "./external-directory";
|
|
37
38
|
import {
|
|
38
39
|
type PermissionForwardingDeps,
|
|
39
40
|
processForwardedPermissionRequests,
|
|
@@ -64,6 +65,14 @@ export interface ExtensionRuntime {
|
|
|
64
65
|
readonly subagentSessionsDir: string;
|
|
65
66
|
readonly forwardingDir: string;
|
|
66
67
|
readonly globalLogsDir: string;
|
|
68
|
+
/**
|
|
69
|
+
* Static Pi infrastructure directories used for external-directory
|
|
70
|
+
* read auto-allow. Computed once at construction from `agentDir` and
|
|
71
|
+
* `discoverGlobalNodeModulesRoot()`. Config-based extras
|
|
72
|
+
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
|
73
|
+
* call time in the handler so they pick up config reloads.
|
|
74
|
+
*/
|
|
75
|
+
readonly piInfrastructureDirs: string[];
|
|
67
76
|
|
|
68
77
|
// ── Mutable state ──────────────────────────────────────────────────────
|
|
69
78
|
config: PermissionSystemExtensionConfig;
|
|
@@ -341,6 +350,13 @@ export function createExtensionRuntime(options?: {
|
|
|
341
350
|
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
|
342
351
|
const globalLogsDir = getGlobalLogsDir(agentDir);
|
|
343
352
|
|
|
353
|
+
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
|
|
354
|
+
const piInfrastructureDirs: string[] = [
|
|
355
|
+
agentDir,
|
|
356
|
+
join(agentDir, "git"),
|
|
357
|
+
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
|
358
|
+
];
|
|
359
|
+
|
|
344
360
|
// Build a plain-object runtime first so the logger's `getConfig` closure
|
|
345
361
|
// can reference `runtime.config` directly (always reads current value).
|
|
346
362
|
const runtime: ExtensionRuntime = {
|
|
@@ -349,6 +365,7 @@ export function createExtensionRuntime(options?: {
|
|
|
349
365
|
subagentSessionsDir,
|
|
350
366
|
forwardingDir,
|
|
351
367
|
globalLogsDir,
|
|
368
|
+
piInfrastructureDirs,
|
|
352
369
|
config: { ...DEFAULT_EXTENSION_CONFIG },
|
|
353
370
|
runtimeContext: null,
|
|
354
371
|
permissionManager: createPermissionManagerForCwd(agentDir, undefined),
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { expandHomePath } from "./expand-home";
|
|
2
|
+
|
|
1
3
|
export type CompiledWildcardPattern<TState> = {
|
|
2
4
|
pattern: string;
|
|
3
5
|
state: TState;
|
|
@@ -18,7 +20,8 @@ export function compileWildcardPattern<TState>(
|
|
|
18
20
|
pattern: string,
|
|
19
21
|
state: TState,
|
|
20
22
|
): CompiledWildcardPattern<TState> {
|
|
21
|
-
const
|
|
23
|
+
const expanded = expandHomePath(pattern);
|
|
24
|
+
const escaped = expanded
|
|
22
25
|
.split("*")
|
|
23
26
|
.map((part) => escapeRegExp(part))
|
|
24
27
|
.join(".*");
|