@gotgenes/pi-permission-system 5.6.1 → 5.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/bash-path-extractor.ts +518 -0
- package/src/config-loader.ts +1 -29
- package/src/external-directory-messages.ts +54 -0
- package/src/external-directory.ts +24 -797
- package/src/handlers/gates/external-directory.ts +1 -1
- package/src/handlers/gates/skill-read.ts +1 -1
- package/src/node-modules-discovery.ts +76 -0
- package/src/path-utils.ts +155 -0
- package/src/permission-manager.ts +1 -29
- package/src/permission-merge.ts +30 -0
- package/src/skill-prompt-sanitizer.ts +5 -39
- package/tests/external-directory-messages.test.ts +137 -0
- package/tests/node-modules-discovery.test.ts +97 -0
- package/tests/{external-directory.test.ts → path-utils.test.ts} +124 -256
- package/tests/permission-merge.test.ts +61 -0
|
@@ -1,797 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
dir = dirname(dir);
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Run `npm root -g` synchronously and return the trimmed output, or `null` on
|
|
35
|
-
* any failure (non-zero exit, ENOENT, timeout, non-existent path).
|
|
36
|
-
*
|
|
37
|
-
* Only called when the walk-up-from-self strategy fails (i.e. the extension is
|
|
38
|
-
* running from a local development checkout, not a global install).
|
|
39
|
-
*/
|
|
40
|
-
function discoverGlobalNodeModulesViaSubprocess(): string | null {
|
|
41
|
-
try {
|
|
42
|
-
const result = spawnSync("npm", ["root", "-g"], {
|
|
43
|
-
encoding: "utf-8",
|
|
44
|
-
timeout: 5000,
|
|
45
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
46
|
-
});
|
|
47
|
-
const root = result.stdout?.trim();
|
|
48
|
-
if (result.status === 0 && root && existsSync(root)) {
|
|
49
|
-
return root;
|
|
50
|
-
}
|
|
51
|
-
return null;
|
|
52
|
-
} catch {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Discover the global node_modules root.
|
|
59
|
-
*
|
|
60
|
-
* Strategy 1 (zero-cost, covers all global installs): walk up from
|
|
61
|
-
* `fromUrl` (defaults to this module's own `import.meta.url`) looking for a
|
|
62
|
-
* directory named `node_modules`. This works whenever the extension is
|
|
63
|
-
* installed inside a `node_modules` tree.
|
|
64
|
-
*
|
|
65
|
-
* Strategy 2 (subprocess fallback, dev checkout only): when Strategy 1 fails
|
|
66
|
-
* because the extension is running from a local development checkout with no
|
|
67
|
-
* `node_modules` ancestor, run `npm root -g` to discover the global root.
|
|
68
|
-
* Pi installs skills and extensions via `npm` by default, so `npm root -g`
|
|
69
|
-
* returns the correct root regardless of the user's own project package
|
|
70
|
-
* manager.
|
|
71
|
-
*
|
|
72
|
-
* Returns `null` when both strategies fail — callers must degrade gracefully.
|
|
73
|
-
*/
|
|
74
|
-
export function discoverGlobalNodeModulesRoot(
|
|
75
|
-
fromUrl = import.meta.url,
|
|
76
|
-
): string | null {
|
|
77
|
-
const fromSelf = walkUpToNodeModules(fromUrl);
|
|
78
|
-
if (fromSelf) return fromSelf;
|
|
79
|
-
return discoverGlobalNodeModulesViaSubprocess();
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Paths that are universally safe and should never trigger external-directory checks.
|
|
84
|
-
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
85
|
-
*/
|
|
86
|
-
export const SAFE_SYSTEM_PATHS: ReadonlySet<string> = new Set([
|
|
87
|
-
"/dev/null",
|
|
88
|
-
"/dev/stdin",
|
|
89
|
-
"/dev/stdout",
|
|
90
|
-
"/dev/stderr",
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* Returns true if the given normalized path is a safe OS device file
|
|
95
|
-
* that should never trigger external-directory checks.
|
|
96
|
-
*/
|
|
97
|
-
export function isSafeSystemPath(normalizedPath: string): boolean {
|
|
98
|
-
return SAFE_SYSTEM_PATHS.has(normalizedPath);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Returns true if the given tool + normalized path combination qualifies for
|
|
103
|
-
* automatic allow as a Pi infrastructure read.
|
|
104
|
-
*
|
|
105
|
-
* A path qualifies when:
|
|
106
|
-
* 1. The tool is read-only (in READ_ONLY_PATH_BEARING_TOOLS).
|
|
107
|
-
* 2. The normalized path is within one of the provided `infrastructureDirs`
|
|
108
|
-
* OR within the project-local Pi package directories
|
|
109
|
-
* (`<cwd>/.pi/npm/` or `<cwd>/.pi/git/`).
|
|
110
|
-
*
|
|
111
|
-
* `infrastructureDirs` should contain pre-expanded absolute paths (no `~`).
|
|
112
|
-
* Project-local paths are computed fresh from `cwd` on each call so they
|
|
113
|
-
* follow working-directory changes without a runtime rebuild.
|
|
114
|
-
*/
|
|
115
|
-
export function isPiInfrastructureRead(
|
|
116
|
-
toolName: string,
|
|
117
|
-
normalizedPath: string,
|
|
118
|
-
infrastructureDirs: readonly string[],
|
|
119
|
-
cwd: string,
|
|
120
|
-
): boolean {
|
|
121
|
-
if (!READ_ONLY_PATH_BEARING_TOOLS.has(toolName)) {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
for (const dir of infrastructureDirs) {
|
|
126
|
-
if (isPathWithinDirectory(normalizedPath, dir)) {
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Project-local Pi packages — checked fresh every call so CWD changes work.
|
|
132
|
-
const projectNpmDir = join(cwd, ".pi", "npm");
|
|
133
|
-
const projectGitDir = join(cwd, ".pi", "git");
|
|
134
|
-
if (isPathWithinDirectory(normalizedPath, projectNpmDir)) {
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
if (isPathWithinDirectory(normalizedPath, projectGitDir)) {
|
|
138
|
-
return true;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* File tools that only read — never write — the filesystem.
|
|
146
|
-
* Only these tools are eligible for the Pi infrastructure auto-allow.
|
|
147
|
-
*/
|
|
148
|
-
export const READ_ONLY_PATH_BEARING_TOOLS: ReadonlySet<string> = new Set([
|
|
149
|
-
"read",
|
|
150
|
-
"find",
|
|
151
|
-
"grep",
|
|
152
|
-
"ls",
|
|
153
|
-
]);
|
|
154
|
-
|
|
155
|
-
export const PATH_BEARING_TOOLS = new Set([
|
|
156
|
-
"read",
|
|
157
|
-
"write",
|
|
158
|
-
"edit",
|
|
159
|
-
"find",
|
|
160
|
-
"grep",
|
|
161
|
-
"ls",
|
|
162
|
-
]);
|
|
163
|
-
|
|
164
|
-
export function normalizePathForComparison(
|
|
165
|
-
pathValue: string,
|
|
166
|
-
cwd: string,
|
|
167
|
-
): string {
|
|
168
|
-
const trimmed = pathValue.trim().replace(/^['"]|['"]$/g, "");
|
|
169
|
-
if (!trimmed) {
|
|
170
|
-
return "";
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
let normalizedPath = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
|
|
174
|
-
|
|
175
|
-
if (normalizedPath === "~") {
|
|
176
|
-
normalizedPath = homedir();
|
|
177
|
-
} else if (
|
|
178
|
-
normalizedPath.startsWith("~/") ||
|
|
179
|
-
normalizedPath.startsWith("~\\")
|
|
180
|
-
) {
|
|
181
|
-
normalizedPath = join(homedir(), normalizedPath.slice(2));
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const absolutePath = resolve(cwd, normalizedPath);
|
|
185
|
-
const normalizedAbsolutePath = normalize(absolutePath);
|
|
186
|
-
return process.platform === "win32"
|
|
187
|
-
? normalizedAbsolutePath.toLowerCase()
|
|
188
|
-
: normalizedAbsolutePath;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function isPathWithinDirectory(
|
|
192
|
-
pathValue: string,
|
|
193
|
-
directory: string,
|
|
194
|
-
): boolean {
|
|
195
|
-
if (!pathValue || !directory) {
|
|
196
|
-
return false;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (pathValue === directory) {
|
|
200
|
-
return true;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const prefix = directory.endsWith(sep) ? directory : `${directory}${sep}`;
|
|
204
|
-
return pathValue.startsWith(prefix);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export function getPathBearingToolPath(
|
|
208
|
-
toolName: string,
|
|
209
|
-
input: unknown,
|
|
210
|
-
): string | null {
|
|
211
|
-
if (!PATH_BEARING_TOOLS.has(toolName)) {
|
|
212
|
-
return null;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return getNonEmptyString(toRecord(input).path);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function isPathOutsideWorkingDirectory(
|
|
219
|
-
pathValue: string,
|
|
220
|
-
cwd: string,
|
|
221
|
-
): boolean {
|
|
222
|
-
const normalizedCwd = normalizePathForComparison(cwd, cwd);
|
|
223
|
-
const normalizedPath = normalizePathForComparison(pathValue, cwd);
|
|
224
|
-
if (!normalizedCwd || !normalizedPath) {
|
|
225
|
-
return false;
|
|
226
|
-
}
|
|
227
|
-
if (isSafeSystemPath(normalizedPath)) {
|
|
228
|
-
return false;
|
|
229
|
-
}
|
|
230
|
-
return !isPathWithinDirectory(normalizedPath, normalizedCwd);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export function formatExternalDirectoryHardStopHint(): string {
|
|
234
|
-
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.";
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
export function formatExternalDirectoryAskPrompt(
|
|
238
|
-
toolName: string,
|
|
239
|
-
pathValue: string,
|
|
240
|
-
cwd: string,
|
|
241
|
-
agentName?: string,
|
|
242
|
-
): string {
|
|
243
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
244
|
-
return `${subject} requested tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. Allow this external directory access?`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export function formatExternalDirectoryDenyReason(
|
|
248
|
-
toolName: string,
|
|
249
|
-
pathValue: string,
|
|
250
|
-
cwd: string,
|
|
251
|
-
agentName?: string,
|
|
252
|
-
): string {
|
|
253
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
254
|
-
return `${subject} is not permitted to run tool '${toolName}' for path '${pathValue}' outside working directory '${cwd}'. ${formatExternalDirectoryHardStopHint()}`;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export function formatExternalDirectoryUserDeniedReason(
|
|
258
|
-
toolName: string,
|
|
259
|
-
pathValue: string,
|
|
260
|
-
denialReason?: string,
|
|
261
|
-
): string {
|
|
262
|
-
const reasonSuffix = denialReason ? ` Reason: ${denialReason}.` : "";
|
|
263
|
-
return `User denied external directory access for tool '${toolName}' path '${pathValue}'.${reasonSuffix} ${formatExternalDirectoryHardStopHint()}`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export function formatBashExternalDirectoryAskPrompt(
|
|
267
|
-
command: string,
|
|
268
|
-
externalPaths: string[],
|
|
269
|
-
cwd: string,
|
|
270
|
-
agentName?: string,
|
|
271
|
-
): string {
|
|
272
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
273
|
-
const pathList = externalPaths.join(", ");
|
|
274
|
-
return `${subject} requested bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. Allow this external directory access?`;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
export function formatBashExternalDirectoryDenyReason(
|
|
278
|
-
command: string,
|
|
279
|
-
externalPaths: string[],
|
|
280
|
-
cwd: string,
|
|
281
|
-
agentName?: string,
|
|
282
|
-
): string {
|
|
283
|
-
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
|
|
284
|
-
const pathList = externalPaths.join(", ");
|
|
285
|
-
return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
// ── tree-sitter-bash lazy parser ───────────────────────────────────────────
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
|
|
292
|
-
* Defined locally so callers do not need to import web-tree-sitter types.
|
|
293
|
-
*/
|
|
294
|
-
interface TSNode {
|
|
295
|
-
readonly type: string;
|
|
296
|
-
readonly text: string;
|
|
297
|
-
readonly childCount: number;
|
|
298
|
-
child(index: number): TSNode | null;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Minimal subset of web-tree-sitter's Parser used by this module.
|
|
303
|
-
*/
|
|
304
|
-
interface TSParser {
|
|
305
|
-
parse(input: string): { rootNode: TSNode; delete(): void } | null;
|
|
306
|
-
delete(): void;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
let parserPromise: Promise<TSParser> | null = null;
|
|
310
|
-
|
|
311
|
-
async function initParser(): Promise<TSParser> {
|
|
312
|
-
// Use named imports — web-tree-sitter exports Parser as a named class.
|
|
313
|
-
const { Parser, Language } = await import("web-tree-sitter");
|
|
314
|
-
const req = createRequire(import.meta.url);
|
|
315
|
-
const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
|
|
316
|
-
await Parser.init({ locateFile: () => treeSitterWasm });
|
|
317
|
-
|
|
318
|
-
const parser = new Parser();
|
|
319
|
-
const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
|
|
320
|
-
const bash = await Language.load(bashWasm);
|
|
321
|
-
parser.setLanguage(bash);
|
|
322
|
-
return parser as TSParser;
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function getParser(): Promise<TSParser> {
|
|
326
|
-
if (!parserPromise) {
|
|
327
|
-
parserPromise = initParser();
|
|
328
|
-
}
|
|
329
|
-
return parserPromise;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/**
|
|
333
|
-
* Reset the cached parser promise. Only used by tests to avoid
|
|
334
|
-
* cross-test pollution or to inject a mock parser.
|
|
335
|
-
*/
|
|
336
|
-
export function resetParserForTesting(): void {
|
|
337
|
-
parserPromise = null;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// ── AST walker ─────────────────────────────────────────────────────────────
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Node types whose subtrees must never be descended into for
|
|
344
|
-
* path extraction — their text content is not a command argument.
|
|
345
|
-
*/
|
|
346
|
-
const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
|
|
347
|
-
|
|
348
|
-
/**
|
|
349
|
-
* Resolve the "shell value" of an argument node — the string the shell
|
|
350
|
-
* would pass to the command after quote removal.
|
|
351
|
-
*
|
|
352
|
-
* - `word` → `.text` (already unquoted)
|
|
353
|
-
* - `raw_string` → strip surrounding single quotes
|
|
354
|
-
* - `string` → strip surrounding double quotes, concatenate children text
|
|
355
|
-
* - `concatenation` → concatenate resolved children
|
|
356
|
-
* - other → `.text` as fallback
|
|
357
|
-
*/
|
|
358
|
-
function resolveNodeText(node: TSNode): string {
|
|
359
|
-
switch (node.type) {
|
|
360
|
-
case "word":
|
|
361
|
-
return node.text;
|
|
362
|
-
case "raw_string": {
|
|
363
|
-
// Strip surrounding single quotes: 'content' → content
|
|
364
|
-
const t = node.text;
|
|
365
|
-
if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
|
|
366
|
-
return t.slice(1, -1);
|
|
367
|
-
}
|
|
368
|
-
return t;
|
|
369
|
-
}
|
|
370
|
-
case "string": {
|
|
371
|
-
// Double-quoted string: concatenate the resolved text of inner children,
|
|
372
|
-
// skipping the quote-delimiter nodes (literal `"`).
|
|
373
|
-
let result = "";
|
|
374
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
375
|
-
const child = node.child(i);
|
|
376
|
-
if (!child) continue;
|
|
377
|
-
// Skip the literal `"` delimiters
|
|
378
|
-
if (child.type === '"') continue;
|
|
379
|
-
result += resolveNodeText(child);
|
|
380
|
-
}
|
|
381
|
-
return result;
|
|
382
|
-
}
|
|
383
|
-
case "string_content":
|
|
384
|
-
case "simple_expansion":
|
|
385
|
-
case "expansion":
|
|
386
|
-
return node.text;
|
|
387
|
-
case "concatenation": {
|
|
388
|
-
let result = "";
|
|
389
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
390
|
-
const child = node.child(i);
|
|
391
|
-
if (!child) continue;
|
|
392
|
-
result += resolveNodeText(child);
|
|
393
|
-
}
|
|
394
|
-
return result;
|
|
395
|
-
}
|
|
396
|
-
default:
|
|
397
|
-
return node.text;
|
|
398
|
-
}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
// ── Pattern-first command config ───────────────────────────────────────────
|
|
402
|
-
|
|
403
|
-
interface PatternCommandConfig {
|
|
404
|
-
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
|
405
|
-
readonly argConsumingFlags: ReadonlySet<string>;
|
|
406
|
-
/** Flags that consume the next argument as a file path */
|
|
407
|
-
readonly fileConsumingFlags: ReadonlySet<string>;
|
|
408
|
-
/**
|
|
409
|
-
* Number of leading positional arguments that are patterns/scripts, not paths.
|
|
410
|
-
* Default: 1 (covers sed, awk, grep, rg).
|
|
411
|
-
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
|
|
412
|
-
*/
|
|
413
|
-
readonly patternPositionals?: number;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Commands whose first N positional arguments are inline patterns/scripts,
|
|
418
|
-
* not filesystem paths. The map stores per-command flag configuration so
|
|
419
|
-
* the walker can correctly identify which arguments are consumed by flags
|
|
420
|
-
* vs. which are positional.
|
|
421
|
-
*/
|
|
422
|
-
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
|
423
|
-
new Map([
|
|
424
|
-
[
|
|
425
|
-
"sed",
|
|
426
|
-
{
|
|
427
|
-
argConsumingFlags: new Set(["-e", "-i"]),
|
|
428
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
429
|
-
},
|
|
430
|
-
],
|
|
431
|
-
[
|
|
432
|
-
"awk",
|
|
433
|
-
{
|
|
434
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
435
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
436
|
-
},
|
|
437
|
-
],
|
|
438
|
-
[
|
|
439
|
-
"gawk",
|
|
440
|
-
{
|
|
441
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
442
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
443
|
-
},
|
|
444
|
-
],
|
|
445
|
-
[
|
|
446
|
-
"nawk",
|
|
447
|
-
{
|
|
448
|
-
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
449
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
450
|
-
},
|
|
451
|
-
],
|
|
452
|
-
[
|
|
453
|
-
"grep",
|
|
454
|
-
{
|
|
455
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
456
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
457
|
-
},
|
|
458
|
-
],
|
|
459
|
-
[
|
|
460
|
-
"egrep",
|
|
461
|
-
{
|
|
462
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
463
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
464
|
-
},
|
|
465
|
-
],
|
|
466
|
-
[
|
|
467
|
-
"fgrep",
|
|
468
|
-
{
|
|
469
|
-
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
470
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
471
|
-
},
|
|
472
|
-
],
|
|
473
|
-
[
|
|
474
|
-
"rg",
|
|
475
|
-
{
|
|
476
|
-
argConsumingFlags: new Set([
|
|
477
|
-
"-e",
|
|
478
|
-
"-A",
|
|
479
|
-
"-B",
|
|
480
|
-
"-C",
|
|
481
|
-
"-m",
|
|
482
|
-
"-g",
|
|
483
|
-
"-t",
|
|
484
|
-
"-T",
|
|
485
|
-
"-j",
|
|
486
|
-
"-M",
|
|
487
|
-
"-r",
|
|
488
|
-
"-E",
|
|
489
|
-
]),
|
|
490
|
-
fileConsumingFlags: new Set(["-f"]),
|
|
491
|
-
},
|
|
492
|
-
],
|
|
493
|
-
[
|
|
494
|
-
"sd",
|
|
495
|
-
{
|
|
496
|
-
argConsumingFlags: new Set(["-n", "-f"]),
|
|
497
|
-
fileConsumingFlags: new Set([]),
|
|
498
|
-
patternPositionals: 2,
|
|
499
|
-
},
|
|
500
|
-
],
|
|
501
|
-
]);
|
|
502
|
-
|
|
503
|
-
/** Node types that represent argument values in the AST. */
|
|
504
|
-
const ARG_NODE_TYPES = new Set([
|
|
505
|
-
"word",
|
|
506
|
-
"concatenation",
|
|
507
|
-
"string",
|
|
508
|
-
"raw_string",
|
|
509
|
-
]);
|
|
510
|
-
|
|
511
|
-
/**
|
|
512
|
-
* Extract the command name from a `command` node.
|
|
513
|
-
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
514
|
-
* if the command name cannot be determined (e.g. variable expansion).
|
|
515
|
-
*/
|
|
516
|
-
function extractCommandName(node: TSNode): string | undefined {
|
|
517
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
518
|
-
const child = node.child(i);
|
|
519
|
-
if (!child) continue;
|
|
520
|
-
if (child.type === "command_name") {
|
|
521
|
-
const text = resolveNodeText(child);
|
|
522
|
-
return text ? basename(text) : undefined;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return undefined;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
/**
|
|
529
|
-
* Collect path-candidate tokens from a command known to have
|
|
530
|
-
* pattern/script arguments in leading positional slots.
|
|
531
|
-
*
|
|
532
|
-
* Uses position-based skipping: the first N positional arguments
|
|
533
|
-
* (where N = patternPositionals, default 1) are assumed to be
|
|
534
|
-
* inline patterns/scripts and are skipped. Remaining positional
|
|
535
|
-
* arguments are collected as path candidates.
|
|
536
|
-
*
|
|
537
|
-
* Flags listed in `argConsumingFlags` consume the next argument
|
|
538
|
-
* (skipped). Flags in `fileConsumingFlags` consume the next
|
|
539
|
-
* argument as a file path (collected). The flags `-e` and `-f`
|
|
540
|
-
* additionally signal that an explicit script was provided via
|
|
541
|
-
* flag, so no inline positional script is expected.
|
|
542
|
-
*/
|
|
543
|
-
function collectPatternCommandTokens(
|
|
544
|
-
node: TSNode,
|
|
545
|
-
tokens: string[],
|
|
546
|
-
config: PatternCommandConfig,
|
|
547
|
-
): void {
|
|
548
|
-
const patternPositionals = config.patternPositionals ?? 1;
|
|
549
|
-
let hasExplicitScript = false;
|
|
550
|
-
let positionalsSeen = 0;
|
|
551
|
-
let nextArgAction: "skip" | "extract" | null = null;
|
|
552
|
-
let pastEndOfFlags = false;
|
|
553
|
-
|
|
554
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
555
|
-
const child = node.child(i);
|
|
556
|
-
if (!child) continue;
|
|
557
|
-
|
|
558
|
-
// Skip command_name and variable_assignment nodes.
|
|
559
|
-
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
560
|
-
continue;
|
|
561
|
-
|
|
562
|
-
// Only process argument-like nodes; recurse into others
|
|
563
|
-
// (e.g. command_substitution) for nested commands.
|
|
564
|
-
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
565
|
-
collectPathCandidateTokens(child, tokens);
|
|
566
|
-
continue;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const text = resolveNodeText(child);
|
|
570
|
-
|
|
571
|
-
// Handle consumed argument from previous flag.
|
|
572
|
-
if (nextArgAction === "skip") {
|
|
573
|
-
nextArgAction = null;
|
|
574
|
-
continue;
|
|
575
|
-
}
|
|
576
|
-
if (nextArgAction === "extract") {
|
|
577
|
-
tokens.push(text);
|
|
578
|
-
nextArgAction = null;
|
|
579
|
-
continue;
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
// Flag detection (only before "--" end-of-flags marker).
|
|
583
|
-
if (
|
|
584
|
-
!pastEndOfFlags &&
|
|
585
|
-
child.type === "word" &&
|
|
586
|
-
text.startsWith("-") &&
|
|
587
|
-
text.length > 1
|
|
588
|
-
) {
|
|
589
|
-
if (text === "--") {
|
|
590
|
-
pastEndOfFlags = true;
|
|
591
|
-
continue;
|
|
592
|
-
}
|
|
593
|
-
if (config.argConsumingFlags.has(text)) {
|
|
594
|
-
nextArgAction = "skip";
|
|
595
|
-
if (text === "-e" || text === "-f") {
|
|
596
|
-
hasExplicitScript = true;
|
|
597
|
-
}
|
|
598
|
-
continue;
|
|
599
|
-
}
|
|
600
|
-
if (config.fileConsumingFlags.has(text)) {
|
|
601
|
-
nextArgAction = "extract";
|
|
602
|
-
hasExplicitScript = true;
|
|
603
|
-
continue;
|
|
604
|
-
}
|
|
605
|
-
// Regular flag — skip it.
|
|
606
|
-
continue;
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
// Positional argument.
|
|
610
|
-
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
611
|
-
positionalsSeen++;
|
|
612
|
-
continue; // Skip: this is an inline pattern/script.
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// File argument — collect as path candidate.
|
|
616
|
-
tokens.push(text);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Recursively visit the AST and collect resolved text of nodes that
|
|
622
|
-
* represent command arguments or redirect destinations.
|
|
623
|
-
*
|
|
624
|
-
* Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
|
|
625
|
-
*
|
|
626
|
-
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
627
|
-
* argument skipping to avoid collecting inline patterns/scripts
|
|
628
|
-
* as path candidates. For all other commands, collects all
|
|
629
|
-
* arguments generically.
|
|
630
|
-
*/
|
|
631
|
-
function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
632
|
-
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
|
|
633
|
-
|
|
634
|
-
// Extract arguments from `command` nodes.
|
|
635
|
-
if (node.type === "command") {
|
|
636
|
-
const commandName = extractCommandName(node);
|
|
637
|
-
const patternConfig = commandName
|
|
638
|
-
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
639
|
-
: undefined;
|
|
640
|
-
|
|
641
|
-
if (patternConfig) {
|
|
642
|
-
collectPatternCommandTokens(node, tokens, patternConfig);
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
// Generic extraction: collect all arguments (skip command name).
|
|
647
|
-
let seenCommandName = false;
|
|
648
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
649
|
-
const child = node.child(i);
|
|
650
|
-
if (!child) continue;
|
|
651
|
-
|
|
652
|
-
if (child.type === "command_name") {
|
|
653
|
-
seenCommandName = true;
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
// Skip variable_assignment nodes (FOO=/bar)
|
|
657
|
-
if (child.type === "variable_assignment") continue;
|
|
658
|
-
|
|
659
|
-
// If there was no explicit command_name node, the first word-like
|
|
660
|
-
// child is the command name itself — skip it.
|
|
661
|
-
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
662
|
-
seenCommandName = true;
|
|
663
|
-
continue;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
// Argument nodes: resolve their text and collect.
|
|
667
|
-
if (ARG_NODE_TYPES.has(child.type)) {
|
|
668
|
-
tokens.push(resolveNodeText(child));
|
|
669
|
-
continue;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// Recurse into other children (e.g. command_substitution nested in args)
|
|
673
|
-
collectPathCandidateTokens(child, tokens);
|
|
674
|
-
}
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
// Extract redirect destinations from `file_redirect` nodes.
|
|
679
|
-
if (node.type === "file_redirect") {
|
|
680
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
681
|
-
const child = node.child(i);
|
|
682
|
-
if (!child) continue;
|
|
683
|
-
if (
|
|
684
|
-
child.type === "word" ||
|
|
685
|
-
child.type === "concatenation" ||
|
|
686
|
-
child.type === "string" ||
|
|
687
|
-
child.type === "raw_string"
|
|
688
|
-
) {
|
|
689
|
-
tokens.push(resolveNodeText(child));
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// For all other node types, recurse into children.
|
|
696
|
-
for (let i = 0; i < node.childCount; i++) {
|
|
697
|
-
const child = node.child(i);
|
|
698
|
-
if (!child) continue;
|
|
699
|
-
collectPathCandidateTokens(child, tokens);
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
/**
|
|
704
|
-
* URL pattern to skip tokens that look like URLs rather than paths.
|
|
705
|
-
*/
|
|
706
|
-
const URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Regex metacharacter sequences that are never found in real filesystem paths.
|
|
710
|
-
* If a token contains any of these, it is almost certainly a regex pattern
|
|
711
|
-
* (e.g. a grep argument) rather than a path.
|
|
712
|
-
*/
|
|
713
|
-
const REGEX_METACHAR_PATTERN = /\.\*|\.\+|\\\||\\\(|\\\)|\[.*?\]|\^\//;
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* Determines whether a token looks like a path candidate worth resolving.
|
|
717
|
-
* Returns the raw token string if it's a candidate, or null to skip.
|
|
718
|
-
*/
|
|
719
|
-
function classifyTokenAsPathCandidate(token: string): string | null {
|
|
720
|
-
// Skip empty tokens
|
|
721
|
-
if (!token) return null;
|
|
722
|
-
|
|
723
|
-
// Skip flags
|
|
724
|
-
if (token.startsWith("-")) return null;
|
|
725
|
-
|
|
726
|
-
// Skip env assignments (FOO=/bar)
|
|
727
|
-
const eqIndex = token.indexOf("=");
|
|
728
|
-
const slashIndex = token.indexOf("/");
|
|
729
|
-
if (eqIndex !== -1 && (slashIndex === -1 || eqIndex < slashIndex)) {
|
|
730
|
-
return null;
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
// Skip URLs
|
|
734
|
-
if (URL_PATTERN.test(token)) return null;
|
|
735
|
-
|
|
736
|
-
// Skip @scope/package patterns
|
|
737
|
-
if (token.startsWith("@") && !token.startsWith("@/")) return null;
|
|
738
|
-
|
|
739
|
-
// Skip bare-slash tokens (// JS comments, lone /, etc.) — they resolve to root
|
|
740
|
-
// and are never meaningful path arguments in practice.
|
|
741
|
-
if (/^\/+$/.test(token)) return null;
|
|
742
|
-
|
|
743
|
-
// Skip tokens that contain regex metacharacter sequences — these are almost
|
|
744
|
-
// certainly grep/sed/awk patterns, not filesystem paths.
|
|
745
|
-
// Matches: .*, .+, \|, \(, \), [...], or ^/ (anchored regex starting with /)
|
|
746
|
-
if (REGEX_METACHAR_PATTERN.test(token)) return null;
|
|
747
|
-
|
|
748
|
-
// Must look like a path: starts with /, ~/, or contains ..
|
|
749
|
-
if (token.startsWith("/")) return token;
|
|
750
|
-
if (token.startsWith("~/")) return token;
|
|
751
|
-
if (token.includes("..")) return token;
|
|
752
|
-
|
|
753
|
-
return null;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Extracts paths from a bash command string that resolve outside CWD.
|
|
758
|
-
* Uses tree-sitter-bash to parse the command into a full AST, then walks
|
|
759
|
-
* command argument and redirect-destination nodes. Heredoc bodies, comments,
|
|
760
|
-
* and other non-argument content are skipped, eliminating false positives.
|
|
761
|
-
*/
|
|
762
|
-
export async function extractExternalPathsFromBashCommand(
|
|
763
|
-
command: string,
|
|
764
|
-
cwd: string,
|
|
765
|
-
): Promise<string[]> {
|
|
766
|
-
const parser = await getParser();
|
|
767
|
-
const tree = parser.parse(command);
|
|
768
|
-
if (!tree) return [];
|
|
769
|
-
|
|
770
|
-
const tokens: string[] = [];
|
|
771
|
-
try {
|
|
772
|
-
collectPathCandidateTokens(tree.rootNode, tokens);
|
|
773
|
-
} finally {
|
|
774
|
-
tree.delete();
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
const seen = new Set<string>();
|
|
778
|
-
const externalPaths: string[] = [];
|
|
779
|
-
|
|
780
|
-
for (const token of tokens) {
|
|
781
|
-
const candidate = classifyTokenAsPathCandidate(token);
|
|
782
|
-
if (!candidate) continue;
|
|
783
|
-
|
|
784
|
-
const normalized = normalizePathForComparison(candidate, cwd);
|
|
785
|
-
if (!normalized) continue;
|
|
786
|
-
|
|
787
|
-
if (
|
|
788
|
-
isPathOutsideWorkingDirectory(candidate, cwd) &&
|
|
789
|
-
!seen.has(normalized)
|
|
790
|
-
) {
|
|
791
|
-
seen.add(normalized);
|
|
792
|
-
externalPaths.push(normalized);
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
return externalPaths;
|
|
797
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
extractExternalPathsFromBashCommand,
|
|
3
|
+
resetParserForTesting,
|
|
4
|
+
} from "./bash-path-extractor";
|
|
5
|
+
export {
|
|
6
|
+
formatBashExternalDirectoryAskPrompt,
|
|
7
|
+
formatBashExternalDirectoryDenyReason,
|
|
8
|
+
formatExternalDirectoryAskPrompt,
|
|
9
|
+
formatExternalDirectoryDenyReason,
|
|
10
|
+
formatExternalDirectoryHardStopHint,
|
|
11
|
+
formatExternalDirectoryUserDeniedReason,
|
|
12
|
+
} from "./external-directory-messages";
|
|
13
|
+
export { discoverGlobalNodeModulesRoot } from "./node-modules-discovery";
|
|
14
|
+
export {
|
|
15
|
+
getPathBearingToolPath,
|
|
16
|
+
isPathOutsideWorkingDirectory,
|
|
17
|
+
isPathWithinDirectory,
|
|
18
|
+
isPiInfrastructureRead,
|
|
19
|
+
isSafeSystemPath,
|
|
20
|
+
normalizePathForComparison,
|
|
21
|
+
PATH_BEARING_TOOLS,
|
|
22
|
+
READ_ONLY_PATH_BEARING_TOOLS,
|
|
23
|
+
SAFE_SYSTEM_PATHS,
|
|
24
|
+
} from "./path-utils";
|