@gotgenes/pi-permission-system 16.0.0 → 16.0.2

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/package.json +1 -1
  3. package/src/access-intent/bash/command-enumeration.ts +154 -0
  4. package/src/access-intent/bash/cwd-projection.ts +493 -0
  5. package/src/access-intent/bash/node-text.ts +75 -0
  6. package/src/access-intent/bash/parser.ts +42 -0
  7. package/src/access-intent/bash/program.ts +102 -0
  8. package/src/{handlers/gates/bash-token-classification.ts → access-intent/bash/token-classification.ts} +1 -1
  9. package/src/access-intent/bash/token-collection.ts +351 -0
  10. package/src/handlers/gates/bash-command.ts +1 -1
  11. package/src/handlers/gates/bash-external-directory.ts +3 -3
  12. package/src/handlers/gates/bash-path-extractor.ts +2 -2
  13. package/src/handlers/gates/bash-path.ts +2 -2
  14. package/src/handlers/gates/external-directory.ts +0 -2
  15. package/src/handlers/gates/path.ts +1 -3
  16. package/src/handlers/gates/skill-read.ts +0 -4
  17. package/src/handlers/gates/tool-call-gate-pipeline.ts +2 -2
  18. package/src/handlers/gates/tool.ts +1 -1
  19. package/src/handlers/gates/types.ts +1 -1
  20. package/test/access-intent/bash/node-text.test.ts +147 -0
  21. package/test/access-intent/bash/parser.test.ts +19 -0
  22. package/test/{handlers/gates/bash-program.test.ts → access-intent/bash/program.test.ts} +136 -69
  23. package/test/{handlers/gates/bash-token-classification.test.ts → access-intent/bash/token-classification.test.ts} +1 -1
  24. package/test/access-intent/bash/token-collection.test.ts +300 -0
  25. package/test/composition-root.test.ts +80 -0
  26. package/test/handlers/external-directory-symlink-acceptance.test.ts +2 -3
  27. package/test/handlers/gates/bash-command-metamorphic.test.ts +2 -3
  28. package/test/handlers/gates/bash-external-directory.test.ts +2 -10
  29. package/test/handlers/gates/bash-path.test.ts +2 -2
  30. package/test/handlers/gates/external-directory.test.ts +0 -5
  31. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +5 -2
  32. package/src/handlers/gates/bash-program.ts +0 -1028
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
3
3
  import {
4
4
  classifyTokenAsPathCandidate,
5
5
  classifyTokenAsRuleCandidate,
6
- } from "#src/handlers/gates/bash-token-classification";
6
+ } from "#src/access-intent/bash/token-classification";
7
7
 
8
8
  // ── Shared rejection behaviour ─────────────────────────────────────────────
9
9
  //
