@gotgenes/pi-permission-system 5.0.0 → 5.1.1
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 +28 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/schemas/permissions.schema.json +1 -1
- package/src/external-directory.ts +294 -24
- package/tests/bash-external-directory.test.ts +227 -0
- package/tests/external-directory.test.ts +107 -1
- package/tests/pi-infrastructure-read.test.ts +21 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ 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.1.1](https://github.com/gotgenes/pi-permission-system/compare/v5.1.0...v5.1.1) (2026-05-05)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* discover global node_modules root from dev checkout via npm root -g fallback ([93aac81](https://github.com/gotgenes/pi-permission-system/commit/93aac81bd830ec260d2156b34ca8074f6c533255))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* note npm root -g fallback for dev checkout infrastructure reads ([d06caf7](https://github.com/gotgenes/pi-permission-system/commit/d06caf73371c7ea73f1c56a5efb86b2023292dd3))
|
|
19
|
+
* plan createRequire fallback for dev checkout infra read bypass ([#93](https://github.com/gotgenes/pi-permission-system/issues/93)) ([7750044](https://github.com/gotgenes/pi-permission-system/commit/775004477efff0d3cd2eb5ba7a0fcbdd98f3d122))
|
|
20
|
+
* plan npm root -g fallback for dev checkout infra read bypass ([#93](https://github.com/gotgenes/pi-permission-system/issues/93)) ([85e697c](https://github.com/gotgenes/pi-permission-system/commit/85e697c5062a36fd150bd4d6b377ca906b3a1dbf))
|
|
21
|
+
* **retro:** add retro notes for issue [#91](https://github.com/gotgenes/pi-permission-system/issues/91) ([d2d1263](https://github.com/gotgenes/pi-permission-system/commit/d2d1263955741053b2cf8718830d88043b9cdd8e))
|
|
22
|
+
|
|
23
|
+
## [5.1.0](https://github.com/gotgenes/pi-permission-system/compare/v5.0.0...v5.1.0) (2026-05-05)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
* command-aware path extraction for pattern-first commands ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([befca23](https://github.com/gotgenes/pi-permission-system/commit/befca2341e1b54d9ed7e6ff3c3d465776afcc50d))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
### Documentation
|
|
32
|
+
|
|
33
|
+
* plan command-aware path extraction for sed/awk/grep/rg/sd ([#91](https://github.com/gotgenes/pi-permission-system/issues/91)) ([be88a6a](https://github.com/gotgenes/pi-permission-system/commit/be88a6ab66ab386ce5843b3dd12218fc7968ee15))
|
|
34
|
+
* **retro:** add retro notes for issue [#88](https://github.com/gotgenes/pi-permission-system/issues/88) ([453a8ba](https://github.com/gotgenes/pi-permission-system/commit/453a8ba69fb68f24200be7a604f4fac4738c0cfe))
|
|
35
|
+
|
|
8
36
|
## [5.0.0](https://github.com/gotgenes/pi-permission-system/compare/v4.9.0...v5.0.0) (2026-05-05)
|
|
9
37
|
|
|
10
38
|
|
package/README.md
CHANGED
|
@@ -379,7 +379,7 @@ Infrastructure directories include:
|
|
|
379
379
|
|
|
380
380
|
1. The agent config directory (`~/.pi/agent/` or `$PI_CODING_AGENT_DIR`)
|
|
381
381
|
2. Git-cloned global packages (`<agentDir>/git/`)
|
|
382
|
-
3. The global `node_modules` root (auto-discovered from the extension's own install path
|
|
382
|
+
3. The global `node_modules` root (auto-discovered from the extension's own install path; falls back to `npm root -g` when running from a local development checkout)
|
|
383
383
|
4. Project-local Pi packages (`<cwd>/.pi/npm/` and `<cwd>/.pi/git/`)
|
|
384
384
|
5. Any paths listed in `piInfrastructureReadPaths`
|
|
385
385
|
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"piInfrastructureReadPaths": {
|
|
33
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.",
|
|
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 (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~` expansion. Directory prefixes only — no glob patterns.",
|
|
35
35
|
"type": "array",
|
|
36
36
|
"items": {
|
|
37
37
|
"type": "string",
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
1
3
|
import { createRequire } from "node:module";
|
|
2
4
|
import { homedir } from "node:os";
|
|
3
5
|
import { basename, dirname, join, normalize, resolve, sep } from "node:path";
|
|
@@ -6,21 +8,16 @@ import { fileURLToPath } from "node:url";
|
|
|
6
8
|
import { getNonEmptyString, toRecord } from "./common";
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Walk up the directory tree from the given file URL until a directory
|
|
12
|
+
* literally named `node_modules` is found.
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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.
|
|
14
|
+
* Returns the `node_modules` path, or `null` if the URL cannot be parsed or
|
|
15
|
+
* no `node_modules` ancestor exists.
|
|
16
16
|
*/
|
|
17
|
-
|
|
18
|
-
fromUrl = import.meta.url,
|
|
19
|
-
): string | null {
|
|
17
|
+
function walkUpToNodeModules(fromUrl: string): string | null {
|
|
20
18
|
try {
|
|
21
19
|
const thisFile = fileURLToPath(fromUrl);
|
|
22
20
|
let dir = dirname(thisFile);
|
|
23
|
-
// Walk up until we find a directory named "node_modules" or hit the root.
|
|
24
21
|
while (dir !== dirname(dir)) {
|
|
25
22
|
if (basename(dir) === "node_modules") {
|
|
26
23
|
return dir;
|
|
@@ -33,6 +30,55 @@ export function discoverGlobalNodeModulesRoot(
|
|
|
33
30
|
}
|
|
34
31
|
}
|
|
35
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
|
+
|
|
36
82
|
/**
|
|
37
83
|
* Paths that are universally safe and should never trigger external-directory checks.
|
|
38
84
|
* These are OS device files: read returns EOF or process streams, write discards or goes to process streams.
|
|
@@ -352,17 +398,252 @@ function resolveNodeText(node: TSNode): string {
|
|
|
352
398
|
}
|
|
353
399
|
}
|
|
354
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
|
+
|
|
355
620
|
/**
|
|
356
621
|
* Recursively visit the AST and collect resolved text of nodes that
|
|
357
622
|
* represent command arguments or redirect destinations.
|
|
358
623
|
*
|
|
359
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.
|
|
360
630
|
*/
|
|
361
631
|
function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
362
632
|
if (SKIP_SUBTREE_TYPES.has(node.type)) return;
|
|
363
633
|
|
|
364
|
-
// Extract arguments from `command` nodes
|
|
634
|
+
// Extract arguments from `command` nodes.
|
|
365
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).
|
|
366
647
|
let seenCommandName = false;
|
|
367
648
|
for (let i = 0; i < node.childCount; i++) {
|
|
368
649
|
const child = node.child(i);
|
|
@@ -377,24 +658,13 @@ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
|
|
|
377
658
|
|
|
378
659
|
// If there was no explicit command_name node, the first word-like
|
|
379
660
|
// child is the command name itself — skip it.
|
|
380
|
-
if (
|
|
381
|
-
!seenCommandName &&
|
|
382
|
-
(child.type === "word" ||
|
|
383
|
-
child.type === "concatenation" ||
|
|
384
|
-
child.type === "string" ||
|
|
385
|
-
child.type === "raw_string")
|
|
386
|
-
) {
|
|
661
|
+
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
387
662
|
seenCommandName = true;
|
|
388
663
|
continue;
|
|
389
664
|
}
|
|
390
665
|
|
|
391
666
|
// Argument nodes: resolve their text and collect.
|
|
392
|
-
if (
|
|
393
|
-
child.type === "word" ||
|
|
394
|
-
child.type === "concatenation" ||
|
|
395
|
-
child.type === "string" ||
|
|
396
|
-
child.type === "raw_string"
|
|
397
|
-
) {
|
|
667
|
+
if (ARG_NODE_TYPES.has(child.type)) {
|
|
398
668
|
tokens.push(resolveNodeText(child));
|
|
399
669
|
continue;
|
|
400
670
|
}
|
|
@@ -545,6 +545,233 @@ describe("extractExternalPathsFromBashCommand", () => {
|
|
|
545
545
|
});
|
|
546
546
|
});
|
|
547
547
|
|
|
548
|
+
describe("command-aware extraction", () => {
|
|
549
|
+
describe("sed", () => {
|
|
550
|
+
test("issue #91 reproducer: sed address pattern is not flagged", async () => {
|
|
551
|
+
const cmd = `sed -i '' '/source: "tool",/{/origin:/!s/source: "tool",/source: "tool",\n origin: "builtin",/;}' tests/tool-input-preview.test.ts`;
|
|
552
|
+
const result = await extractExternalPathsFromBashCommand(cmd, cwd);
|
|
553
|
+
expect(result).toHaveLength(0);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("sed script is skipped but file argument is extracted", async () => {
|
|
557
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
558
|
+
"sed 's/foo/bar/g' /etc/hosts",
|
|
559
|
+
cwd,
|
|
560
|
+
);
|
|
561
|
+
expect(result).toContain("/etc/hosts");
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test("sed address pattern starting with / is skipped", async () => {
|
|
565
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
566
|
+
"sed '/pattern/d' /etc/hosts",
|
|
567
|
+
cwd,
|
|
568
|
+
);
|
|
569
|
+
expect(result).toContain("/etc/hosts");
|
|
570
|
+
expect(result).toHaveLength(1);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("sed with only in-CWD file returns empty", async () => {
|
|
574
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
575
|
+
"sed 's/foo/bar/' src/index.ts",
|
|
576
|
+
cwd,
|
|
577
|
+
);
|
|
578
|
+
expect(result).toHaveLength(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("sed -e: script consumed by flag, file extracted", async () => {
|
|
582
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
583
|
+
"sed -e 's/foo/bar/' /etc/hosts",
|
|
584
|
+
cwd,
|
|
585
|
+
);
|
|
586
|
+
expect(result).toContain("/etc/hosts");
|
|
587
|
+
expect(result).toHaveLength(1);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("sed -n: regular flag does not consume next arg", async () => {
|
|
591
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
592
|
+
"sed -n '/pattern/p' /etc/hosts",
|
|
593
|
+
cwd,
|
|
594
|
+
);
|
|
595
|
+
expect(result).toContain("/etc/hosts");
|
|
596
|
+
expect(result).toHaveLength(1);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("sed -f: script file is extracted as path", async () => {
|
|
600
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
601
|
+
"sed -f /etc/sed-script.sed input.txt",
|
|
602
|
+
cwd,
|
|
603
|
+
);
|
|
604
|
+
expect(result).toContain("/etc/sed-script.sed");
|
|
605
|
+
expect(result).toHaveLength(1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("sed -i '': extension consumed, script skipped, file extracted", async () => {
|
|
609
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
610
|
+
"sed -i '' 's/foo/bar/' /etc/hosts",
|
|
611
|
+
cwd,
|
|
612
|
+
);
|
|
613
|
+
expect(result).toContain("/etc/hosts");
|
|
614
|
+
expect(result).toHaveLength(1);
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
describe("grep", () => {
|
|
619
|
+
test("grep: pattern skipped, file extracted", async () => {
|
|
620
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
621
|
+
"grep '/etc/' /var/log/syslog",
|
|
622
|
+
cwd,
|
|
623
|
+
);
|
|
624
|
+
expect(result).toContain("/var/log/syslog");
|
|
625
|
+
expect(result).toHaveLength(1);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("grep -e: pattern consumed by flag, file extracted", async () => {
|
|
629
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
630
|
+
"grep -e '/etc/' /var/log/syslog",
|
|
631
|
+
cwd,
|
|
632
|
+
);
|
|
633
|
+
expect(result).toContain("/var/log/syslog");
|
|
634
|
+
expect(result).toHaveLength(1);
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
describe("awk", () => {
|
|
639
|
+
test("awk: program skipped, file extracted", async () => {
|
|
640
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
641
|
+
"awk '{print}' /etc/hosts",
|
|
642
|
+
cwd,
|
|
643
|
+
);
|
|
644
|
+
expect(result).toContain("/etc/hosts");
|
|
645
|
+
expect(result).toHaveLength(1);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("awk -F: separator consumed, program skipped, file extracted", async () => {
|
|
649
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
650
|
+
"awk -F: '{print $1}' /etc/passwd",
|
|
651
|
+
cwd,
|
|
652
|
+
);
|
|
653
|
+
expect(result).toContain("/etc/passwd");
|
|
654
|
+
expect(result).toHaveLength(1);
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
describe("rg", () => {
|
|
659
|
+
test("rg: pattern skipped, path extracted", async () => {
|
|
660
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
661
|
+
"rg '/usr/local' /etc/profile.d/",
|
|
662
|
+
cwd,
|
|
663
|
+
);
|
|
664
|
+
expect(result).toContain("/etc/profile.d");
|
|
665
|
+
expect(result).toHaveLength(1);
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test("rg -e: pattern consumed by flag, path extracted", async () => {
|
|
669
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
670
|
+
"rg -e '/usr/local' /etc/profile.d/",
|
|
671
|
+
cwd,
|
|
672
|
+
);
|
|
673
|
+
expect(result).toContain("/etc/profile.d");
|
|
674
|
+
expect(result).toHaveLength(1);
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
describe("sd", () => {
|
|
679
|
+
test("sd: both pattern positionals skipped, file extracted", async () => {
|
|
680
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
681
|
+
"sd '/usr/local/bin' '/opt/bin' /etc/profile",
|
|
682
|
+
cwd,
|
|
683
|
+
);
|
|
684
|
+
expect(result).toContain("/etc/profile");
|
|
685
|
+
expect(result).toHaveLength(1);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test("sd with only in-CWD file returns empty", async () => {
|
|
689
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
690
|
+
"sd 'foo' 'bar' src/index.ts",
|
|
691
|
+
cwd,
|
|
692
|
+
);
|
|
693
|
+
expect(result).toHaveLength(0);
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
describe("unknown commands", () => {
|
|
698
|
+
test("unknown command: all args go through generic extraction", async () => {
|
|
699
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
700
|
+
"some-tool /etc/hosts",
|
|
701
|
+
cwd,
|
|
702
|
+
);
|
|
703
|
+
expect(result).toContain("/etc/hosts");
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
describe("edge cases", () => {
|
|
708
|
+
test("full-path command invocation: /usr/bin/sed", async () => {
|
|
709
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
710
|
+
"/usr/bin/sed 's/foo/bar/' /etc/hosts",
|
|
711
|
+
cwd,
|
|
712
|
+
);
|
|
713
|
+
expect(result).toContain("/etc/hosts");
|
|
714
|
+
expect(result).toHaveLength(1);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("-- end-of-flags: all remaining args are positional files", async () => {
|
|
718
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
719
|
+
"grep -- '/etc/' /var/log/syslog",
|
|
720
|
+
cwd,
|
|
721
|
+
);
|
|
722
|
+
// After --, '/etc/' is the pattern positional, /var/log/syslog is a file
|
|
723
|
+
expect(result).toContain("/var/log/syslog");
|
|
724
|
+
expect(result).toHaveLength(1);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("redirect target still extracted for pattern-first command", async () => {
|
|
728
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
729
|
+
"sed 's/foo/bar/' input.txt > /tmp/output.txt",
|
|
730
|
+
cwd,
|
|
731
|
+
);
|
|
732
|
+
expect(result).toContain("/tmp/output.txt");
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
test("pipeline: sed piped to cat with external path", async () => {
|
|
736
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
737
|
+
"sed 's/foo/bar/' src/file.ts | cat /etc/hosts",
|
|
738
|
+
cwd,
|
|
739
|
+
);
|
|
740
|
+
expect(result).toContain("/etc/hosts");
|
|
741
|
+
expect(result).toHaveLength(1);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("command substitution inside pattern-first command", async () => {
|
|
745
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
746
|
+
"grep 'pattern' $(cat /etc/file-list)",
|
|
747
|
+
cwd,
|
|
748
|
+
);
|
|
749
|
+
// /etc/file-list is an argument to cat inside command substitution
|
|
750
|
+
expect(result).toContain("/etc/file-list");
|
|
751
|
+
});
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
describe("known limitations", () => {
|
|
755
|
+
test("sed -i without extension (GNU sed): /etc/hosts is missed (false negative)", async () => {
|
|
756
|
+
// GNU sed treats -i as a flag with no argument, so 's/foo/bar/' is
|
|
757
|
+
// the inline script and /etc/hosts is the input file. Our logic
|
|
758
|
+
// treats -i as arg-consuming (correct for BSD sed -i ''), so it
|
|
759
|
+
// consumes the script as the -i extension and /etc/hosts becomes
|
|
760
|
+
// the first positional — which is skipped as the inline script.
|
|
761
|
+
// This is a known false negative. The bash permission gate still
|
|
762
|
+
// applies, so external access is not silently allowed.
|
|
763
|
+
const result = await extractExternalPathsFromBashCommand(
|
|
764
|
+
"sed -i 's/foo/bar/' /etc/hosts",
|
|
765
|
+
cwd,
|
|
766
|
+
);
|
|
767
|
+
// Ideally this would detect /etc/hosts, but position tracking
|
|
768
|
+
// treats it as the inline script. Assert current behavior so
|
|
769
|
+
// a future fix can flip this expectation.
|
|
770
|
+
expect(result).toHaveLength(0);
|
|
771
|
+
});
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
|
|
548
775
|
describe("regex patterns are not mistaken for paths", () => {
|
|
549
776
|
test("grep -v with //.*pattern is not flagged", async () => {
|
|
550
777
|
const result = await extractExternalPathsFromBashCommand(
|
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Hoisted stubs for mocks that reference them in vi.mock factories.
|
|
5
|
+
const { mockSpawnSync, mockExistsSync } = vi.hoisted(() => ({
|
|
6
|
+
mockSpawnSync: vi.fn(),
|
|
7
|
+
mockExistsSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
// Mock node:child_process so tests don't spawn real subprocesses.
|
|
11
|
+
vi.mock("node:child_process", () => ({
|
|
12
|
+
spawnSync: mockSpawnSync,
|
|
13
|
+
default: { spawnSync: mockSpawnSync },
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
// Mock node:fs so existsSync is controllable.
|
|
17
|
+
vi.mock("node:fs", () => ({
|
|
18
|
+
existsSync: mockExistsSync,
|
|
19
|
+
default: { existsSync: mockExistsSync },
|
|
20
|
+
}));
|
|
3
21
|
|
|
4
22
|
// Mock node:os so tilde-expansion is deterministic across platforms.
|
|
5
23
|
vi.mock("node:os", () => {
|
|
@@ -11,6 +29,7 @@ vi.mock("node:os", () => {
|
|
|
11
29
|
});
|
|
12
30
|
|
|
13
31
|
import {
|
|
32
|
+
discoverGlobalNodeModulesRoot,
|
|
14
33
|
formatExternalDirectoryAskPrompt,
|
|
15
34
|
formatExternalDirectoryDenyReason,
|
|
16
35
|
formatExternalDirectoryHardStopHint,
|
|
@@ -317,3 +336,90 @@ describe("formatExternalDirectoryUserDeniedReason", () => {
|
|
|
317
336
|
expect(result).not.toContain("Reason:");
|
|
318
337
|
});
|
|
319
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("discoverGlobalNodeModulesRoot", () => {
|
|
341
|
+
// The walk-up-from-self strategy uses import.meta.url which resolves to a
|
|
342
|
+
// path inside the source tree during tests — there is no node_modules
|
|
343
|
+
// ancestor. So the fallback path is exercised naturally here.
|
|
344
|
+
//
|
|
345
|
+
// For the "walk-up succeeds" case, we verify the subprocess is NOT called
|
|
346
|
+
// by confirming spawnSync call count stays at zero when the URL has a
|
|
347
|
+
// node_modules ancestor.
|
|
348
|
+
|
|
349
|
+
beforeEach(() => {
|
|
350
|
+
mockSpawnSync.mockReset();
|
|
351
|
+
mockExistsSync.mockReset();
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
test("returns node_modules root when URL is inside a node_modules tree", () => {
|
|
355
|
+
// Simulate a URL whose file path contains a node_modules ancestor.
|
|
356
|
+
const fakeUrl =
|
|
357
|
+
"file:///opt/homebrew/lib/node_modules/@gotgenes/pi-permission-system/dist/external-directory.js";
|
|
358
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
359
|
+
expect(result).toBe("/opt/homebrew/lib/node_modules");
|
|
360
|
+
// Subprocess should NOT have been invoked — walk-up succeeds.
|
|
361
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test("calls npm root -g as fallback when walk-up finds no node_modules ancestor", () => {
|
|
365
|
+
const npmRootPath = "/opt/homebrew/lib/node_modules";
|
|
366
|
+
mockSpawnSync.mockReturnValue({
|
|
367
|
+
status: 0,
|
|
368
|
+
stdout: `${npmRootPath}\n`,
|
|
369
|
+
});
|
|
370
|
+
mockExistsSync.mockReturnValue(true);
|
|
371
|
+
|
|
372
|
+
// Use a file URL with no node_modules ancestor.
|
|
373
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
374
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
375
|
+
|
|
376
|
+
expect(mockSpawnSync).toHaveBeenCalledWith(
|
|
377
|
+
"npm",
|
|
378
|
+
["root", "-g"],
|
|
379
|
+
expect.objectContaining({ encoding: "utf-8" }),
|
|
380
|
+
);
|
|
381
|
+
expect(result).toBe(npmRootPath);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("returns null when walk-up fails and npm root -g returns non-zero exit", () => {
|
|
385
|
+
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
|
386
|
+
|
|
387
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
388
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
389
|
+
|
|
390
|
+
expect(result).toBeNull();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("returns null when walk-up fails and spawnSync throws", () => {
|
|
394
|
+
mockSpawnSync.mockImplementation(() => {
|
|
395
|
+
throw new Error("ENOENT");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
399
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
400
|
+
|
|
401
|
+
expect(result).toBeNull();
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("returns null when walk-up fails and npm root -g returns non-existent path", () => {
|
|
405
|
+
mockSpawnSync.mockReturnValue({
|
|
406
|
+
status: 0,
|
|
407
|
+
stdout: "/some/nonexistent/node_modules\n",
|
|
408
|
+
});
|
|
409
|
+
mockExistsSync.mockReturnValue(false);
|
|
410
|
+
|
|
411
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
412
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
413
|
+
|
|
414
|
+
expect(result).toBeNull();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("returns null when walk-up fails and npm root -g returns empty stdout", () => {
|
|
418
|
+
mockSpawnSync.mockReturnValue({ status: 0, stdout: " " });
|
|
419
|
+
|
|
420
|
+
const fakeUrl = "file:///Users/dev/my-project/src/external-directory.ts";
|
|
421
|
+
const result = discoverGlobalNodeModulesRoot(fakeUrl);
|
|
422
|
+
|
|
423
|
+
expect(result).toBeNull();
|
|
424
|
+
});
|
|
425
|
+
});
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
|
-
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
// Hoisted stub so the vi.mock factory can reference it.
|
|
5
|
+
const { mockSpawnSync } = vi.hoisted(() => ({
|
|
6
|
+
mockSpawnSync: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
// Mock node:child_process so tests that exercise the subprocess fallback path
|
|
10
|
+
// don't actually invoke npm. Default: subprocess fails (non-zero exit), so
|
|
11
|
+
// tests focused on the walk-up strategy continue to expect null.
|
|
12
|
+
vi.mock("node:child_process", () => ({
|
|
13
|
+
spawnSync: mockSpawnSync,
|
|
14
|
+
default: { spawnSync: mockSpawnSync },
|
|
15
|
+
}));
|
|
3
16
|
|
|
4
17
|
import {
|
|
5
18
|
discoverGlobalNodeModulesRoot,
|
|
@@ -9,6 +22,13 @@ import {
|
|
|
9
22
|
// ── discoverGlobalNodeModulesRoot ──────────────────────────────────────────
|
|
10
23
|
|
|
11
24
|
describe("discoverGlobalNodeModulesRoot", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Default: subprocess fails, so walk-up-focused tests see null for URLs
|
|
27
|
+
// with no node_modules ancestor.
|
|
28
|
+
mockSpawnSync.mockReset();
|
|
29
|
+
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
|
30
|
+
});
|
|
31
|
+
|
|
12
32
|
test("returns the node_modules dir when the file is inside one", () => {
|
|
13
33
|
const url =
|
|
14
34
|
"file:///opt/homebrew/lib/node_modules/pi-permission-system/dist/external-directory.js";
|