@gotgenes/pi-permission-system 4.8.0 → 5.0.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 +43 -0
- package/README.md +18 -5
- package/config/config.example.json +2 -0
- package/package.json +1 -1
- package/schemas/permissions.schema.json +10 -0
- package/src/config-modal.ts +25 -3
- package/src/extension-config.ts +13 -1
- package/src/external-directory.ts +96 -1
- package/src/handlers/tool-call.ts +87 -61
- package/src/index.ts +4 -0
- package/src/normalize.ts +2 -2
- package/src/permission-manager.ts +72 -17
- package/src/rule.ts +26 -2
- package/src/runtime.ts +17 -0
- package/src/session-rules.ts +7 -1
- package/src/synthesize.ts +7 -2
- package/src/tool-input-preview.ts +7 -1
- package/src/types.ts +6 -0
- package/tests/bash-external-directory.test.ts +50 -0
- package/tests/config-modal.test.ts +83 -0
- package/tests/handlers/tool-call.test.ts +149 -1
- package/tests/normalize.test.ts +64 -22
- package/tests/permission-manager-unified.test.ts +215 -0
- package/tests/permission-prompts.test.ts +8 -1
- package/tests/permission-system.test.ts +12 -0
- package/tests/pi-infrastructure-read.test.ts +245 -0
- package/tests/rule.test.ts +76 -8
- package/tests/runtime.test.ts +45 -0
- package/tests/session-rules.test.ts +7 -1
- package/tests/skill-prompt-sanitizer.test.ts +1 -1
- package/tests/synthesize.test.ts +64 -4
- package/tests/tool-input-preview.test.ts +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ 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
|
+
## [5.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* Rule.origin and PermissionCheckResult.origin are now required fields. Code that constructs Rule or PermissionCheckResult literals must include an origin value.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* add RuleOrigin type and origin field to Rule ([b4452d1](https://github.com/gotgenes/pi-permission-system/commit/b4452d1cc9e87a8315edcd6f5f2b1425310bd0b6))
|
|
18
|
+
* display rule origins in /permission-system show output ([af34c8e](https://github.com/gotgenes/pi-permission-system/commit/af34c8e808c7fa67bbe68635f776ec0fd8717bfa))
|
|
19
|
+
* include rule origin in permission review log entries ([b19fdf6](https://github.com/gotgenes/pi-permission-system/commit/b19fdf69b48248430410643ee20bee58535b99d9))
|
|
20
|
+
* make Rule.origin and PermissionCheckResult.origin required ([937a9f5](https://github.com/gotgenes/pi-permission-system/commit/937a9f5c4a9442611606fa3b27962555ed8c25a9))
|
|
21
|
+
* propagate origin to synthesized default rule ([04f9130](https://github.com/gotgenes/pi-permission-system/commit/04f91304ec5ba975ac512989c90757528a30ef7b))
|
|
22
|
+
* track and propagate rule origin through checkPermission ([327bc60](https://github.com/gotgenes/pi-permission-system/commit/327bc60e7f79aafd19995337f62244fd8b0c191f))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* plan rule origin provenance tracking ([#88](https://github.com/gotgenes/pi-permission-system/issues/88)) ([d8f8840](https://github.com/gotgenes/pi-permission-system/commit/d8f884028f03682a896dd0d6e8e5a335d8e669f5))
|
|
28
|
+
* **retro:** add retro notes for issue [#48](https://github.com/gotgenes/pi-permission-system/issues/48) ([2187a53](https://github.com/gotgenes/pi-permission-system/commit/2187a53d2af30d6a68f664b1ce4af0dc30b39061))
|
|
29
|
+
* update target architecture for required Rule.origin ([edf0620](https://github.com/gotgenes/pi-permission-system/commit/edf06209ce148b70131a5abf361070571db51e7b))
|
|
30
|
+
* update target architecture for rule origin provenance ([c82435b](https://github.com/gotgenes/pi-permission-system/commit/c82435bb75dd7b22331986c6a23bfe5cf1849ca7))
|
|
31
|
+
|
|
32
|
+
## [4.9.0](https://github.com/gotgenes/pi-permission-system/compare/v4.8.0...v4.9.0) (2026-05-05)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
### Features
|
|
36
|
+
|
|
37
|
+
* bypass external_directory gate for Pi infrastructure reads ([229a352](https://github.com/gotgenes/pi-permission-system/commit/229a35222dd47f1d0c079f0bcd34760569e912f3))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
### Bug Fixes
|
|
41
|
+
|
|
42
|
+
* skip regex patterns in bash external-directory path extraction ([9fe4ba6](https://github.com/gotgenes/pi-permission-system/commit/9fe4ba6d259c25aa0a9e3a5508884d26a303cac3))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Documentation
|
|
46
|
+
|
|
47
|
+
* document piInfrastructureReadPaths config and infrastructure auto-allow ([65e0ac8](https://github.com/gotgenes/pi-permission-system/commit/65e0ac8ef8a4973c261628e026c3772faa0849ab))
|
|
48
|
+
* 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))
|
|
49
|
+
* **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))
|
|
50
|
+
|
|
8
51
|
## [4.8.0](https://github.com/gotgenes/pi-permission-system/compare/v4.7.0...v4.8.0) (2026-05-05)
|
|
9
52
|
|
|
10
53
|
|
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.
|
|
@@ -372,6 +374,17 @@ Quoted strings are stripped first to reduce false positives.
|
|
|
372
374
|
This is a best-effort heuristic — variable expansion, subshells, and escaped quotes are not parsed.
|
|
373
375
|
OS device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are always excluded.
|
|
374
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
|
+
|
|
375
388
|
---
|
|
376
389
|
|
|
377
390
|
## Common Recipes
|
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.",
|
package/src/config-modal.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
DEFAULT_EXTENSION_CONFIG,
|
|
10
10
|
type PermissionSystemExtensionConfig,
|
|
11
11
|
} from "./extension-config";
|
|
12
|
+
import type { Ruleset } from "./rule";
|
|
12
13
|
|
|
13
14
|
interface PermissionSystemConfigController {
|
|
14
15
|
getConfig(): PermissionSystemExtensionConfig;
|
|
@@ -17,6 +18,8 @@ interface PermissionSystemConfigController {
|
|
|
17
18
|
ctx: ExtensionCommandContext,
|
|
18
19
|
): void;
|
|
19
20
|
getConfigPath(): string;
|
|
21
|
+
/** Optional: returns the composed config-layer ruleset for origin display. */
|
|
22
|
+
getComposedRules?(): Ruleset;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
const ON_OFF = ["on", "off"];
|
|
@@ -57,12 +60,30 @@ function toOnOff(value: boolean): string {
|
|
|
57
60
|
return value ? "on" : "off";
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
function
|
|
61
|
-
|
|
63
|
+
function formatRulesSummary(rules: Ruleset): string {
|
|
64
|
+
const configRules = rules.filter((r) => r.layer === "config" && r.origin);
|
|
65
|
+
if (configRules.length === 0) return "";
|
|
66
|
+
const formatted = configRules
|
|
67
|
+
.map((r) => {
|
|
68
|
+
const key =
|
|
69
|
+
r.pattern === "*" ? r.surface : `${r.surface}["${r.pattern}"]`;
|
|
70
|
+
return `${key}=${r.action} (${r.origin})`;
|
|
71
|
+
})
|
|
72
|
+
.join(", ");
|
|
73
|
+
return `\n rules: ${formatted}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function summarizeConfig(
|
|
77
|
+
config: PermissionSystemExtensionConfig,
|
|
78
|
+
rules?: Ruleset,
|
|
79
|
+
): string {
|
|
80
|
+
const knobs = [
|
|
62
81
|
`yoloMode=${toOnOff(config.yoloMode)}`,
|
|
63
82
|
`permissionReviewLog=${toOnOff(config.permissionReviewLog)}`,
|
|
64
83
|
`debugLog=${toOnOff(config.debugLog)}`,
|
|
65
84
|
].join(", ");
|
|
85
|
+
const rulesSuffix = rules ? formatRulesSummary(rules) : "";
|
|
86
|
+
return `${knobs}${rulesSuffix}`;
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
function buildSettingItems(
|
|
@@ -183,8 +204,9 @@ function handleArgs(
|
|
|
183
204
|
}
|
|
184
205
|
|
|
185
206
|
if (normalized === "show") {
|
|
207
|
+
const rules = controller.getComposedRules?.();
|
|
186
208
|
ctx.ui.notify(
|
|
187
|
-
`permission-system: ${summarizeConfig(controller.getConfig())}`,
|
|
209
|
+
`permission-system: ${summarizeConfig(controller.getConfig(), rules)}`,
|
|
188
210
|
"info",
|
|
189
211
|
);
|
|
190
212
|
return true;
|
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/index.ts
CHANGED
|
@@ -58,6 +58,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
58
58
|
getConfig: () => runtime.config,
|
|
59
59
|
setConfig: (next, ctx) => saveExtensionConfig(runtime, next, ctx),
|
|
60
60
|
getConfigPath: () => getGlobalConfigPath(runtime.agentDir),
|
|
61
|
+
getComposedRules: () =>
|
|
62
|
+
runtime.permissionManager.getComposedConfigRules(
|
|
63
|
+
runtime.lastKnownActiveAgentName ?? undefined,
|
|
64
|
+
),
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
const createPermissionRequestId = (prefix: string): string =>
|
package/src/normalize.ts
CHANGED
|
@@ -18,12 +18,12 @@ export function normalizeFlatConfig(permission: FlatPermissionConfig): Ruleset {
|
|
|
18
18
|
for (const [surface, value] of Object.entries(permission)) {
|
|
19
19
|
if (typeof value === "string") {
|
|
20
20
|
if (isPermissionState(value)) {
|
|
21
|
-
rules.push({ surface, pattern: "*", action: value });
|
|
21
|
+
rules.push({ surface, pattern: "*", action: value, origin: "builtin" });
|
|
22
22
|
}
|
|
23
23
|
} else if (typeof value === "object" && value !== null) {
|
|
24
24
|
for (const [pattern, action] of Object.entries(value)) {
|
|
25
25
|
if (isPermissionState(action)) {
|
|
26
|
-
rules.push({ surface, pattern, action });
|
|
26
|
+
rules.push({ surface, pattern, action, origin: "builtin" });
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
}
|