@@ -0,0 +1,300 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TSNode } from "#src/access-intent/bash/parser";
3
+ import { getParser } from "#src/access-intent/bash/parser";
4
+ import {
5
+ collectCommandTokens,
6
+ collectPathCandidateTokens,
7
+ collectRedirectTokens,
8
+ extractCommandName,
9
+ } from "#src/access-intent/bash/token-collection";
10
+
11
+ // ── Helpers ───────────────────────────────────────────────────────────────────
12
+
13
+ /** Depth-first search for the first node of the given type. */
14
+ function findNode(node: TSNode, type: string): TSNode | null {
15
+ if (node.type === type) return node;
16
+ for (let i = 0; i < node.childCount; i++) {
17
+ const child = node.child(i);
18
+ if (!child) continue;
19
+ const found = findNode(child, type);
20
+ if (found) return found;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ /** Parse a bash snippet and return the first `command` node. */
26
+ async function parseCommandNode(cmd: string): Promise<{
27
+ node: TSNode;
28
+ tree: { rootNode: TSNode; delete(): void };
29
+ }> {
30
+ const parser = await getParser();
31
+ const tree = parser.parse(cmd);
32
+ if (!tree) throw new Error("parser.parse returned null");
33
+ const node = findNode(tree.rootNode, "command");
34
+ if (!node) throw new Error(`no command node found in: ${cmd}`);
35
+ return { node, tree };
36
+ }
37
+
38
+ /** Parse a bash snippet and return the first `file_redirect` node. */
39
+ async function parseRedirectNode(cmd: string): Promise<{
40
+ node: TSNode;
41
+ tree: { rootNode: TSNode; delete(): void };
42
+ }> {
43
+ const parser = await getParser();
44
+ const tree = parser.parse(cmd);
45
+ if (!tree) throw new Error("parser.parse returned null");
46
+ const node = findNode(tree.rootNode, "file_redirect");
47
+ if (!node) throw new Error(`no file_redirect node found in: ${cmd}`);
48
+ return { node, tree };
49
+ }
50
+
51
+ // ── extractCommandName ────────────────────────────────────────────────────────
52
+
53
+ describe("extractCommandName", () => {
54
+ it("returns the basename for a bare command", async () => {
55
+ const { node, tree } = await parseCommandNode("sed 's/x/y/' file.txt");
56
+ try {
57
+ expect(extractCommandName(node)).toBe("sed");
58
+ } finally {
59
+ tree.delete();
60
+ }
61
+ });
62
+
63
+ it("strips the directory prefix from an absolute command path", async () => {
64
+ const { node, tree } = await parseCommandNode(
65
+ "/usr/bin/sed 's/x/y/' file.txt",
66
+ );
67
+ try {
68
+ expect(extractCommandName(node)).toBe("sed");
69
+ } finally {
70
+ tree.delete();
71
+ }
72
+ });
73
+
74
+ it("returns the substitution text when the command name is a command substitution", async () => {
75
+ // $(which sed) parses with a command_name child whose text is "$(which sed)";
76
+ // resolveNodeText returns that text, so extractCommandName returns its basename.
77
+ // PATTERN_FIRST_COMMANDS.get("$(which sed)") returns undefined, so
78
+ // collectCommandTokens falls back to generic collection — correct behaviour.
79
+ const { node, tree } = await parseCommandNode(
80
+ "$(which sed) 's/x/y/' file.txt",
81
+ );
82
+ try {
83
+ expect(extractCommandName(node)).toBe("$(which sed)");
84
+ } finally {
85
+ tree.delete();
86
+ }
87
+ });
88
+ });
89
+
90
+ // ── collectCommandTokens — pattern-first commands ─────────────────────────────
91
+
92
+ describe("collectCommandTokens — pattern-first commands", () => {
93
+ it("sed: skips the first positional (inline pattern) and collects the rest", async () => {
94
+ const { node, tree } = await parseCommandNode("sed 's/x/y/' a.txt b.txt");
95
+ try {
96
+ expect(collectCommandTokens(node)).toEqual(["a.txt", "b.txt"]);
97
+ } finally {
98
+ tree.delete();
99
+ }
100
+ });
101
+
102
+ it("sed -e: skips the explicit script arg-consuming flag and collects positionals", async () => {
103
+ const { node, tree } = await parseCommandNode("sed -e 's/x/y/' file.txt");
104
+ try {
105
+ // -e consumes the next argument (the script), so file.txt is the first positional
106
+ // Since hasExplicitScript is set by -e, the positional is not skipped
107
+ expect(collectCommandTokens(node)).toEqual(["file.txt"]);
108
+ } finally {
109
+ tree.delete();
110
+ }
111
+ });
112
+
113
+ it("sed -f: treats the next argument as a file path (file-consuming flag)", async () => {
114
+ const { node, tree } = await parseCommandNode(
115
+ "sed -f /scripts/script.sed file.txt",
116
+ );
117
+ try {
118
+ // -f consumes the next arg as a file path (extracted), and sets hasExplicitScript
119
+ expect(collectCommandTokens(node)).toEqual([
120
+ "/scripts/script.sed",
121
+ "file.txt",
122
+ ]);
123
+ } finally {
124
+ tree.delete();
125
+ }
126
+ });
127
+
128
+ it("grep: skips the first positional (pattern) and collects file arguments", async () => {
129
+ const { node, tree } = await parseCommandNode(
130
+ "grep pattern /etc/hosts /etc/passwd",
131
+ );
132
+ try {
133
+ expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
134
+ } finally {
135
+ tree.delete();
136
+ }
137
+ });
138
+
139
+ it("grep -e: with explicit -e flag, all positionals are file arguments", async () => {
140
+ const { node, tree } = await parseCommandNode("grep -e pattern /etc/hosts");
141
+ try {
142
+ expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
143
+ } finally {
144
+ tree.delete();
145
+ }
146
+ });
147
+
148
+ it("grep: end-of-flags (--) causes subsequent args to be treated as positionals", async () => {
149
+ const { node, tree } = await parseCommandNode("grep -- pattern /etc/hosts");
150
+ try {
151
+ // After --, both 'pattern' (first positional) and '/etc/hosts' are positionals.
152
+ // pattern is the pattern positional and is skipped; /etc/hosts is collected.
153
+ expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
154
+ } finally {
155
+ tree.delete();
156
+ }
157
+ });
158
+
159
+ it("sd: skips the first two positionals (FIND and REPLACE_WITH) as patterns", async () => {
160
+ const { node, tree } = await parseCommandNode(
161
+ "sd find replace file.txt other.txt",
162
+ );
163
+ try {
164
+ expect(collectCommandTokens(node)).toEqual(["file.txt", "other.txt"]);
165
+ } finally {
166
+ tree.delete();
167
+ }
168
+ });
169
+
170
+ it("rg: skips the pattern positional and collects file/dir arguments", async () => {
171
+ const { node, tree } = await parseCommandNode("rg pattern /etc/");
172
+ try {
173
+ expect(collectCommandTokens(node)).toEqual(["/etc/"]);
174
+ } finally {
175
+ tree.delete();
176
+ }
177
+ });
178
+ });
179
+
180
+ // ── collectCommandTokens — generic commands ───────────────────────────────────
181
+
182
+ describe("collectCommandTokens — generic commands", () => {
183
+ it("collects all argument tokens after the command name", async () => {
184
+ const { node, tree } = await parseCommandNode("cat /etc/hosts /etc/passwd");
185
+ try {
186
+ expect(collectCommandTokens(node)).toEqual(["/etc/hosts", "/etc/passwd"]);
187
+ } finally {
188
+ tree.delete();
189
+ }
190
+ });
191
+
192
+ it("skips variable assignment prefixes", async () => {
193
+ const { node, tree } = await parseCommandNode("FOO=/bar cat /etc/hosts");
194
+ try {
195
+ expect(collectCommandTokens(node)).toEqual(["/etc/hosts"]);
196
+ } finally {
197
+ tree.delete();
198
+ }
199
+ });
200
+
201
+ it("collects no tokens for a bare command with no arguments", async () => {
202
+ const { node, tree } = await parseCommandNode("ls");
203
+ try {
204
+ expect(collectCommandTokens(node)).toEqual([]);
205
+ } finally {
206
+ tree.delete();
207
+ }
208
+ });
209
+ });
210
+
211
+ // ── collectRedirectTokens ─────────────────────────────────────────────────────
212
+
213
+ describe("collectRedirectTokens", () => {
214
+ it("collects the destination path from a stdout redirect", async () => {
215
+ const { node, tree } = await parseRedirectNode(
216
+ "cat /etc/hosts > /tmp/out.txt",
217
+ );
218
+ try {
219
+ expect(collectRedirectTokens(node)).toEqual(["/tmp/out.txt"]);
220
+ } finally {
221
+ tree.delete();
222
+ }
223
+ });
224
+
225
+ it("collects the destination path from an append redirect", async () => {
226
+ const { node, tree } = await parseRedirectNode(
227
+ "echo hello >> /tmp/log.txt",
228
+ );
229
+ try {
230
+ expect(collectRedirectTokens(node)).toEqual(["/tmp/log.txt"]);
231
+ } finally {
232
+ tree.delete();
233
+ }
234
+ });
235
+
236
+ it("collects the source path from a stdin redirect", async () => {
237
+ const { node, tree } = await parseRedirectNode("cat < /etc/hosts");
238
+ try {
239
+ expect(collectRedirectTokens(node)).toEqual(["/etc/hosts"]);
240
+ } finally {
241
+ tree.delete();
242
+ }
243
+ });
244
+ });
245
+
246
+ // ── collectPathCandidateTokens ────────────────────────────────────────────────
247
+
248
+ describe("collectPathCandidateTokens", () => {
249
+ it("collects all argument tokens from a simple command via the program root", async () => {
250
+ const parser = await getParser();
251
+ const tree = parser.parse("cat /etc/hosts");
252
+ try {
253
+ if (!tree) throw new Error("parse returned null");
254
+ expect(collectPathCandidateTokens(tree.rootNode)).toEqual(["/etc/hosts"]);
255
+ } finally {
256
+ tree?.delete();
257
+ }
258
+ });
259
+
260
+ it("collects redirect destinations as well as command arguments", async () => {
261
+ const parser = await getParser();
262
+ const tree = parser.parse("cat /etc/hosts > /tmp/out.txt");
263
+ try {
264
+ if (!tree) throw new Error("parse returned null");
265
+ expect(collectPathCandidateTokens(tree.rootNode)).toEqual([
266
+ "/etc/hosts",
267
+ "/tmp/out.txt",
268
+ ]);
269
+ } finally {
270
+ tree?.delete();
271
+ }
272
+ });
273
+
274
+ it("returns empty array for heredoc-only content (SKIP_SUBTREE_TYPES)", async () => {
275
+ const parser = await getParser();
276
+ const tree = parser.parse("cat <<EOF\nhello\nEOF");
277
+ try {
278
+ if (!tree) throw new Error("parse returned null");
279
+ // heredoc_body is in SKIP_SUBTREE_TYPES — its text must not be collected
280
+ const tokens = collectPathCandidateTokens(tree.rootNode);
281
+ expect(tokens).not.toContain("hello");
282
+ } finally {
283
+ tree?.delete();
284
+ }
285
+ });
286
+
287
+ it("recurses into command substitution to collect nested tokens", async () => {
288
+ const parser = await getParser();
289
+ const tree = parser.parse("cat $(echo /etc/hosts)");
290
+ try {
291
+ if (!tree) throw new Error("parse returned null");
292
+ // The command_substitution is a non-command, non-redirect node — recurse
293
+ const tokens = collectPathCandidateTokens(tree.rootNode);
294
+ // /etc/hosts is inside the substitution, collected by recursion
295
+ expect(tokens).toContain("/etc/hosts");
296
+ } finally {
297
+ tree?.delete();
298
+ }
299
+ });
300
+ });
@@ -521,3 +521,83 @@ describe("multi-instance global service interplay", () => {
521
521
  rmSync(childCwd, { recursive: true, force: true });
522
522
  });
523
523
  });
