@gotgenes/pi-permission-system 4.1.1 → 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 +17 -0
- package/README.md +1 -1
- package/package.json +3 -3
- package/src/external-directory.ts +208 -11
- package/src/handlers/tool-call.ts +1 -1
- package/tests/bash-external-directory.test.ts +244 -94
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ 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
|
+
|
|
8
25
|
## [4.1.1](https://github.com/gotgenes/pi-permission-system/compare/v4.1.0...v4.1.1) (2026-05-04)
|
|
9
26
|
|
|
10
27
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
|
204
|
-
*
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
);
|
|
@@ -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(
|
|
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(
|
|
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
|
-
//
|
|
218
|
-
//
|
|
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(
|
|
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(
|
|
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:
|
|
305
|
-
//
|
|
306
|
-
//
|
|
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
|
|
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("
|
|
358
|
-
test("does not flag path inside string when escaped quote is present", () => {
|
|
359
|
-
//
|
|
360
|
-
|
|
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
|
);
|