@gotgenes/pi-permission-system 4.1.0 → 4.2.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 CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [4.2.0](https://github.com/gotgenes/pi-permission-system/compare/v4.1.1...v4.2.0) (2026-05-04)
9
+
10
+
11
+ ### Features
12
+
13
+ * replace shell-quote with tree-sitter-bash for AST-based path extraction ([7dce2a4](https://github.com/gotgenes/pi-permission-system/commit/7dce2a4d264d26171a1d54db265f12f3f1d342c6))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * note tree-sitter follow-up addressed by [#74](https://github.com/gotgenes/pi-permission-system/issues/74) ([bd835bd](https://github.com/gotgenes/pi-permission-system/commit/bd835bda62af9aa9149149c24ddb14927d52abf4))
19
+ * note tree-sitter-bash AST parser in architecture docs ([ecec2a6](https://github.com/gotgenes/pi-permission-system/commit/ecec2a6db375434a4bc2f920a21741fe29786896))
20
+ * plan tree-sitter-bash AST-based path extraction ([#74](https://github.com/gotgenes/pi-permission-system/issues/74)) ([1693794](https://github.com/gotgenes/pi-permission-system/commit/1693794fd423eaf872400a2a6dc3b0d0faeba13a))
21
+ * rename current-architecture.md to v3-architecture.md ([38d91c5](https://github.com/gotgenes/pi-permission-system/commit/38d91c587a842caa71b32f379ed5723e73f490f4))
22
+ * **retro:** add retro notes for issue [#73](https://github.com/gotgenes/pi-permission-system/issues/73) ([d73097d](https://github.com/gotgenes/pi-permission-system/commit/d73097d7dcda097fb79b6213b482bac8642f4a90))
23
+ * update bash external-directory description for tree-sitter AST parser ([d022d3d](https://github.com/gotgenes/pi-permission-system/commit/d022d3d87ab0cc0192ba9adbaf3b9bf379dfa414))
24
+
25
+ ## [4.1.1](https://github.com/gotgenes/pi-permission-system/compare/v4.1.0...v4.1.1) (2026-05-04)
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * add dotAll flag so wildcard `*` matches newlines ([#73](https://github.com/gotgenes/pi-permission-system/issues/73)) ([57085e3](https://github.com/gotgenes/pi-permission-system/commit/57085e3c9dbe80204e5629a3c18f8c1f307226f8))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * plan dotAll fix for wildcard multiline matching ([#73](https://github.com/gotgenes/pi-permission-system/issues/73)) ([b9c0a5b](https://github.com/gotgenes/pi-permission-system/commit/b9c0a5bcabc0f77e85d4932f7e2cf584a1bc0223))
36
+
8
37
  ## [4.1.0](https://github.com/gotgenes/pi-permission-system/compare/v4.0.1...v4.1.0) (2026-05-04)
9
38
 
10
39
 
package/README.md CHANGED
@@ -91,7 +91,7 @@ The extension integrates via Pi's lifecycle hooks:
91
91
  - Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries instead of raw multiline JSON
92
92
  - Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls so approvals can be audited without writing raw full payloads
93
93
  - Path-bearing file tools (`read`, `write`, `edit`, `find`, `grep`, `ls`) evaluate `permission.external_directory` before their normal tool permission when an explicit path points outside `ctx.cwd`
94
- - Bash commands are scanned for path tokens (absolute, `~/`, or `..`-relative) that resolve outside `ctx.cwd`; matching commands trigger the same `permission.external_directory` gate before the normal bash pattern check
94
+ - Bash commands are parsed with a full bash AST (`web-tree-sitter` + `tree-sitter-bash`) to extract path-bearing arguments; only genuine command arguments and redirect destinations are checked — heredoc bodies, comments, and quoted string contents are correctly excluded — and paths that resolve outside `ctx.cwd` trigger the same `permission.external_directory` gate before the normal bash pattern check
95
95
 
96
96
  ## Configuration
97
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "4.1.0",
3
+ "version": "4.2.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "files": [
@@ -56,13 +56,13 @@
56
56
  "@mariozechner/pi-coding-agent": "^0.72.1",
57
57
  "@mariozechner/pi-tui": "^0.72.1",
58
58
  "@types/node": "^25.6.0",
59
- "@types/shell-quote": "^1.7.5",
60
59
  "markdownlint-cli2": "^0.22.1",
61
60
  "typescript": "6.0.3",
62
61
  "vitest": "^4.1.5"
63
62
  },
64
63
  "dependencies": {
65
- "shell-quote": "^1.8.3"
64
+ "tree-sitter-bash": "^0.25.1",
65
+ "web-tree-sitter": "^0.26.8"
66
66
  },
67
67
  "scripts": {
68
68
  "build": "tsc -p tsconfig.json",
@@ -1,8 +1,7 @@
1
+ import { createRequire } from "node:module";
1
2
  import { homedir } from "node:os";
2
3
  import { join, normalize, resolve, sep } from "node:path";
3
4
 
4
- import { parse } from "shell-quote";
5
-
6
5
  import { getNonEmptyString, toRecord } from "./common";
7
6
 
8
7
  /**
@@ -157,6 +156,197 @@ export function formatBashExternalDirectoryDenyReason(
157
156
  return `${subject} is not permitted to run bash command '${command}' which references path(s) outside working directory '${cwd}': ${pathList}. ${formatExternalDirectoryHardStopHint()}`;
158
157
  }
159
158
 
159
+ // ── tree-sitter-bash lazy parser ───────────────────────────────────────────
160
+
161
+ /**
162
+ * Minimal subset of web-tree-sitter's SyntaxNode used by the AST walker.
163
+ * Defined locally so callers do not need to import web-tree-sitter types.
164
+ */
165
+ interface TSNode {
166
+ readonly type: string;
167
+ readonly text: string;
168
+ readonly childCount: number;
169
+ child(index: number): TSNode | null;
170
+ }
171
+
172
+ /**
173
+ * Minimal subset of web-tree-sitter's Parser used by this module.
174
+ */
175
+ interface TSParser {
176
+ parse(input: string): { rootNode: TSNode; delete(): void } | null;
177
+ delete(): void;
178
+ }
179
+
180
+ let parserPromise: Promise<TSParser> | null = null;
181
+
182
+ async function initParser(): Promise<TSParser> {
183
+ // Use named imports — web-tree-sitter exports Parser as a named class.
184
+ const { Parser, Language } = await import("web-tree-sitter");
185
+ const req = createRequire(import.meta.url);
186
+ const treeSitterWasm = req.resolve("web-tree-sitter/web-tree-sitter.wasm");
187
+ await Parser.init({ locateFile: () => treeSitterWasm });
188
+
189
+ const parser = new Parser();
190
+ const bashWasm = req.resolve("tree-sitter-bash/tree-sitter-bash.wasm");
191
+ const bash = await Language.load(bashWasm);
192
+ parser.setLanguage(bash);
193
+ return parser as TSParser;
194
+ }
195
+
196
+ function getParser(): Promise<TSParser> {
197
+ if (!parserPromise) {
198
+ parserPromise = initParser();
199
+ }
200
+ return parserPromise;
201
+ }
202
+
203
+ /**
204
+ * Reset the cached parser promise. Only used by tests to avoid
205
+ * cross-test pollution or to inject a mock parser.
206
+ */
207
+ export function resetParserForTesting(): void {
208
+ parserPromise = null;
209
+ }
210
+
211
+ // ── AST walker ─────────────────────────────────────────────────────────────
212
+
213
+ /**
214
+ * Node types whose subtrees must never be descended into for
215
+ * path extraction — their text content is not a command argument.
216
+ */
217
+ const SKIP_SUBTREE_TYPES = new Set(["heredoc_body", "heredoc_end", "comment"]);
218
+
219
+ /**
220
+ * Resolve the "shell value" of an argument node — the string the shell
221
+ * would pass to the command after quote removal.
222
+ *
223
+ * - `word` → `.text` (already unquoted)
224
+ * - `raw_string` → strip surrounding single quotes
225
+ * - `string` → strip surrounding double quotes, concatenate children text
226
+ * - `concatenation` → concatenate resolved children
227
+ * - other → `.text` as fallback
228
+ */
229
+ function resolveNodeText(node: TSNode): string {
230
+ switch (node.type) {
231
+ case "word":
232
+ return node.text;
233
+ case "raw_string": {
234
+ // Strip surrounding single quotes: 'content' → content
235
+ const t = node.text;
236
+ if (t.length >= 2 && t[0] === "'" && t[t.length - 1] === "'") {
237
+ return t.slice(1, -1);
238
+ }
239
+ return t;
240
+ }
241
+ case "string": {
242
+ // Double-quoted string: concatenate the resolved text of inner children,
243
+ // skipping the quote-delimiter nodes (literal `"`).
244
+ let result = "";
245
+ for (let i = 0; i < node.childCount; i++) {
246
+ const child = node.child(i);
247
+ if (!child) continue;
248
+ // Skip the literal `"` delimiters
249
+ if (child.type === '"') continue;
250
+ result += resolveNodeText(child);
251
+ }
252
+ return result;
253
+ }
254
+ case "string_content":
255
+ case "simple_expansion":
256
+ case "expansion":
257
+ return node.text;
258
+ case "concatenation": {
259
+ let result = "";
260
+ for (let i = 0; i < node.childCount; i++) {
261
+ const child = node.child(i);
262
+ if (!child) continue;
263
+ result += resolveNodeText(child);
264
+ }
265
+ return result;
266
+ }
267
+ default:
268
+ return node.text;
269
+ }
270
+ }
271
+
272
+ /**
273
+ * Recursively visit the AST and collect resolved text of nodes that
274
+ * represent command arguments or redirect destinations.
275
+ *
276
+ * Skips `heredoc_body`, `heredoc_end`, and `comment` subtrees entirely.
277
+ */
278
+ function collectPathCandidateTokens(node: TSNode, tokens: string[]): void {
279
+ if (SKIP_SUBTREE_TYPES.has(node.type)) return;
280
+
281
+ // Extract arguments from `command` nodes (skip the command name).
282
+ if (node.type === "command") {
283
+ let seenCommandName = false;
284
+ for (let i = 0; i < node.childCount; i++) {
285
+ const child = node.child(i);
286
+ if (!child) continue;
287
+
288
+ if (child.type === "command_name") {
289
+ seenCommandName = true;
290
+ continue;
291
+ }
292
+ // Skip variable_assignment nodes (FOO=/bar)
293
+ if (child.type === "variable_assignment") continue;
294
+
295
+ // If there was no explicit command_name node, the first word-like
296
+ // child is the command name itself — skip it.
297
+ if (
298
+ !seenCommandName &&
299
+ (child.type === "word" ||
300
+ child.type === "concatenation" ||
301
+ child.type === "string" ||
302
+ child.type === "raw_string")
303
+ ) {
304
+ seenCommandName = true;
305
+ continue;
306
+ }
307
+
308
+ // Argument nodes: resolve their text and collect.
309
+ if (
310
+ child.type === "word" ||
311
+ child.type === "concatenation" ||
312
+ child.type === "string" ||
313
+ child.type === "raw_string"
314
+ ) {
315
+ tokens.push(resolveNodeText(child));
316
+ continue;
317
+ }
318
+
319
+ // Recurse into other children (e.g. command_substitution nested in args)
320
+ collectPathCandidateTokens(child, tokens);
321
+ }
322
+ return;
323
+ }
324
+
325
+ // Extract redirect destinations from `file_redirect` nodes.
326
+ if (node.type === "file_redirect") {
327
+ for (let i = 0; i < node.childCount; i++) {
328
+ const child = node.child(i);
329
+ if (!child) continue;
330
+ if (
331
+ child.type === "word" ||
332
+ child.type === "concatenation" ||
333
+ child.type === "string" ||
334
+ child.type === "raw_string"
335
+ ) {
336
+ tokens.push(resolveNodeText(child));
337
+ }
338
+ }
339
+ return;
340
+ }
341
+
342
+ // For all other node types, recurse into children.
343
+ for (let i = 0; i < node.childCount; i++) {
344
+ const child = node.child(i);
345
+ if (!child) continue;
346
+ collectPathCandidateTokens(child, tokens);
347
+ }
348
+ }
349
+
160
350
  /**
161
351
  * URL pattern to skip tokens that look like URLs rather than paths.
162
352
  */
@@ -200,18 +390,25 @@ function classifyTokenAsPathCandidate(token: string): string | null {
200
390
 
201
391
  /**
202
392
  * Extracts paths from a bash command string that resolve outside CWD.
203
- * Uses shell-quote to tokenize so that quoted strings, operators, and comments
204
- * are handled correctly, eliminating false positives from the old regex approach.
393
+ * Uses tree-sitter-bash to parse the command into a full AST, then walks
394
+ * command argument and redirect-destination nodes. Heredoc bodies, comments,
395
+ * and other non-argument content are skipped, eliminating false positives.
205
396
  */
206
- export function extractExternalPathsFromBashCommand(
397
+ export async function extractExternalPathsFromBashCommand(
207
398
  command: string,
208
399
  cwd: string,
209
- ): string[] {
210
- // shell-quote parse() returns strings (resolved arguments), operator objects,
211
- // glob objects, and comment objects. Only string entries are path candidates.
212
- const tokens = parse(command).filter(
213
- (entry): entry is string => typeof entry === "string",
214
- );
400
+ ): Promise<string[]> {
401
+ const parser = await getParser();
402
+ const tree = parser.parse(command);
403
+ if (!tree) return [];
404
+
405
+ const tokens: string[] = [];
406
+ try {
407
+ collectPathCandidateTokens(tree.rootNode, tokens);
408
+ } finally {
409
+ tree.delete();
410
+ }
411
+
215
412
  const seen = new Set<string>();
216
413
  const externalPaths: string[] = [];
217
414
 
@@ -252,7 +252,7 @@ export async function handleToolCall(
252
252
  if (ctx.cwd && toolName === "bash") {
253
253
  const command = getNonEmptyString(toRecord(input).command);
254
254
  if (command) {
255
- const externalPaths = extractExternalPathsFromBashCommand(
255
+ const externalPaths = await extractExternalPathsFromBashCommand(
256
256
  command,
257
257
  ctx.cwd,
258
258
  );
@@ -26,7 +26,7 @@ export function compileWildcardPattern<TState>(
26
26
  return {
27
27
  pattern,
28
28
  state,
29
- regex: new RegExp(`^${escaped}$`),
29
+ regex: new RegExp(`^${escaped}$`, "s"),
30
30
  };
31
31
  }
32
32
 
@@ -23,13 +23,16 @@ describe("extractExternalPathsFromBashCommand", () => {
23
23
  const cwd = "/projects/my-app";
24
24
 
25
25
  describe("absolute paths", () => {
26
- test("detects absolute path outside CWD", () => {
27
- const result = extractExternalPathsFromBashCommand("cat /etc/hosts", cwd);
26
+ test("detects absolute path outside CWD", async () => {
27
+ const result = await extractExternalPathsFromBashCommand(
28
+ "cat /etc/hosts",
29
+ cwd,
30
+ );
28
31
  expect(result).toContain("/etc/hosts");
29
32
  });
30
33
 
31
- test("detects multiple absolute paths outside CWD", () => {
32
- const result = extractExternalPathsFromBashCommand(
34
+ test("detects multiple absolute paths outside CWD", async () => {
35
+ const result = await extractExternalPathsFromBashCommand(
33
36
  "diff /etc/hosts /var/log/syslog",
34
37
  cwd,
35
38
  );
@@ -37,8 +40,8 @@ describe("extractExternalPathsFromBashCommand", () => {
37
40
  expect(result).toContain("/var/log/syslog");
38
41
  });
39
42
 
40
- test("does not flag absolute path within CWD", () => {
41
- const result = extractExternalPathsFromBashCommand(
43
+ test("does not flag absolute path within CWD", async () => {
44
+ const result = await extractExternalPathsFromBashCommand(
42
45
  "cat /projects/my-app/src/index.ts",
43
46
  cwd,
44
47
  );
@@ -47,17 +50,17 @@ describe("extractExternalPathsFromBashCommand", () => {
47
50
  });
48
51
 
49
52
  describe("home-relative paths", () => {
50
- test("detects ~/path outside CWD", () => {
51
- const result = extractExternalPathsFromBashCommand(
53
+ test("detects ~/path outside CWD", async () => {
54
+ const result = await extractExternalPathsFromBashCommand(
52
55
  "cat ~/documents/secret.txt",
53
56
  cwd,
54
57
  );
55
58
  expect(result).toContain("/mock/home/documents/secret.txt");
56
59
  });
57
60
 
58
- test("does not flag ~/path that resolves within CWD", () => {
61
+ test("does not flag ~/path that resolves within CWD", async () => {
59
62
  // CWD is under /mock/home for this test
60
- const result = extractExternalPathsFromBashCommand(
63
+ const result = await extractExternalPathsFromBashCommand(
61
64
  "cat ~/myproject/file.ts",
62
65
  "/mock/home/myproject",
63
66
  );
@@ -66,16 +69,16 @@ describe("extractExternalPathsFromBashCommand", () => {
66
69
  });
67
70
 
68
71
  describe("dot-dot relative paths", () => {
69
- test("detects ../ path that resolves outside CWD", () => {
70
- const result = extractExternalPathsFromBashCommand(
72
+ test("detects ../ path that resolves outside CWD", async () => {
73
+ const result = await extractExternalPathsFromBashCommand(
71
74
  "cat ../../other-project/secrets.env",
72
75
  cwd,
73
76
  );
74
77
  expect(result).toContain("/other-project/secrets.env");
75
78
  });
76
79
 
77
- test("does not flag ../ path that stays within CWD", () => {
78
- const result = extractExternalPathsFromBashCommand(
80
+ test("does not flag ../ path that stays within CWD", async () => {
81
+ const result = await extractExternalPathsFromBashCommand(
79
82
  "cat src/../lib/utils.ts",
80
83
  cwd,
81
84
  );
@@ -84,31 +87,34 @@ describe("extractExternalPathsFromBashCommand", () => {
84
87
  });
85
88
 
86
89
  describe("commands within CWD only", () => {
87
- test("returns empty for relative paths within CWD", () => {
88
- const result = extractExternalPathsFromBashCommand(
90
+ test("returns empty for relative paths within CWD", async () => {
91
+ const result = await extractExternalPathsFromBashCommand(
89
92
  "cat src/index.ts",
90
93
  cwd,
91
94
  );
92
95
  expect(result).toHaveLength(0);
93
96
  });
94
97
 
95
- test("returns empty for bare command with no path arguments", () => {
96
- const result = extractExternalPathsFromBashCommand("git status", cwd);
98
+ test("returns empty for bare command with no path arguments", async () => {
99
+ const result = await extractExternalPathsFromBashCommand(
100
+ "git status",
101
+ cwd,
102
+ );
97
103
  expect(result).toHaveLength(0);
98
104
  });
99
105
  });
100
106
 
101
107
  describe("flags are skipped", () => {
102
- test("does not treat flags as paths", () => {
103
- const result = extractExternalPathsFromBashCommand(
108
+ test("does not treat flags as paths", async () => {
109
+ const result = await extractExternalPathsFromBashCommand(
104
110
  "ls -la --color=auto",
105
111
  cwd,
106
112
  );
107
113
  expect(result).toHaveLength(0);
108
114
  });
109
115
 
110
- test("detects path after flags", () => {
111
- const result = extractExternalPathsFromBashCommand(
116
+ test("detects path after flags", async () => {
117
+ const result = await extractExternalPathsFromBashCommand(
112
118
  "ls -la /etc/passwd",
113
119
  cwd,
114
120
  );
@@ -117,8 +123,8 @@ describe("extractExternalPathsFromBashCommand", () => {
117
123
  });
118
124
 
119
125
  describe("env assignments are skipped", () => {
120
- test("does not treat FOO=/bar as a path", () => {
121
- const result = extractExternalPathsFromBashCommand(
126
+ test("does not treat FOO=/bar as a path", async () => {
127
+ const result = await extractExternalPathsFromBashCommand(
122
128
  "FOO=/usr/local/bin command",
123
129
  cwd,
124
130
  );
@@ -127,32 +133,32 @@ describe("extractExternalPathsFromBashCommand", () => {
127
133
  });
128
134
 
129
135
  describe("shell metacharacters split correctly", () => {
130
- test("detects path after pipe", () => {
131
- const result = extractExternalPathsFromBashCommand(
136
+ test("detects path after pipe", async () => {
137
+ const result = await extractExternalPathsFromBashCommand(
132
138
  "echo hello | tee /tmp/output.txt",
133
139
  cwd,
134
140
  );
135
141
  expect(result).toContain("/tmp/output.txt");
136
142
  });
137
143
 
138
- test("detects path after semicolon", () => {
139
- const result = extractExternalPathsFromBashCommand(
144
+ test("detects path after semicolon", async () => {
145
+ const result = await extractExternalPathsFromBashCommand(
140
146
  "echo done; cat /etc/hosts",
141
147
  cwd,
142
148
  );
143
149
  expect(result).toContain("/etc/hosts");
144
150
  });
145
151
 
146
- test("detects path after &&", () => {
147
- const result = extractExternalPathsFromBashCommand(
152
+ test("detects path after &&", async () => {
153
+ const result = await extractExternalPathsFromBashCommand(
148
154
  "true && cat /etc/hosts",
149
155
  cwd,
150
156
  );
151
157
  expect(result).toContain("/etc/hosts");
152
158
  });
153
159
 
154
- test("detects path in redirect target", () => {
155
- const result = extractExternalPathsFromBashCommand(
160
+ test("detects path in redirect target", async () => {
161
+ const result = await extractExternalPathsFromBashCommand(
156
162
  "echo hello > /tmp/out.txt",
157
163
  cwd,
158
164
  );
@@ -161,16 +167,16 @@ describe("extractExternalPathsFromBashCommand", () => {
161
167
  });
162
168
 
163
169
  describe("URLs are skipped", () => {
164
- test("does not treat http:// URL as a path", () => {
165
- const result = extractExternalPathsFromBashCommand(
170
+ test("does not treat http:// URL as a path", async () => {
171
+ const result = await extractExternalPathsFromBashCommand(
166
172
  "curl http://example.com/path",
167
173
  cwd,
168
174
  );
169
175
  expect(result).toHaveLength(0);
170
176
  });
171
177
 
172
- test("does not treat https:// URL as a path", () => {
173
- const result = extractExternalPathsFromBashCommand(
178
+ test("does not treat https:// URL as a path", async () => {
179
+ const result = await extractExternalPathsFromBashCommand(
174
180
  "curl https://example.com/etc/hosts",
175
181
  cwd,
176
182
  );
@@ -179,8 +185,8 @@ describe("extractExternalPathsFromBashCommand", () => {
179
185
  });
180
186
 
181
187
  describe("@scope/package patterns are skipped", () => {
182
- test("does not treat @scope/package as a path", () => {
183
- const result = extractExternalPathsFromBashCommand(
188
+ test("does not treat @scope/package as a path", async () => {
189
+ const result = await extractExternalPathsFromBashCommand(
184
190
  "npm install @types/node",
185
191
  cwd,
186
192
  );
@@ -189,34 +195,34 @@ describe("extractExternalPathsFromBashCommand", () => {
189
195
  });
190
196
 
191
197
  describe("quoted strings are ignored", () => {
192
- test("does not flag path inside double-quoted string", () => {
193
- const result = extractExternalPathsFromBashCommand(
198
+ test("does not flag path inside double-quoted string", async () => {
199
+ const result = await extractExternalPathsFromBashCommand(
194
200
  'git commit -m "fix: update /etc/hosts handler"',
195
201
  cwd,
196
202
  );
197
203
  expect(result).toHaveLength(0);
198
204
  });
199
205
 
200
- test("does not flag path inside single-quoted string", () => {
201
- const result = extractExternalPathsFromBashCommand(
206
+ test("does not flag path inside single-quoted string", async () => {
207
+ const result = await extractExternalPathsFromBashCommand(
202
208
  "echo 'see /usr/local/docs for info'",
203
209
  cwd,
204
210
  );
205
211
  expect(result).toHaveLength(0);
206
212
  });
207
213
 
208
- test("still flags unquoted path alongside quoted content", () => {
209
- const result = extractExternalPathsFromBashCommand(
214
+ test("still flags unquoted path alongside quoted content", async () => {
215
+ const result = await extractExternalPathsFromBashCommand(
210
216
  'cat /etc/hosts && echo "done"',
211
217
  cwd,
212
218
  );
213
219
  expect(result).toContain("/etc/hosts");
214
220
  });
215
221
 
216
- test("does not flag path when adjacent quoted segments form one word", () => {
217
- // shell-quote concatenates adjacent quoted/unquoted segments into one word:
218
- // '"path is "/etc/hosts""' 'path is /etc/hosts' (one token, not a path candidate).
219
- const result = extractExternalPathsFromBashCommand(
222
+ test("does not flag path when adjacent quoted segments form one word", async () => {
223
+ // tree-sitter parses adjacent quoted/unquoted segments as a concatenation node
224
+ // whose resolved text is 'path is /etc/hosts' (one token, not a path candidate).
225
+ const result = await extractExternalPathsFromBashCommand(
220
226
  'echo "path is "/etc/hosts""',
221
227
  cwd,
222
228
  );
@@ -225,45 +231,48 @@ describe("extractExternalPathsFromBashCommand", () => {
225
231
  });
226
232
 
227
233
  describe("safe system paths are filtered", () => {
228
- test("does not flag /dev/null in stderr redirect", () => {
229
- const result = extractExternalPathsFromBashCommand(
234
+ test("does not flag /dev/null in stderr redirect", async () => {
235
+ const result = await extractExternalPathsFromBashCommand(
230
236
  "command 2>/dev/null",
231
237
  cwd,
232
238
  );
233
239
  expect(result).toHaveLength(0);
234
240
  });
235
241
 
236
- test("does not flag /dev/null as a redirect target", () => {
237
- const result = extractExternalPathsFromBashCommand(
242
+ test("does not flag /dev/null as a redirect target", async () => {
243
+ const result = await extractExternalPathsFromBashCommand(
238
244
  "echo hello > /dev/null",
239
245
  cwd,
240
246
  );
241
247
  expect(result).toHaveLength(0);
242
248
  });
243
249
 
244
- test("does not flag /dev/stdin", () => {
245
- const result = extractExternalPathsFromBashCommand("cat /dev/stdin", cwd);
250
+ test("does not flag /dev/stdin", async () => {
251
+ const result = await extractExternalPathsFromBashCommand(
252
+ "cat /dev/stdin",
253
+ cwd,
254
+ );
246
255
  expect(result).toHaveLength(0);
247
256
  });
248
257
 
249
- test("does not flag /dev/stdout", () => {
250
- const result = extractExternalPathsFromBashCommand(
258
+ test("does not flag /dev/stdout", async () => {
259
+ const result = await extractExternalPathsFromBashCommand(
251
260
  "cat /dev/stdout",
252
261
  cwd,
253
262
  );
254
263
  expect(result).toHaveLength(0);
255
264
  });
256
265
 
257
- test("does not flag /dev/stderr", () => {
258
- const result = extractExternalPathsFromBashCommand(
266
+ test("does not flag /dev/stderr", async () => {
267
+ const result = await extractExternalPathsFromBashCommand(
259
268
  "cat /dev/stderr",
260
269
  cwd,
261
270
  );
262
271
  expect(result).toHaveLength(0);
263
272
  });
264
273
 
265
- test("still flags a real external path alongside /dev/null", () => {
266
- const result = extractExternalPathsFromBashCommand(
274
+ test("still flags a real external path alongside /dev/null", async () => {
275
+ const result = await extractExternalPathsFromBashCommand(
267
276
  "cat /etc/hosts 2>/dev/null",
268
277
  cwd,
269
278
  );
@@ -271,8 +280,8 @@ describe("extractExternalPathsFromBashCommand", () => {
271
280
  expect(result).not.toContain("/dev/null");
272
281
  });
273
282
 
274
- test("does not flag /dev/null/subdir (not a safe path)", () => {
275
- const result = extractExternalPathsFromBashCommand(
283
+ test("does not flag /dev/null/subdir (not a safe path)", async () => {
284
+ const result = await extractExternalPathsFromBashCommand(
276
285
  "cat /dev/null/subdir",
277
286
  cwd,
278
287
  );
@@ -281,37 +290,46 @@ describe("extractExternalPathsFromBashCommand", () => {
281
290
  });
282
291
 
283
292
  describe("bare-slash tokens are skipped", () => {
284
- test("does not flag // token", () => {
285
- const result = extractExternalPathsFromBashCommand("echo //", cwd);
293
+ test("does not flag // token", async () => {
294
+ const result = await extractExternalPathsFromBashCommand("echo //", cwd);
286
295
  expect(result).toHaveLength(0);
287
296
  });
288
297
 
289
- test("does not flag / token", () => {
290
- const result = extractExternalPathsFromBashCommand("echo /", cwd);
298
+ test("does not flag / token", async () => {
299
+ const result = await extractExternalPathsFromBashCommand("echo /", cwd);
291
300
  expect(result).toHaveLength(0);
292
301
  });
293
302
 
294
- test("does not flag /// token", () => {
295
- const result = extractExternalPathsFromBashCommand("echo ///", cwd);
303
+ test("does not flag /// token", async () => {
304
+ const result = await extractExternalPathsFromBashCommand("echo ///", cwd);
296
305
  expect(result).toHaveLength(0);
297
306
  });
298
307
 
299
- test("does not flag // in echo with other args", () => {
300
- const result = extractExternalPathsFromBashCommand("echo // hello", cwd);
308
+ test("does not flag // in echo with other args", async () => {
309
+ const result = await extractExternalPathsFromBashCommand(
310
+ "echo // hello",
311
+ cwd,
312
+ );
301
313
  expect(result).toHaveLength(0);
302
314
  });
303
315
 
304
- test("bare-slash guard is still needed: shell-quote emits / as a string token", () => {
305
- // shell-quote.parse('echo /') returns ['echo', '/'] the bare slash IS a string
306
- // token, not an operator. classifyTokenAsPathCandidate must still reject it.
316
+ test("bare-slash guard is still needed: tree-sitter emits / as a word node", async () => {
317
+ // tree-sitter parses 'echo /' with '/' as a word argument node.
318
+ // classifyTokenAsPathCandidate must still reject it.
307
319
  // This test documents that the /^\/+$/ guard remains a necessary
308
- // defense-in-depth layer even with shell-quote as the tokenizer.
309
- const result = extractExternalPathsFromBashCommand("echo /", cwd);
320
+ // defense-in-depth layer even with tree-sitter as the parser.
321
+ const result = await extractExternalPathsFromBashCommand("echo /", cwd);
322
+ expect(result).toHaveLength(0);
323
+ });
324
+
325
+ test("bare double-slash guard with tree-sitter", async () => {
326
+ // tree-sitter also emits '//' as a word node — guard must reject it.
327
+ const result = await extractExternalPathsFromBashCommand("echo //", cwd);
310
328
  expect(result).toHaveLength(0);
311
329
  });
312
330
 
313
- test("still flags real external path alongside //", () => {
314
- const result = extractExternalPathsFromBashCommand(
331
+ test("still flags real external path alongside //", async () => {
332
+ const result = await extractExternalPathsFromBashCommand(
315
333
  "cat /etc/hosts; echo //",
316
334
  cwd,
317
335
  );
@@ -321,23 +339,23 @@ describe("extractExternalPathsFromBashCommand", () => {
321
339
  });
322
340
 
323
341
  describe("node -e and multi-line commands", () => {
324
- test("does not flag path inside single-quoted string in node -e argument", () => {
325
- const result = extractExternalPathsFromBashCommand(
342
+ test("does not flag path inside single-quoted string in node -e argument", async () => {
343
+ const result = await extractExternalPathsFromBashCommand(
326
344
  "node -e \"const p = '/etc/hosts'; console.log(p);\"",
327
345
  cwd,
328
346
  );
329
347
  expect(result).toHaveLength(0);
330
348
  });
331
349
 
332
- test("does not flag path inside multi-line node -e argument", () => {
350
+ test("does not flag path inside multi-line node -e argument", async () => {
333
351
  // Actual newlines inside the double-quoted -e argument.
334
352
  const cmd =
335
353
  "node -e \"\nimport('x').then(() => {\n console.log('/etc/hosts');\n});\n\"";
336
- const result = extractExternalPathsFromBashCommand(cmd, cwd);
354
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
337
355
  expect(result).toHaveLength(0);
338
356
  });
339
357
 
340
- test("does not flag path that appears after escaped quote in multi-line node -e argument", () => {
358
+ test("does not flag path that appears after escaped quote in multi-line node -e argument", async () => {
341
359
  // This is the shape of the command that triggered a prompt during dog-fooding.
342
360
  // The outer \"...\" arg contains both actual newlines and \\" escape sequences,
343
361
  // with /etc/hosts appearing after a \\" boundary.
@@ -349,32 +367,31 @@ describe("extractExternalPathsFromBashCommand", () => {
349
367
  "});",
350
368
  '"',
351
369
  ].join("\n");
352
- const result = extractExternalPathsFromBashCommand(cmd, cwd);
370
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
353
371
  expect(result).toHaveLength(0);
354
372
  });
355
373
  });
356
374
 
357
- describe("shell-quote tokenizer edge cases", () => {
358
- test("does not flag path inside string when escaped quote is present", () => {
359
- // stripQuotedStrings regex breaks at \" content after it leaks into the token stream.
360
- // shell-quote correctly parses the escaped quote and keeps the path inside the string.
361
- const result = extractExternalPathsFromBashCommand(
375
+ describe("tokenizer edge cases", () => {
376
+ test("does not flag path inside string when escaped quote is present", async () => {
377
+ // tree-sitter correctly parses the escaped quote and keeps the path inside the string.
378
+ const result = await extractExternalPathsFromBashCommand(
362
379
  'git commit -m "fix: update \\"the /etc/hosts\\" handler"',
363
380
  cwd,
364
381
  );
365
382
  expect(result).toHaveLength(0);
366
383
  });
367
384
 
368
- test("does not flag path appearing only in a shell comment", () => {
369
- const result = extractExternalPathsFromBashCommand(
385
+ test("does not flag path appearing only in a shell comment", async () => {
386
+ const result = await extractExternalPathsFromBashCommand(
370
387
  "echo hello # /etc/shadow",
371
388
  cwd,
372
389
  );
373
390
  expect(result).toHaveLength(0);
374
391
  });
375
392
 
376
- test("flags real path before comment but not path inside comment", () => {
377
- const result = extractExternalPathsFromBashCommand(
393
+ test("flags real path before comment but not path inside comment", async () => {
394
+ const result = await extractExternalPathsFromBashCommand(
378
395
  "cat /etc/hosts # see also /etc/shadow",
379
396
  cwd,
380
397
  );
@@ -384,9 +401,142 @@ describe("extractExternalPathsFromBashCommand", () => {
384
401
  });
385
402
  });
386
403
 
404
+ describe("heredoc handling", () => {
405
+ test("does not flag path inside single-quoted heredoc delimiter", async () => {
406
+ const cmd = "cat << 'EOF'\n/etc/hosts\nEOF";
407
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
408
+ expect(result).toHaveLength(0);
409
+ });
410
+
411
+ test("does not flag path inside double-quoted heredoc delimiter", async () => {
412
+ const cmd = 'cat << "EOF"\n/etc/hosts\nEOF';
413
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
414
+ expect(result).toHaveLength(0);
415
+ });
416
+
417
+ test("does not flag path inside unquoted heredoc delimiter", async () => {
418
+ const cmd = "cat << EOF\n/etc/hosts\nEOF";
419
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
420
+ expect(result).toHaveLength(0);
421
+ });
422
+
423
+ test("flags real path alongside heredoc but not heredoc content", async () => {
424
+ const cmd = "cat /etc/hosts << 'EOF'\nsome content\nEOF";
425
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
426
+ expect(result).toContain("/etc/hosts");
427
+ expect(result).toHaveLength(1);
428
+ });
429
+
430
+ test("does not flag path inside indented heredoc (<<-)", async () => {
431
+ const cmd = "cat <<- 'EOF'\n\t/etc/hosts\nEOF";
432
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
433
+ expect(result).toHaveLength(0);
434
+ });
435
+ });
436
+
437
+ describe("defense-in-depth guards with tree-sitter", () => {
438
+ test("env assignment is a variable_assignment node, not a command argument", async () => {
439
+ // tree-sitter parses FOO=/usr/local/bin as a variable_assignment node.
440
+ // The walker skips variable_assignment, so the env-assignment guard in
441
+ // classifyTokenAsPathCandidate is defense-in-depth.
442
+ const result = await extractExternalPathsFromBashCommand(
443
+ "FOO=/usr/local/bin command",
444
+ cwd,
445
+ );
446
+ expect(result).toHaveLength(0);
447
+ });
448
+
449
+ test("URL is a word argument, classifyTokenAsPathCandidate rejects it", async () => {
450
+ // tree-sitter emits the URL as a plain word argument.
451
+ // classifyTokenAsPathCandidate's URL pattern must still reject it.
452
+ const result = await extractExternalPathsFromBashCommand(
453
+ "curl https://example.com/etc/hosts",
454
+ cwd,
455
+ );
456
+ expect(result).toHaveLength(0);
457
+ });
458
+
459
+ test("flag arguments are word nodes, classifyTokenAsPathCandidate rejects them", async () => {
460
+ // tree-sitter emits '-la' as a word argument.
461
+ // classifyTokenAsPathCandidate's flag check must still reject it.
462
+ const result = await extractExternalPathsFromBashCommand(
463
+ "ls -la --color=auto",
464
+ cwd,
465
+ );
466
+ expect(result).toHaveLength(0);
467
+ });
468
+ });
469
+
470
+ describe("command substitution", () => {
471
+ test("detects path inside command substitution", async () => {
472
+ const result = await extractExternalPathsFromBashCommand(
473
+ "echo $(cat /etc/hosts)",
474
+ cwd,
475
+ );
476
+ expect(result).toContain("/etc/hosts");
477
+ });
478
+
479
+ test("detects path inside nested command substitution", async () => {
480
+ const result = await extractExternalPathsFromBashCommand(
481
+ "echo $(echo $(cat /etc/hosts))",
482
+ cwd,
483
+ );
484
+ expect(result).toContain("/etc/hosts");
485
+ });
486
+
487
+ test("does not flag command substitution inside single-quoted heredoc", async () => {
488
+ // Single-quoted heredoc delimiter prevents expansion — content is literal.
489
+ const cmd = "cat << 'EOF'\n$(cat /etc/hosts)\nEOF";
490
+ const result = await extractExternalPathsFromBashCommand(cmd, cwd);
491
+ expect(result).toHaveLength(0);
492
+ });
493
+
494
+ test("detects path in subshell", async () => {
495
+ const result = await extractExternalPathsFromBashCommand(
496
+ "(cat /etc/hosts)",
497
+ cwd,
498
+ );
499
+ expect(result).toContain("/etc/hosts");
500
+ });
501
+ });
502
+
503
+ describe("redirect targets", () => {
504
+ test("detects path in output redirect", async () => {
505
+ const result = await extractExternalPathsFromBashCommand(
506
+ "echo hello > /tmp/out.txt",
507
+ cwd,
508
+ );
509
+ expect(result).toContain("/tmp/out.txt");
510
+ });
511
+
512
+ test("detects path in append redirect", async () => {
513
+ const result = await extractExternalPathsFromBashCommand(
514
+ "echo hello >> /tmp/out.txt",
515
+ cwd,
516
+ );
517
+ expect(result).toContain("/tmp/out.txt");
518
+ });
519
+
520
+ test("detects path in input redirect", async () => {
521
+ const result = await extractExternalPathsFromBashCommand(
522
+ "sort < /etc/hosts",
523
+ cwd,
524
+ );
525
+ expect(result).toContain("/etc/hosts");
526
+ });
527
+
528
+ test("detects path in stderr redirect", async () => {
529
+ const result = await extractExternalPathsFromBashCommand(
530
+ "command 2>/tmp/errors.log",
531
+ cwd,
532
+ );
533
+ expect(result).toContain("/tmp/errors.log");
534
+ });
535
+ });
536
+
387
537
  describe("deduplication", () => {
388
- test("returns deduplicated paths", () => {
389
- const result = extractExternalPathsFromBashCommand(
538
+ test("returns deduplicated paths", async () => {
539
+ const result = await extractExternalPathsFromBashCommand(
390
540
  "cat /etc/hosts; grep foo /etc/hosts",
391
541
  cwd,
392
542
  );
@@ -635,6 +635,27 @@ test("PermissionManager canonical built-in permission checking", () => {
635
635
  }
636
636
  });
637
637
 
638
+ test("multiline bash command resolves to allow via universal fallback", () => {
639
+ // Regression test for #73: node -e "..." with embedded newlines was
640
+ // falling through to the hard-coded 'ask' default because wildcardMatch
641
+ // used /^.*$/ (no dotAll), which does not match '\n'.
642
+ const { manager, cleanup } = createManager({
643
+ permission: {
644
+ "*": "allow",
645
+ bash: { "rm -rf *": "deny", "sudo *": "ask" },
646
+ },
647
+ });
648
+
649
+ try {
650
+ const command =
651
+ "node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
652
+ const result = manager.checkPermission("bash", { command });
653
+ assert.equal(result.state, "allow");
654
+ } finally {
655
+ cleanup();
656
+ }
657
+ });
658
+
638
659
  test("Bash specific deny patterns override catch-all within the same config", () => {
639
660
  // In the flat format, patterns within a surface map are ordered by insertion.
640
661
  // Last-match-wins means specific patterns placed AFTER the catch-all override it.
@@ -187,6 +187,22 @@ describe("wildcardMatch", () => {
187
187
  expect(wildcardMatch("*", "bash")).toBe(true);
188
188
  });
189
189
 
190
+ test("'*' pattern matches values containing newlines", () => {
191
+ expect(wildcardMatch("*", "line1\nline2")).toBe(true);
192
+ expect(wildcardMatch("*", "a\nb\nc")).toBe(true);
193
+ });
194
+
195
+ test("prefix-wildcard pattern matches value with embedded newlines", () => {
196
+ const command =
197
+ "node -e \"\nimport('x').then(() => {\n console.log('done');\n});\n\"";
198
+ expect(wildcardMatch("node *", command)).toBe(true);
199
+ });
200
+
201
+ test("compileWildcardPattern regex matches multiline string", () => {
202
+ const compiled = compileWildcardPattern("*", "allow");
203
+ expect(compiled.regex.test("a\nb")).toBe(true);
204
+ });
205
+
190
206
  test("exact pattern matches identical value", () => {
191
207
  expect(wildcardMatch("read", "read")).toBe(true);
192
208
  expect(wildcardMatch("external_directory", "external_directory")).toBe(