@aliou/pi-guardrails 0.9.5 → 0.11.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.
@@ -0,0 +1,91 @@
1
+ import { homedir } from "node:os";
2
+ import { describe, expect, it } from "vitest";
3
+ import { extractBashPathCandidates } from "./bash-paths";
4
+
5
+ const CWD = "/work/project";
6
+ const HOME = homedir();
7
+
8
+ describe("extractBashPathCandidates", () => {
9
+ describe("when command has path arguments", () => {
10
+ it("extracts a single absolute path", async () => {
11
+ expect(await extractBashPathCandidates("cat /etc/hosts", CWD)).toEqual([
12
+ "/etc/hosts",
13
+ ]);
14
+ });
15
+
16
+ it("extracts multiple absolute paths", async () => {
17
+ expect(await extractBashPathCandidates("cp /a /b", CWD)).toEqual([
18
+ "/a",
19
+ "/b",
20
+ ]);
21
+ });
22
+
23
+ it("resolves a relative path with ./ against cwd", async () => {
24
+ expect(await extractBashPathCandidates("cat ./foo/bar", CWD)).toEqual([
25
+ "/work/project/foo/bar",
26
+ ]);
27
+ });
28
+
29
+ it("expands ~ to home", async () => {
30
+ expect(await extractBashPathCandidates("cat ~/file", CWD)).toEqual([
31
+ `${HOME}/file`,
32
+ ]);
33
+ });
34
+
35
+ it("detects Windows-style paths", async () => {
36
+ const result = await extractBashPathCandidates("type C:\\foo\\bar", CWD);
37
+ expect(result.length).toBeGreaterThan(0);
38
+ // On POSIX, resolve() treats backslash path as a single component under cwd
39
+ expect(result[0]).toContain("C:\\foo\\bar");
40
+ });
41
+ });
42
+
43
+ describe("when command has flags and redirects", () => {
44
+ it("ignores flag arguments", async () => {
45
+ expect(await extractBashPathCandidates("ls -la /tmp", CWD)).toEqual([
46
+ "/tmp",
47
+ ]);
48
+ });
49
+
50
+ it("extracts redirect targets", async () => {
51
+ expect(
52
+ await extractBashPathCandidates("echo foo > /tmp/out", CWD),
53
+ ).toEqual(["/tmp/out"]);
54
+ });
55
+ });
56
+
57
+ describe("when command has no path-like tokens", () => {
58
+ it("returns an empty array for bare filenames (no separators)", async () => {
59
+ expect(await extractBashPathCandidates("cat README.md", CWD)).toEqual([]);
60
+ });
61
+
62
+ it("returns an empty array for commands with no file arguments", async () => {
63
+ expect(await extractBashPathCandidates("echo hello", CWD)).toEqual([]);
64
+ });
65
+ });
66
+
67
+ describe("when command uses quoting", () => {
68
+ it("handles quoted paths with spaces", async () => {
69
+ expect(
70
+ await extractBashPathCandidates('cat "/tmp/hello world"', CWD),
71
+ ).toEqual(["/tmp/hello world"]);
72
+ });
73
+ });
74
+
75
+ describe("when command has duplicate paths", () => {
76
+ it("deduplicates results", async () => {
77
+ expect(await extractBashPathCandidates("cat /a /a", CWD)).toEqual(["/a"]);
78
+ });
79
+ });
80
+
81
+ describe("when command is malformed", () => {
82
+ it("falls back to regex tokenization on parse failure", async () => {
83
+ // Unbalanced quote triggers parse error; regex fallback still finds paths
84
+ const result = await extractBashPathCandidates(
85
+ "cat /tmp/foo 'unterminated",
86
+ CWD,
87
+ );
88
+ expect(result).toContain("/tmp/foo");
89
+ });
90
+ });
91
+ });
@@ -0,0 +1,96 @@
1
+ import { resolve } from "node:path";
2
+ import { parse } from "@aliou/sh";
3
+ import { expandGlob, hasGlobChars } from "./glob-expander";
4
+ import { expandHomePath } from "./path";
5
+ import { walkCommands, wordToString } from "./shell-utils";
6
+
7
+ /**
8
+ * Heuristic: is this token likely a filesystem path?
9
+ * Intentionally conservative — only structural signals.
10
+ * Known false positives: "application/json", URL paths. These cause
11
+ * spurious prompts in ask mode but are safe (better to over-prompt than miss).
12
+ * Known false negatives: bare filenames without path separators (e.g. "README.md").
13
+ * These are usually cwd-relative and would pass the boundary check anyway.
14
+ */
15
+ function maybePathLike(token: string): boolean {
16
+ if (token.includes("/")) return true;
17
+ if (token.includes("\\")) return true;
18
+ if (/^[A-Za-z]:[\\/]/.test(token)) return true;
19
+ if (token.startsWith("~")) return true;
20
+ return false;
21
+ }
22
+
23
+ async function expandCandidate(
24
+ candidate: string,
25
+ cwd: string,
26
+ ): Promise<string[]> {
27
+ if (!hasGlobChars(candidate)) return [candidate];
28
+ const matches = await expandGlob(candidate, { cwd });
29
+ return matches.length > 0 ? matches : [candidate];
30
+ }
31
+
32
+ /**
33
+ * Extract path-like candidates from a bash command string.
34
+ * Returns absolute paths. Best-effort: uses AST parsing with regex fallback.
35
+ * Does NOT filter by any policy — returns all path-like arguments.
36
+ */
37
+ export async function extractBashPathCandidates(
38
+ command: string,
39
+ cwd: string,
40
+ ): Promise<string[]> {
41
+ const seen = new Set<string>();
42
+ const results: string[] = [];
43
+
44
+ const addCandidate = async (
45
+ token: string,
46
+ forcePath = false,
47
+ ): Promise<void> => {
48
+ if (!token || token.startsWith("-")) return;
49
+ if (!forcePath && !maybePathLike(token)) return;
50
+
51
+ const expanded = await expandCandidate(token, cwd);
52
+ for (const file of expanded) {
53
+ const abs = resolve(cwd, expandHomePath(file));
54
+ if (!seen.has(abs)) {
55
+ seen.add(abs);
56
+ results.push(abs);
57
+ }
58
+ }
59
+ };
60
+
61
+ try {
62
+ const { ast } = parse(command);
63
+ const pending: Promise<void>[] = [];
64
+
65
+ walkCommands(ast, (cmd) => {
66
+ const words = (cmd.words ?? []).map(wordToString);
67
+ for (let i = 1; i < words.length; i++) {
68
+ pending.push(addCandidate(words[i] as string));
69
+ }
70
+ for (const redir of cmd.redirects ?? []) {
71
+ pending.push(addCandidate(wordToString(redir.target), true));
72
+ }
73
+ return false;
74
+ });
75
+
76
+ await Promise.all(pending);
77
+ return results;
78
+ } catch {
79
+ // Fallback: regex tokenization
80
+ const tokenRegex = /"([^"]+)"|'([^']+)'|`([^`]+)`|([^\s"'`<>|;&]+)/g;
81
+ for (const match of command.matchAll(tokenRegex)) {
82
+ const token = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
83
+ if (token && !token.startsWith("-") && maybePathLike(token)) {
84
+ const expanded = await expandCandidate(token, cwd);
85
+ for (const file of expanded) {
86
+ const abs = resolve(cwd, expandHomePath(file));
87
+ if (!seen.has(abs)) {
88
+ seen.add(abs);
89
+ results.push(abs);
90
+ }
91
+ }
92
+ }
93
+ }
94
+ return results;
95
+ }
96
+ }
@@ -4,7 +4,7 @@ export const GUARDRAILS_BLOCKED_EVENT = "guardrails:blocked";
4
4
  export const GUARDRAILS_DANGEROUS_EVENT = "guardrails:dangerous";