524
+
525
+ describe("session approvals do not leak across same-cwd session switches", () => {
526
+ // Pi caches the extension *import* (the jiti module, factory function) for
527
+ // same-cwd `/new` / `/resume` / `/fork` / `/import` switches
528
+ // (earendil-works/pi#5905). The factory is still re-invoked per switch, and
529
+ // `session_shutdown` still fires — so a session-scoped "allow for this
530
+ // session" grant must not survive into the next session.
531
+ //
532
+ // Two factory invocations against the same cwd model the cached-import
533
+ // switch: invocation #1 records an approval and shuts down; invocation #2 is
534
+ // the re-invoked cached factory. The new session must start with an empty
535
+ // SessionRules. Two independent mechanisms keep it empty, and the grant only
536
+ // leaks if *both* break together: `session_shutdown` clears the first
537
+ // instance's rules, and the re-invoked factory builds a fresh SessionRules
538
+ // (no module-scoped state bridges the switch — the per-session reset the
539
+ // fresh-jiti load used to provide is gone once the import is cached).
540
+
541
+ /** A UI ctx that approves the gate's "for this session" option (options[1]). */
542
+ function makeSessionApprovingCtx(cwd: string, sessionId: string): unknown {
543
+ return {
544
+ cwd,
545
+ hasUI: true,
546
+ sessionManager: {
547
+ getEntries: (): unknown[] => [],
548
+ getSessionId: (): string => sessionId,
549
+ getSessionDir: (): string => cwd,
550
+ },
551
+ ui: {
552
+ notify: (): void => {},
553
+ setStatus: (): void => {},
554
+ select: async (
555
+ _title: string,
556
+ options: string[],
557
+ ): Promise<string | undefined> => options[1],
558
+ input: async (): Promise<string | undefined> => undefined,
559
+ },
560
+ };
561
+ }
562
+
563
+ it("starts the next same-cwd session with an empty session ruleset", async () => {
564
+ writeGlobalConfig({
565
+ permission: { "*": "allow", demo: "ask" },
566
+ });
567
+
568
+ const cwd = mkdtempSync(join(tmpdir(), "pi-perm-switch-cwd-"));
569
+
570
+ // ── Session #1: approve `demo` for the session, then shut down ──────────
571
+ const firstPi = makeFakePi({ toolNames: ["demo"] });
572
+ piPermissionSystemExtension(firstPi as unknown as ExtensionAPI);
573
+
574
+ const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1");
575
+ await fireSessionStart(firstPi, firstCtx);
576
+
577
+ // The gate prompts and the mock selects options[1], recording a
578
+ // session-scoped approval the service can read back.
579
+ await firstPi.fire(
580
+ "tool_call",
581
+ { toolName: "demo", toolCallId: "demo-approve", input: { foo: "bar" } },
582
+ firstCtx,
583
+ );
584
+ expect(getPermissionsService()!.checkPermission("demo").state).toBe(
585
+ "allow",
586
+ );
587
+
588
+ // The switch tears down the old session before the new one starts.
589
+ await firstPi.fire("session_shutdown");
590
+
591
+ // ── Session #2: the re-invoked cached factory, same cwd ────────────────
592
+ const secondPi = makeFakePi({ toolNames: ["demo"] });
593
+ piPermissionSystemExtension(secondPi as unknown as ExtensionAPI);
594
+
595
+ await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2"));
596
+
597
+ // The previous session's approval must not be visible: `demo` is back to
598
+ // its configured `ask`, not the carried-over `allow`.
599
+ expect(getPermissionsService()!.checkPermission("demo").state).toBe("ask");
600
+
601
+ rmSync(cwd, { recursive: true, force: true });
602
+ });
603
+ });
@@ -13,9 +13,8 @@ import { mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
13
13
  import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
16
-
16
+ import { BashProgram } from "#src/access-intent/bash/program";
17
17
  import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
18
- import { BashProgram } from "#src/handlers/gates/bash-program";
19
18
  import {
20
19
  type GateDescriptor,
21
20
  isGateBypass,
@@ -138,7 +137,7 @@ describe("external_directory symlink acceptance (#418)", () => {
138
137
  toolCallId: "tc-2",
139
138
  cwd,
140
139
  };
141
- const program = await BashProgram.parse(command);
140
+ const program = await BashProgram.parse(command, cwd);
142
141
  const result = describeBashExternalDirectoryGate(tcc, program, resolver);
143
142
  // All external paths are covered by the allow → bypass, no prompt.
144
143
  expect(isGateBypass(result)).toBe(true);
@@ -10,9 +10,8 @@
10
10
  * full fuzzer (tree-sitter fuzzing is brittle); it pins A3 directly.
11
11
  */
12
12
  import { describe, expect, it } from "vitest";
13
-
13
+ import { BashProgram } from "#src/access-intent/bash/program";
14
14
  import { resolveBashCommandCheck } from "#src/handlers/gates/bash-command";
15
- import { BashProgram } from "#src/handlers/gates/bash-program";
16
15
  import type { ScopedPermissionResolver } from "#src/permission-resolver";
17
16
  import type { PermissionState } from "#src/types";
18
17
 
@@ -47,7 +46,7 @@ async function decide(
47
46
  command: string,
48
47
  resolver: ScopedPermissionResolver,
49
48
  ): Promise<PermissionState> {
50
- const program = await BashProgram.parse(command);
49
+ const program = await BashProgram.parse(command, "/cwd");
51
50
  return resolveBashCommandCheck(
52
51
  command,
53
52
  program.commands(),
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, it } from "vitest";
2
+ import { BashProgram } from "#src/access-intent/bash/program";
2
3
  import { getNonEmptyString, toRecord } from "#src/common";
3
4
  import { describeBashExternalDirectoryGate } from "#src/handlers/gates/bash-external-directory";
4
- import { BashProgram } from "#src/handlers/gates/bash-program";
5
5
  import type {
6
6
  GateBypass,
7
7
  GateDescriptor,
@@ -52,7 +52,7 @@ async function describeGate(
52
52
  const command = getNonEmptyString(toRecord(tcc.input).command);
53
53
  const bashProgram =
54
54
  tcc.toolName === "bash" && command
55
- ? await BashProgram.parse(command)
55
+ ? await BashProgram.parse(command, tcc.cwd)
56
56
  : null;
57
57
  return describeBashExternalDirectoryGate(tcc, bashProgram, resolver);
58
58
  }
@@ -68,14 +68,6 @@ describe("describeBashExternalDirectoryGate", () => {
68
68
  expect(result).toBeNull();
69
69
  });
70
70
 
71
- it("returns null when no CWD", async () => {
72
- const result = await describeGate(
73
- makeTcc({ cwd: undefined }),
74
- makeResolver(makeCheckResult("ask")),
75
- );
76
- expect(result).toBeNull();
77
- });
78
-
79
71
  it("returns null when command has no external paths", async () => {
80
72
  const result = await describeGate(
81
73
  makeTcc({ input: { command: "ls -la" } }),
@@ -9,9 +9,9 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
+ import { BashProgram } from "#src/access-intent/bash/program";
12
13
  import { getNonEmptyString, toRecord } from "#src/common";
13
14
  import { describeBashPathGate } from "#src/handlers/gates/bash-path";
14
- import { BashProgram } from "#src/handlers/gates/bash-program";
15
15
  import type {
16
16
  GateBypass,
17
17
  GateDescriptor,
@@ -44,7 +44,7 @@ async function describeGate(
44
44
  const command = getNonEmptyString(toRecord(tcc.input).command);
45
45
  const bashProgram =
46
46
  tcc.toolName === "bash" && command
47
- ? await BashProgram.parse(command)
47
+ ? await BashProgram.parse(command, tcc.cwd)
48
48
  : null;
49
49
  return describeBashPathGate(tcc, bashProgram, resolver);
50
50
  }
@@ -43,11 +43,6 @@ function gateUnderTest(
43
43
  // ── tests ────────────────────��────────────────────────────────────��────────
44
44
 
45
45
  describe("describeExternalDirectoryGate", () => {
46
- it("returns null when no CWD", () => {
47
- const result = gateUnderTest(makeTcc({ cwd: undefined }), ["/test/agent"]);
48
- expect(result).toBeNull();
49
- });
50
-
51
46
  it("returns null when tool is not path-bearing", () => {
52
47
  const result = gateUnderTest(
53
48
  makeTcc({ toolName: "bash", input: { command: "ls" } }),
@@ -16,7 +16,7 @@ const { mockBashProgramParse } = vi.hoisted(() => ({
16
16
  mockBashProgramParse: vi.fn(),
17
17
  }));
18
18
 
19
- vi.mock("#src/handlers/gates/bash-program", () => ({
19
+ vi.mock("#src/access-intent/bash/program", () => ({
20
20
  BashProgram: { parse: mockBashProgramParse },
21
21
  }));
22
22
 
@@ -169,7 +169,10 @@ describe("ToolCallGatePipeline", () => {
169
169
  );
170
170
 
171
171
  expect(mockBashProgramParse).toHaveBeenCalledTimes(1);
172
- expect(mockBashProgramParse).toHaveBeenCalledWith("echo hello");
172
+ expect(mockBashProgramParse).toHaveBeenCalledWith(
173
+ "echo hello",
174
+ "/test/project",
175
+ );
173
176
  });
174
177
 
175
178
  it("does not parse BashProgram when the bash command is empty", async () => {