@gotgenes/pi-permission-system 5.6.2 → 5.7.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 +26 -0
- package/package.json +1 -1
- package/src/extension-paths.ts +55 -0
- package/src/handlers/gates/bash-external-directory.ts +6 -6
- package/src/{external-directory.ts → handlers/gates/bash-path-extractor.ts} +3 -248
- package/src/handlers/gates/external-directory-messages.ts +54 -0
- package/src/handlers/gates/external-directory.ts +7 -5
- package/src/handlers/gates/tool.ts +1 -1
- package/src/handlers/types.ts +1 -1
- package/src/node-modules-discovery.ts +76 -0
- package/src/path-utils.ts +110 -0
- package/src/runtime.ts +11 -38
- package/tests/bash-external-directory.test.ts +2 -2
- package/tests/extension-paths.test.ts +89 -0
- package/tests/handlers/gates/external-directory-messages.test.ts +137 -0
- package/tests/node-modules-discovery.test.ts +97 -0
- package/tests/path-utils.test.ts +201 -0
- package/tests/pi-infrastructure-read.test.ts +2 -4
- package/tests/runtime.test.ts +1 -1
- package/tests/external-directory.test.ts +0 -346
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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.7.0](https://github.com/gotgenes/pi-permission-system/compare/v5.6.3...v5.7.0) (2026-05-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* extract ExtensionPaths value object ([#126](https://github.com/gotgenes/pi-permission-system/issues/126)) ([85bc347](https://github.com/gotgenes/pi-permission-system/commit/85bc347d3ed487210ffbed4c1c53616b5cf0d978))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* add handler decomposition plan ([#126](https://github.com/gotgenes/pi-permission-system/issues/126), [#127](https://github.com/gotgenes/pi-permission-system/issues/127), [#128](https://github.com/gotgenes/pi-permission-system/issues/128), [#129](https://github.com/gotgenes/pi-permission-system/issues/129), [#130](https://github.com/gotgenes/pi-permission-system/issues/130)) ([5a116a6](https://github.com/gotgenes/pi-permission-system/commit/5a116a6cf2f6ef29f5e6550821bb26b6e1c3a90f))
|
|
19
|
+
* add structural design heuristics, design-review skill, and plan-issue hook ([d8e3233](https://github.com/gotgenes/pi-permission-system/commit/d8e32330baa25fa5a5abaf75f8e442ce650fe5a9))
|
|
20
|
+
* extract code-style, testing, and markdown-conventions skills from AGENTS.md ([9d5ba7a](https://github.com/gotgenes/pi-permission-system/commit/9d5ba7a4a4a7a9e9fbdfe869fb42840238351b81))
|
|
21
|
+
* plan ExtensionPaths value object extraction ([#126](https://github.com/gotgenes/pi-permission-system/issues/126)) ([d76e6cc](https://github.com/gotgenes/pi-permission-system/commit/d76e6cc255dc124a7a914e8d178113e5b7c8bddd))
|
|
22
|
+
* rename target-architecture to architecture, strip progress indicators ([9776550](https://github.com/gotgenes/pi-permission-system/commit/9776550351f3b59f96bdad614cc5129a7be52a51))
|
|
23
|
+
* **retro:** add retro notes for issue [#110](https://github.com/gotgenes/pi-permission-system/issues/110) ([5597de3](https://github.com/gotgenes/pi-permission-system/commit/5597de3c9c64c3d672a1fc77ba7910e952545824))
|
|
24
|
+
|
|
25
|
+
## [5.6.3](https://github.com/gotgenes/pi-permission-system/compare/v5.6.2...v5.6.3) (2026-05-07)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
* **retro:** add retro notes for issue [#109](https://github.com/gotgenes/pi-permission-system/issues/109) ([7d46cf4](https://github.com/gotgenes/pi-permission-system/commit/7d46cf4576d13d1d348355de88fb3dda6297be5a))
|
|
31
|
+
* update architecture for external-directory split ([#110](https://github.com/gotgenes/pi-permission-system/issues/110)) ([2e86fe7](https://github.com/gotgenes/pi-permission-system/commit/2e86fe79bc5de8076f775949824adadd4d366d7c))
|
|
32
|
+
* update plan for [#110](https://github.com/gotgenes/pi-permission-system/issues/110) after [#109](https://github.com/gotgenes/pi-permission-system/issues/109) landed ([3541d57](https://github.com/gotgenes/pi-permission-system/commit/3541d5739385b77ea8b21752595efd5cb75a6789))
|
|
33
|
+
|
|
8
34
|
## [5.6.2](https://github.com/gotgenes/pi-permission-system/compare/v5.6.1...v5.6.2) (2026-05-07)
|
|
9
35
|
|
|
10
36
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { getGlobalLogsDir } from "./config-paths";
|
|
3
|
+
import { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Immutable path constants derived from `agentDir` at construction time.
|
|
7
|
+
*
|
|
8
|
+
* Computed once at startup in `computeExtensionPaths()` and embedded into
|
|
9
|
+
* `ExtensionRuntime`. Later refactorings (#129 PermissionSession, #130
|
|
10
|
+
* handler classes) consume this as a single dep instead of individual fields.
|
|
11
|
+
*/
|
|
12
|
+
export interface ExtensionPaths {
|
|
13
|
+
readonly agentDir: string;
|
|
14
|
+
readonly sessionsDir: string;
|
|
15
|
+
readonly subagentSessionsDir: string;
|
|
16
|
+
readonly forwardingDir: string;
|
|
17
|
+
readonly globalLogsDir: string;
|
|
18
|
+
/**
|
|
19
|
+
* Static Pi infrastructure directories used for external-directory
|
|
20
|
+
* read auto-allow. Computed once from `agentDir` and
|
|
21
|
+
* `discoverGlobalNodeModulesRoot()`. Config-based extras
|
|
22
|
+
* (`piInfrastructureReadPaths`) are read from `runtime.config` at
|
|
23
|
+
* call time in the handler so they pick up config reloads.
|
|
24
|
+
*/
|
|
25
|
+
readonly piInfrastructureDirs: readonly string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compute all immutable path constants from `agentDir`.
|
|
30
|
+
*
|
|
31
|
+
* Calls `discoverGlobalNodeModulesRoot()` internally so the result is
|
|
32
|
+
* self-contained. Call this once at extension startup, not at module scope.
|
|
33
|
+
*/
|
|
34
|
+
export function computeExtensionPaths(agentDir: string): ExtensionPaths {
|
|
35
|
+
const sessionsDir = join(agentDir, "sessions");
|
|
36
|
+
const subagentSessionsDir = join(agentDir, "subagent-sessions");
|
|
37
|
+
const forwardingDir = join(sessionsDir, "permission-forwarding");
|
|
38
|
+
const globalLogsDir = getGlobalLogsDir(agentDir);
|
|
39
|
+
|
|
40
|
+
const globalNodeModulesRoot = discoverGlobalNodeModulesRoot();
|
|
41
|
+
const piInfrastructureDirs: string[] = [
|
|
42
|
+
agentDir,
|
|
43
|
+
join(agentDir, "git"),
|
|
44
|
+
...(globalNodeModulesRoot ? [globalNodeModulesRoot] : []),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
agentDir,
|
|
49
|
+
sessionsDir,
|
|
50
|
+
subagentSessionsDir,
|
|
51
|
+
forwardingDir,
|
|
52
|
+
globalLogsDir,
|
|
53
|
+
piInfrastructureDirs,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { getNonEmptyString, toRecord } from "../../common";
|
|
2
|
-
import {
|
|
3
|
-
extractExternalPathsFromBashCommand,
|
|
4
|
-
formatBashExternalDirectoryAskPrompt,
|
|
5
|
-
formatBashExternalDirectoryDenyReason,
|
|
6
|
-
formatExternalDirectoryHardStopHint,
|
|
7
|
-
} from "../../external-directory";
|
|
8
2
|
import type { Rule } from "../../rule";
|
|
9
3
|
import { deriveApprovalPattern } from "../../session-rules";
|
|
10
4
|
import type { PermissionCheckResult } from "../../types";
|
|
5
|
+
import { extractExternalPathsFromBashCommand } from "./bash-path-extractor";
|
|
11
6
|
import type { GateResult } from "./descriptor";
|
|
7
|
+
import {
|
|
8
|
+
formatBashExternalDirectoryAskPrompt,
|
|
9
|
+
formatBashExternalDirectoryDenyReason,
|
|
10
|
+
formatExternalDirectoryHardStopHint,
|
|
11
|
+
} from "./external-directory-messages";
|
|
12
12
|
import type { ToolCallContext } from "./types";
|
|
13
13
|
|
|
14
14
|
/** Function type for checkPermission used by the descriptor factory. */
|
|
@@ -1,255 +1,10 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
1
|
import { createRequire } from "node:module";
|
|
4
|
-
import { basename
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
|
|
7
|
-
import { getNonEmptyString, toRecord } from "./common";
|
|
8
|
-
|
|
9
|
-
export {
|
|
10
|
-
isPathWithinDirectory,
|
|
11
|
-
normalizePathForComparison,
|
|
12
|
-
} from "./path-utils";
|
|
2
|
+
import { basename } from "node:path";
|
|
13
3
|
|
|
14
4
|
import {
|
|
15
|
-
|
|
5
|
+
isPathOutsideWorkingDirectory,
|
|
16
6
|
normalizePathForComparison,
|
|
17
|
-
} from "
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Walk up the directory tree from the given file URL until a directory
|
|
21
|
-
* literally named `node_modules` is found.
|
|
22
|
-
*
|
|
23
|
-
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
|
24
|
-
* no `node_modules` ancestor exists.
|
|
25
|
-
*/
|
|
26
|
-
function walkUpToNodeModules(fromUrl: string): string | null {
|
|
27
|
-
try {
|
|
28
|
-
const thisFile = fileURLToPath(fromUrl);
|
|
29
|
-
let dir = dirname(thisFile);
|
|
30
|
-
while (dir !== dirname(dir)) {
|
|
31
|
-
if (basename(dir) === "node_modules") {
|
|
32
|
-
return dir;
|
|
33
|
-
}
|
|
34
|
-
dir = dirname(dir);
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
|
44
|
-
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
|
45
|
-
*
|
|
46
|
-
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
|
47
|
-
* running from a local development checkout, not a global install).
|
|
48
|
-
*/
|
|
49
|
-
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
|
50
|
-
try {
|
|
51
|
-
const result = spawnSync("npm", ["root", "-g"], {
|
|
52
|
-
encoding: "utf-8",
|
|
53
|
-
timeout: 5000,
|
|
54
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
55
|
-
});
|
|
56
|
-
const root = result.stdout?.trim();
|
|
57
|
-
if (result.status === 0 && root && existsSync(root)) {
|
|
58
|
-
return root;
|
|
59
|
-
}
|
|
60
|
-
return null;
|
|
61
|
-
} catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Discover the global node_modules root.
|
|
68
|
-
*
|
|
69
|
-
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
|
70
|
-
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
|
71
|
-
* directory named `node_modules`. This works whenever the extension is
|
|
72
|
-
* installed inside a `node_modules` tree.
|
|
73
|
-
*
|
|
74
|
-
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
|
75
|
-
* because the extension is running from a local development checkout with no
|
|
76
|
-
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
|
77
|
-
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
|
78
|
-
* returns the correct root regardless of the user's own project package
|
|
79
|
-
* manager.
|
|
80
|
-
*
|
|
81
|
-
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
|
82
|
-
*/
|
|
83
|
-
export function discoverGlobalNodeModulesRoot(
|
|
84
|
-
fromUrl = import.meta.url,
|
|
85
|
-
): string | null {
|
|
86
|
-
const fromSelf = walkUpToNodeModules(fromUrl);
|
|
87
|
-
if (fromSelf) return fromSelf;
|
|
88
|
-
return discoverGlobalNodeModulesViaSubprocess();
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Paths that are universally safe and should never trigger external-directory checks.
|
|
93
|
-
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
94
|
-
*/
|
|
95
|
-
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
96
|
-
"/dev/null",
|
|
97
|
-
"/dev/stdin",
|
|
98
|
-
"/dev/stdout",
|
|
99
|
-
"/dev/stderr",
|
|
100
|
-
]);
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Returns true if the given normalized path is a safe OS device file
|
|
104
|
-
* that should never trigger external-directory checks.
|
|
105
|
-
*/
|
|
106
|
-
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
107
|
-
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Returns true if the given tool + normalized path combination qualifies for
|
|
112
|
-
* automatic allow as a Pi infrastructure read.
|
|
113
|
-
*
|
|
114
|
-
* A path qualifies when:
|
|
115
|
-
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
116
|
-
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
117
|
-
* OR within the project-local Pi package directories
|
|
118
|
-
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
119
|
-
*
|
|
120
|
-
* `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
|
|
121
|
-
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
122
|
-
* follow working-directory changes without a runtime rebuild.
|
|
123
|
-
*/
|
|
124
|
-
export function isPiInfrastructureRead(
|
|
125
|
-
toolName: string,
|
|
126
|
-
normalizedPath: string,
|
|
127
|
-
infrastructureDirs: readonly string[],
|
|
128
|
-
cwd: string,
|
|
129
|
-
): boolean {
|
|
130
|
-
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
for (const dir of infrastructureDirs) {
|
|
135
|
-
if (isPathWithinDirectory(normalizedPath, dir)) {
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
141
|
-
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
142
|
-
const projectGitDir = join(cwd, ".pi", "git");
|
|
143
|
-
if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return false;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* File tools that only read — never write — the filesystem.
|
|
155
|
-
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
156
|
-
*/
|
|
157
|
-
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
158
|
-
"read",
|
|
159
|
-
"find",
|
|
160
|
-
"grep",
|
|
161
|
-
"ls",
|
|
162
|
-
]);
|
|
163
|
-
|
|
164
|
-
export const PATH_BEARING_TOOLS = new Set([
|
|
165
|
-
"read",
|
|
166
|
-
"write",
|
|
167
|
-
"edit",
|
|
168
|
-
"find",
|
|
169
|
-
"grep",
|
|
170
|
-
"ls",
|
|
171
|
-
]);
|
|
172
|
-
|
|
173
|
-
export function getPathBearingToolPath(
|
|
174
|
-
toolName: string,
|
|
175
|
-
input: unknown,
|
|
176
|
-
): string | null {
|
|
177
|
-
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
178
|
-
return null;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return getNonEmptyString(toRecord(input).path);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function isPathOutsideWorkingDirectory(
|
|
185
|
-
pathValue: string,
|
|
186
|
-
cwd: string,
|
|
187
|
-
): boolean {
|
|
188
|
-
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
189
|
-
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
190
|
-
if (!normalizedCwd || !normalizedPath) {
|
|
191
|
-
return false;
|
|
192
|
-
}
|
|
193
|
-
if (isSafeSystemPath(normalizedPath)) {
|
|
194
|
-
return false;
|
|
195
|
-
}
|
|
196
|
-
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
export function formatExternalDirectoryHardStopHint(): string {
|
|
200
|
-
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.";
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
export function formatExternalDirectoryAskPrompt(
|
|
204
|
-
toolName: string,
|
|
205
|
-
pathValue: string,
|
|
206
|
-
cwd: string,
|
|
207
|
-
agentName?: string,
|
|
208
|
-
): string {
|
|
209
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
210
|
-
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export function formatExternalDirectoryDenyReason(
|
|
214
|
-
toolName: string,
|
|
215
|
-
pathValue: string,
|
|
216
|
-
cwd: string,
|
|
217
|
-
agentName?: string,
|
|
218
|
-
): string {
|
|
219
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
220
|
-
return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function formatExternalDirectoryUserDeniedReason(
|
|
224
|
-
toolName: string,
|
|
225
|
-
pathValue: string,
|
|
226
|
-
denialReason?: string,
|
|
227
|
-
): string {
|
|
228
|
-
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
229
|
-
return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
export function formatBashExternalDirectoryAskPrompt(
|
|
233
|
-
command: string,
|
|
234
|
-
externalPaths: string[],
|
|
235
|
-
cwd: string,
|
|
236
|
-
agentName?: string,
|
|
237
|
-
): string {
|
|
238
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
239
|
-
const pathList = externalPaths.join(", ");
|
|
240
|
-
return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export function formatBashExternalDirectoryDenyReason(
|
|
244
|
-
command: string,
|
|
245
|
-
externalPaths: string[],
|
|
246
|
-
cwd: string,
|
|
247
|
-
agentName?: string,
|
|
248
|
-
): string {
|
|
249
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
250
|
-
const pathList = externalPaths.join(", ");
|
|
251
|
-
return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
|
|
252
|
-
}
|
|
7
|
+
} from "../../path-utils";
|
|
253
8
|
|
|
254
9
|
// ── tree-sitter-bash lazy parser ───────────────────────────────────────────
|
|
255
10
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export function formatExternalDirectoryHardStopHint(): string {
|
|
2
|
+
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.";
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function formatExternalDirectoryAskPrompt(
|
|
6
|
+
toolName: string,
|
|
7
|
+
pathValue: string,
|
|
8
|
+
cwd: string,
|
|
9
|
+
agentName?: string,
|
|
10
|
+
): string {
|
|
11
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
12
|
+
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function formatExternalDirectoryDenyReason(
|
|
16
|
+
toolName: string,
|
|
17
|
+
pathValue: string,
|
|
18
|
+
cwd: string,
|
|
19
|
+
agentName?: string,
|
|
20
|
+
): string {
|
|
21
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
22
|
+
return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function formatExternalDirectoryUserDeniedReason(
|
|
26
|
+
toolName: string,
|
|
27
|
+
pathValue: string,
|
|
28
|
+
denialReason?: string,
|
|
29
|
+
): string {
|
|
30
|
+
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
31
|
+
return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatBashExternalDirectoryAskPrompt(
|
|
35
|
+
command: string,
|
|
36
|
+
externalPaths: string[],
|
|
37
|
+
cwd: string,
|
|
38
|
+
agentName?: string,
|
|
39
|
+
): string {
|
|
40
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
41
|
+
const pathList = externalPaths.join(", ");
|
|
42
|
+
return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function formatBashExternalDirectoryDenyReason(
|
|
46
|
+
command: string,
|
|
47
|
+
externalPaths: string[],
|
|
48
|
+
cwd: string,
|
|
49
|
+
agentName?: string,
|
|
50
|
+
): string {
|
|
51
|
+
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
52
|
+
const pathList = externalPaths.join(", ");
|
|
53
|
+
return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
|
|
54
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import {
|
|
2
|
-
formatExternalDirectoryAskPrompt,
|
|
3
|
-
formatExternalDirectoryDenyReason,
|
|
4
|
-
formatExternalDirectoryUserDeniedReason,
|
|
5
2
|
getPathBearingToolPath,
|
|
6
3
|
isPathOutsideWorkingDirectory,
|
|
7
4
|
isPiInfrastructureRead,
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
normalizePathForComparison,
|
|
6
|
+
} from "../../path-utils";
|
|
10
7
|
import { deriveApprovalPattern } from "../../session-rules";
|
|
11
8
|
import type { GateResult } from "./descriptor";
|
|
9
|
+
import {
|
|
10
|
+
formatExternalDirectoryAskPrompt,
|
|
11
|
+
formatExternalDirectoryDenyReason,
|
|
12
|
+
formatExternalDirectoryUserDeniedReason,
|
|
13
|
+
} from "./external-directory-messages";
|
|
12
14
|
import type { ToolCallContext } from "./types";
|
|
13
15
|
|
|
14
16
|
/**
|
package/src/handlers/types.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface HandlerDeps {
|
|
|
42
42
|
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
43
43
|
|
|
44
44
|
// ── Immutable infrastructure paths ───────────────────────────────────
|
|
45
|
-
readonly piInfrastructureDirs: string[];
|
|
45
|
+
readonly piInfrastructureDirs: readonly string[];
|
|
46
46
|
/** Returns config-derived infrastructure read paths (current at call time). */
|
|
47
47
|
getPiInfrastructureReadPaths(): string[];
|
|
48
48
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { basename, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Walk up the directory tree from the given file URL until a directory
|
|
8
|
+
* literally named `node_modules` is found.
|
|
9
|
+
*
|
|
10
|
+
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
|
11
|
+
* no `node_modules` ancestor exists.
|
|
12
|
+
*/
|
|
13
|
+
function walkUpToNodeModules(fromUrl: string): string | null {
|
|
14
|
+
try {
|
|
15
|
+
const thisFile = fileURLToPath(fromUrl);
|
|
16
|
+
let dir = dirname(thisFile);
|
|
17
|
+
while (dir !== dirname(dir)) {
|
|
18
|
+
if (basename(dir) === "node_modules") {
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
dir = dirname(dir);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
|
31
|
+
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
|
32
|
+
*
|
|
33
|
+
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
|
34
|
+
* running from a local development checkout, not a global install).
|
|
35
|
+
*/
|
|
36
|
+
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
|
37
|
+
try {
|
|
38
|
+
const result = spawnSync("npm", ["root", "-g"], {
|
|
39
|
+
encoding: "utf-8",
|
|
40
|
+
timeout: 5000,
|
|
41
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
42
|
+
});
|
|
43
|
+
const root = result.stdout?.trim();
|
|
44
|
+
if (result.status === 0 && root && existsSync(root)) {
|
|
45
|
+
return root;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Discover the global node_modules root.
|
|
55
|
+
*
|
|
56
|
+
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
|
57
|
+
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
|
58
|
+
* directory named `node_modules`. This works whenever the extension is
|
|
59
|
+
* installed inside a `node_modules` tree.
|
|
60
|
+
*
|
|
61
|
+
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
|
62
|
+
* because the extension is running from a local development checkout with no
|
|
63
|
+
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
|
64
|
+
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
|
65
|
+
* returns the correct root regardless of the user's own project package
|
|
66
|
+
* manager.
|
|
67
|
+
*
|
|
68
|
+
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
|
69
|
+
*/
|
|
70
|
+
export function discoverGlobalNodeModulesRoot(
|
|
71
|
+
fromUrl = import.meta.url,
|
|
72
|
+
): string | null {
|
|
73
|
+
const fromSelf = walkUpToNodeModules(fromUrl);
|
|
74
|
+
if (fromSelf) return fromSelf;
|
|
75
|
+
return discoverGlobalNodeModulesViaSubprocess();
|
|
76
|
+
}
|
package/src/path-utils.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join, normalize, resolve, sep } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { getNonEmptyString, toRecord } from "./common";
|
|
5
|
+
|
|
4
6
|
export function normalizePathForComparison(
|
|
5
7
|
pathValue: string,
|
|
6
8
|
cwd: string,
|
|
@@ -43,3 +45,111 @@ export function isPathWithinDirectory(
|
|
|
43
45
|
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
44
46
|
return pathValue.startsWith(prefix);
|
|
45
47
|
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Paths that are universally safe and should never trigger external-directory checks.
|
|
51
|
+
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
52
|
+
*/
|
|
53
|
+
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
54
|
+
"/dev/null",
|
|
55
|
+
"/dev/stdin",
|
|
56
|
+
"/dev/stdout",
|
|
57
|
+
"/dev/stderr",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns true if the given normalized path is a safe OS device file
|
|
62
|
+
* that should never trigger external-directory checks.
|
|
63
|
+
*/
|
|
64
|
+
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
65
|
+
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* File tools that only read — never write — the filesystem.
|
|
70
|
+
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
71
|
+
*/
|
|
72
|
+
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
73
|
+
"read",
|
|
74
|
+
"find",
|
|
75
|
+
"grep",
|
|
76
|
+
"ls",
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
export const PATH_BEARING_TOOLS = new Set([
|
|
80
|
+
"read",
|
|
81
|
+
"write",
|
|
82
|
+
"edit",
|
|
83
|
+
"find",
|
|
84
|
+
"grep",
|
|
85
|
+
"ls",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
export function getPathBearingToolPath(
|
|
89
|
+
toolName: string,
|
|
90
|
+
input: unknown,
|
|
91
|
+
): string | null {
|
|
92
|
+
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return getNonEmptyString(toRecord(input).path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function isPathOutsideWorkingDirectory(
|
|
100
|
+
pathValue: string,
|
|
101
|
+
cwd: string,
|
|
102
|
+
): boolean {
|
|
103
|
+
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
104
|
+
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
105
|
+
if (!normalizedCwd || !normalizedPath) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
if (isSafeSystemPath(normalizedPath)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns true if the given tool + normalized path combination qualifies for
|
|
116
|
+
* automatic allow as a Pi infrastructure read.
|
|
117
|
+
*
|
|
118
|
+
* A path qualifies when:
|
|
119
|
+
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
120
|
+
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
121
|
+
* OR within the project-local Pi package directories
|
|
122
|
+
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
123
|
+
*
|
|
124
|
+
* `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
|
|
125
|
+
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
126
|
+
* follow working-directory changes without a runtime rebuild.
|
|
127
|
+
*/
|
|
128
|
+
export function isPiInfrastructureRead(
|
|
129
|
+
toolName: string,
|
|
130
|
+
normalizedPath: string,
|
|
131
|
+
infrastructureDirs: readonly string[],
|
|
132
|
+
cwd: string,
|
|
133
|
+
): boolean {
|
|
134
|
+
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const dir of infrastructureDirs) {
|
|
139
|
+
if (isPathWithinDirectory(normalizedPath, dir)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
145
|
+
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
146
|
+
const projectGitDir = join(cwd, ".pi", "git");
|
|
147
|
+
if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return false;
|
|
155
|
+
}
|