5
5
 
6
6
  export interface GuardrailsBlockedEvent {
7
- feature: "policies" | "permissionGate";
7
+ feature: "policies" | "permissionGate" | "pathAccess";
8
8
  toolName: string;
9
9
  input: Record<string, unknown>;
10
10
  reason: string;
@@ -21,7 +21,7 @@ import { pendingWarnings } from "./warnings";
21
21
  * Keep this independent from package.json version.
22
22
  * Bump only when config schema/default migration markers change.
23
23
  */
24
- export const CURRENT_VERSION = "0.8.0-20260228";
24
+ export const CURRENT_VERSION = "0.9.0-20260327";
25
25
 
26
26
  /**
27
27
  * Check if a config needs migration (no version field = v0).
@@ -97,6 +97,60 @@ export function needsEnvFilesToPoliciesMigration(
97
97
  return features?.protectEnvFiles !== undefined;
98
98
  }
99
99
 
100
+ /**
101
+ * Check if config needs applyBuiltinDefaults bridge migration.
102
+ * This runs only for existing config files loaded by ConfigLoader.
103
+ */
104
+ export function needsApplyBuiltinDefaultsMigration(
105
+ config: GuardrailsConfig,
106
+ ): boolean {
107
+ return config.applyBuiltinDefaults === undefined;
108
+ }
109
+
110
+ /**
111
+ * Bridge migration for defaults deprecation.
112
+ * Existing config files get applyBuiltinDefaults=true to preserve behavior.
113
+ */
114
+ export function migrateApplyBuiltinDefaults(
115
+ config: GuardrailsConfig,
116
+ ): GuardrailsConfig {
117
+ const migrated = structuredClone(config);
118
+ migrated.applyBuiltinDefaults = true;
119
+ migrated.version = CURRENT_VERSION;
120
+
121
+ pendingWarnings.push(
122
+ "Guardrails config was migrated. `applyBuiltinDefaults` was set to `true` to preserve current behavior.",
123
+ );
124
+
125
+ return migrated;
126
+ }
127
+
128
+ export function needsOnboardingDoneMigration(
129
+ config: GuardrailsConfig,
130
+ ): boolean {
131
+ return (
132
+ config.onboarding?.completed === undefined &&
133
+ config.applyBuiltinDefaults !== undefined
134
+ );
135
+ }
136
+
137
+ export function migrateMarkOnboardingDone(
138
+ config: GuardrailsConfig,
139
+ ): GuardrailsConfig {
140
+ const migrated = structuredClone(config);
141
+ pendingWarnings.push(
142
+ "Guardrails config was migrated. Existing setup marked as onboarding-complete.",
143
+ );
144
+ migrated.onboarding = {
145
+ ...(migrated.onboarding ?? {}),
146
+ completed: true,
147
+ completedAt: migrated.onboarding?.completedAt ?? new Date().toISOString(),
148
+ version: migrated.onboarding?.version ?? CURRENT_VERSION,
149
+ };
150
+ migrated.version = CURRENT_VERSION;
151
+ return migrated;
152
+ }
153
+
100
154
  /**
101
155
  * Migrate deprecated envFiles/protectEnvFiles fields to policies.
102
156
  */
@@ -0,0 +1,154 @@
1
+ import { assert, describe, expect, it } from "vitest";
2
+ import {
3
+ checkPathAccess,
4
+ isPathAllowed,
5
+ type PathAccessState,
6
+ } from "./path-access";
7
+
8
+ describe("isPathAllowed", () => {
9
+ describe("when allowedPaths is empty", () => {
10
+ it("returns false", () => {
11
+ expect(isPathAllowed("/foo/bar", [])).toBe(false);
12
+ });
13
+ });
14
+
15
+ describe("when entry is an exact file grant", () => {
16
+ it.each([
17
+ { desc: "matches the exact file", path: "/foo/bar", expected: true },
18
+ {
19
+ desc: "does not match a sibling file",
20
+ path: "/foo/other",
21
+ expected: false,
22
+ },
23
+ {
24
+ desc: "does not match the containing directory",
25
+ path: "/foo",
26
+ expected: false,
27
+ },
28
+ ])("$desc", ({ path, expected }) => {
29
+ expect(isPathAllowed(path, ["/foo/bar"])).toBe(expected);
30
+ });
31
+ });
32
+
33
+ describe("when entry is a directory grant (trailing /)", () => {
34
+ it.each([
35
+ {
36
+ desc: "matches the directory itself",
37
+ path: "/foo/bar",
38
+ expected: true,
39
+ },
40
+ { desc: "matches a direct child", path: "/foo/bar/baz", expected: true },
41
+ {
42
+ desc: "matches a grandchild",
43
+ path: "/foo/bar/baz/qux",
44
+ expected: true,
45
+ },
46
+ {
47
+ desc: "does not match a prefix-collision path like /foo/barbaz",
48
+ path: "/foo/barbaz",
49
+ expected: false,
50
+ },
51
+ ])("$desc", ({ path, expected }) => {
52
+ expect(isPathAllowed(path, ["/foo/bar/"])).toBe(expected);
53
+ });
54
+ });
55
+
56
+ describe("when allowedPaths has multiple entries", () => {
57
+ it("returns true if any entry matches", () => {
58
+ expect(isPathAllowed("/b", ["/a", "/b"])).toBe(true);
59
+ });
60
+
61
+ it.each([
62
+ { path: "/foo/file.ts", expected: true },
63
+ { path: "/bar/anything", expected: true },
64
+ { path: "/foo/other.ts", expected: false },
65
+ { path: "/baz", expected: false },
66
+ ])("with mixed file + directory grants, returns $expected for $path", ({
67
+ path,
68
+ expected,
69
+ }) => {
70
+ expect(isPathAllowed(path, ["/foo/file.ts", "/bar/"])).toBe(expected);
71
+ });
72
+ });
73
+ });
74
+
75
+ describe("checkPathAccess", () => {
76
+ const cwd = "/work/project";
77
+ const base = (overrides: Partial<PathAccessState> = {}): PathAccessState => ({
78
+ cwd,
79
+ mode: "ask",
80
+ allowedPaths: [],
81
+ hasUI: true,
82
+ ...overrides,
83
+ });
84
+
85
+ describe("when mode is allow", () => {
86
+ it("always returns allow, even for paths outside cwd and without UI", () => {
87
+ const state = base({ mode: "allow", hasUI: false, allowedPaths: [] });
88
+ expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
89
+ "allow",
90
+ );
91
+ expect(checkPathAccess("/work/project/src", "src", state).kind).toBe(
92
+ "allow",
93
+ );
94
+ });
95
+ });
96
+
97
+ describe("when path is inside cwd", () => {
98
+ it.each([
99
+ { mode: "block" as const },
100
+ { mode: "ask" as const },
101
+ ])("returns allow in $mode mode", ({ mode }) => {
102
+ const state = base({ mode });
103
+ expect(
104
+ checkPathAccess("/work/project/src/file", "src/file", state).kind,
105
+ ).toBe("allow");
106
+ });
107
+
108
+ it("returns allow when path equals cwd", () => {
109
+ expect(checkPathAccess(cwd, ".", base({ mode: "ask" })).kind).toBe(
110
+ "allow",
111
+ );
112
+ });
113
+ });
114
+
115
+ describe("when path is outside cwd and mode is block", () => {
116
+ it("returns deny with the displayPath in the reason", () => {
117
+ const state = base({ mode: "block" });
118
+ const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
119
+ assert(decision.kind === "deny", "expected deny decision");
120
+ expect(decision.reason).toContain("/etc/hosts");
121
+ });
122
+
123
+ it("returns allow when the path is in allowedPaths", () => {
124
+ const state = base({ mode: "block", allowedPaths: ["/etc/hosts"] });
125
+ expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
126
+ "allow",
127
+ );
128
+ });
129
+ });
130
+
131
+ describe("when path is outside cwd and mode is ask", () => {
132
+ it("returns ask with displayPath when UI is available", () => {
133
+ const state = base({ mode: "ask", hasUI: true });
134
+ const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
135
+ assert(decision.kind === "ask", "expected ask decision");
136
+ expect(decision.displayPath).toBe("/etc/hosts");
137
+ expect(decision.absolutePath).toBe("/etc/hosts");
138
+ });
139
+
140
+ it("returns deny with a 'no UI' reason when UI is unavailable", () => {
141
+ const state = base({ mode: "ask", hasUI: false });
142
+ const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
143
+ assert(decision.kind === "deny", "expected deny decision");
144
+ expect(decision.reason).toContain("no UI");
145
+ });
146
+
147
+ it("returns allow when the path is in allowedPaths (no prompt)", () => {
148
+ const state = base({ mode: "ask", allowedPaths: ["/etc/hosts"] });
149
+ expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
150
+ "allow",
151
+ );
152
+ });
153
+ });
154
+ });
@@ -0,0 +1,62 @@
1
+ import { isWithinBoundary } from "./path";
2
+
3
+ export type PathDecision =
4
+ | { kind: "allow" }
5
+ | { kind: "deny"; reason: string }
6
+ | { kind: "ask"; absolutePath: string; displayPath: string };
7
+
8
+ export interface PathAccessState {
9
+ cwd: string;
10
+ mode: "allow" | "ask" | "block";
11
+ allowedPaths: string[]; // already resolved to absolute, with trailing / convention
12
+ hasUI: boolean;
13
+ }
14
+
15
+ /**
16
+ * Check if an absolute path is covered by the allowedPaths list.
17
+ * - Entries ending in "/" are directory grants (boundary/prefix match).
18
+ * - Entries without trailing "/" are exact file grants.
19
+ */
20
+ export function isPathAllowed(
21
+ absPath: string,
22
+ allowedPaths: string[],
23
+ ): boolean {
24
+ for (const entry of allowedPaths) {
25
+ if (entry.endsWith("/")) {
26
+ const dirPath = entry.slice(0, -1);
27
+ if (isWithinBoundary(absPath, dirPath)) return true;
28
+ } else {
29
+ if (absPath === entry) return true;
30
+ }
31
+ }
32
+ return false;
33
+ }
34
+
35
+ export function checkPathAccess(
36
+ absolutePath: string,
37
+ displayPath: string,
38
+ state: PathAccessState,
39
+ ): PathDecision {
40
+ if (state.mode === "allow") return { kind: "allow" };
41
+
42
+ if (isWithinBoundary(absolutePath, state.cwd)) return { kind: "allow" };
43
+
44
+ if (isPathAllowed(absolutePath, state.allowedPaths)) return { kind: "allow" };
45
+
46
+ if (state.mode === "block") {
47
+ return {
48
+ kind: "deny",
49
+ reason: `Access to ${displayPath} is blocked (outside working directory).`,
50
+ };
51
+ }
52
+
53
+ // mode === "ask"
54
+ if (!state.hasUI) {
55
+ return {
56
+ kind: "deny",
57
+ reason: `Access to ${displayPath} is blocked (outside working directory, no UI to confirm).`,
58
+ };
59
+ }
60
+
61
+ return { kind: "ask", absolutePath, displayPath };
62
+ }
@@ -0,0 +1,177 @@
1
+ import { homedir } from "node:os";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ expandHomePath,
5
+ isWithinBoundary,
6
+ normalizeForDisplay,
7
+ resolveFromCwd,
8
+ toStorageForm,
9
+ } from "./path";
10
+
11
+ const HOME = homedir();
12
+
13
+ describe("expandHomePath", () => {
14
+ it.each([
15
+ { desc: "bare ~", input: "~", expected: HOME },
16
+ { desc: "~/foo", input: "~/foo", expected: `${HOME}/foo` },
17
+ {
18
+ desc: "~\\foo (Windows tilde)",
19
+ input: "~\\foo",
20
+ expected: `${HOME}/foo`,
21
+ },
22
+ {
23
+ desc: "an absolute path",
24
+ input: "/absolute/path",
25
+ expected: "/absolute/path",
26
+ },
27
+ {
28
+ desc: "a relative path",
29
+ input: "relative/path",
30
+ expected: "relative/path",
31
+ },
32
+ { desc: "an empty string", input: "", expected: "" },
33
+ ])("given $desc, returns the expanded path", ({ input, expected }) => {
34
+ expect(expandHomePath(input)).toBe(expected);
35
+ });
36
+ });
37
+
38
+ describe("resolveFromCwd", () => {
39
+ const cwd = "/some/cwd";
40
+
41
+ it.each([
42
+ {
43
+ desc: "a relative path",
44
+ input: "sub/file",
45
+ expected: "/some/cwd/sub/file",
46
+ },
47
+ { desc: "an absolute path", input: "/etc/hosts", expected: "/etc/hosts" },
48
+ { desc: "a ~ path", input: "~/foo", expected: `${HOME}/foo` },
49
+ { desc: "'.'", input: ".", expected: "/some/cwd" },
50
+ ])("given $desc, resolves against cwd", ({ input, expected }) => {
51
+ expect(resolveFromCwd(input, cwd)).toBe(expected);
52
+ });
53
+ });
54
+
55
+ describe("isWithinBoundary", () => {
56
+ it.each([
57
+ {
58
+ desc: "paths are identical",
59
+ target: "/foo/bar",
60
+ root: "/foo/bar",
61
+ expected: true,
62
+ },
63
+ {
64
+ desc: "target is a direct child",
65
+ target: "/foo/bar/baz",
66
+ root: "/foo/bar",
67
+ expected: true,
68
+ },
69
+ {
70
+ desc: "target is a grandchild",
71
+ target: "/foo/bar/baz/qux",
72
+ root: "/foo/bar",
73
+ expected: true,
74
+ },
75
+ {
76
+ desc: "target is a parent",
77
+ target: "/foo",
78
+ root: "/foo/bar",
79
+ expected: false,
80
+ },
81
+ {
82
+ desc: "target is a sibling",
83
+ target: "/foo/other",
84
+ root: "/foo/bar",
85
+ expected: false,
86
+ },
87
+ {
88
+ desc: "target shares a string prefix but is not a child (critical case)",
89
+ target: "/foo/barbaz",
90
+ root: "/foo/bar",
91
+ expected: false,
92
+ },
93
+ {
94
+ desc: "paths are completely unrelated",
95
+ target: "/tmp",
96
+ root: "/home/user",
97
+ expected: false,
98
+ },
99
+ ])("when $desc, returns $expected", ({ target, root, expected }) => {
100
+ expect(isWithinBoundary(target, root)).toBe(expected);
101
+ });
102
+ });
103
+
104
+ describe("normalizeForDisplay", () => {
105
+ const cwd = "/work/project";
106
+
107
+ it.each([
108
+ { desc: "path equals cwd", input: cwd, expected: "." },
109
+ {
110
+ desc: "path is a child of cwd",
111
+ input: "/work/project/src/file.ts",
112
+ expected: "src/file.ts",
113
+ },
114
+ {
115
+ desc: "path is under home but not cwd",
116
+ input: `${HOME}/config/file`,
117
+ expected: "~/config/file",
118
+ },
119
+ {
120
+ desc: "path is outside both cwd and home",
121
+ input: "/etc/hosts",
122
+ expected: "/etc/hosts",
123
+ },
124
+ { desc: "path is home itself", input: HOME, expected: "~" },
125
+ ])("when $desc, returns $expected", ({ input, expected }) => {
126
+ expect(normalizeForDisplay(input, cwd)).toBe(expected);
127
+ });
128
+ });
129
+
130
+ describe("toStorageForm", () => {
131
+ it.each([
132
+ {
133
+ desc: "file under home",
134
+ absPath: `${HOME}/code/file.ts`,
135
+ isDirectory: false,
136
+ expected: "~/code/file.ts",
137
+ },
138
+ {
139
+ desc: "directory under home",
140
+ absPath: `${HOME}/code`,
141
+ isDirectory: true,
142
+ expected: "~/code/",
143
+ },
144
+ {
145
+ desc: "absolute file outside home",
146
+ absPath: "/etc/hosts",
147
+ isDirectory: false,
148
+ expected: "/etc/hosts",
149
+ },
150
+ {
151
+ desc: "absolute directory outside home",
152
+ absPath: "/etc",
153
+ isDirectory: true,
154
+ expected: "/etc/",
155
+ },
156
+ {
157
+ desc: "input has trailing slash but isDirectory=false",
158
+ absPath: "/etc/hosts/",
159
+ isDirectory: false,
160
+ expected: "/etc/hosts",
161
+ },
162
+ {
163
+ desc: "input uses Windows backslashes",
164
+ absPath: "C:\\Users\\foo",
165
+ isDirectory: false,
166
+ expected: "C:/Users/foo",
167
+ },
168
+ {
169
+ desc: "input is home itself with isDirectory=true",
170
+ absPath: HOME,
171
+ isDirectory: true,
172
+ expected: "~/",
173
+ },
174
+ ])("when $desc, returns $expected", ({ absPath, isDirectory, expected }) => {
175
+ expect(toStorageForm(absPath, isDirectory)).toBe(expected);
176
+ });
177
+ });