@aliou/pi-guardrails 0.12.1 → 0.13.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/README.md +8 -6
- package/extensions/path-access/index.ts +12 -0
- package/extensions/permission-gate/index.ts +12 -0
- package/package.json +7 -5
- package/src/shared/events.ts +28 -0
- package/extensions/guardrails/rules.test.ts +0 -107
- package/extensions/guardrails/targets.test.ts +0 -44
- package/extensions/path-access/grants.test.ts +0 -47
- package/extensions/path-access/rules.test.ts +0 -46
- package/extensions/path-access/targets.test.ts +0 -40
- package/extensions/permission-gate/rules.test.ts +0 -132
- package/src/core/check.test.ts +0 -169
- package/src/core/commands/dangerous.test.ts +0 -468
- package/src/core/paths/access.test.ts +0 -150
- package/src/core/paths/path.test.ts +0 -293
- package/src/core/shell/command-args.test.ts +0 -142
- package/src/shared/matching.test.ts +0 -86
- package/src/shared/paths/bash-paths.test.ts +0 -171
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
import { assert, describe, expect, it } from "vitest";
|
|
2
|
-
import { checkPathAccess, isPathAllowed, type PathAccessState } from "./access";
|
|
3
|
-
|
|
4
|
-
describe("isPathAllowed", () => {
|
|
5
|
-
describe("when allowedPaths is empty", () => {
|
|
6
|
-
it("returns false", () => {
|
|
7
|
-
expect(isPathAllowed("/foo/bar", [])).toBe(false);
|
|
8
|
-
});
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
describe("when entry is an exact file grant", () => {
|
|
12
|
-
it.each([
|
|
13
|
-
{ desc: "matches the exact file", path: "/foo/bar", expected: true },
|
|
14
|
-
{
|
|
15
|
-
desc: "does not match a sibling file",
|
|
16
|
-
path: "/foo/other",
|
|
17
|
-
expected: false,
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
desc: "does not match the containing directory",
|
|
21
|
-
path: "/foo",
|
|
22
|
-
expected: false,
|
|
23
|
-
},
|
|
24
|
-
])("$desc", ({ path, expected }) => {
|
|
25
|
-
expect(isPathAllowed(path, ["/foo/bar"])).toBe(expected);
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
describe("when entry is a directory grant (trailing /)", () => {
|
|
30
|
-
it.each([
|
|
31
|
-
{
|
|
32
|
-
desc: "matches the directory itself",
|
|
33
|
-
path: "/foo/bar",
|
|
34
|
-
expected: true,
|
|
35
|
-
},
|
|
36
|
-
{ desc: "matches a direct child", path: "/foo/bar/baz", expected: true },
|
|
37
|
-
{
|
|
38
|
-
desc: "matches a grandchild",
|
|
39
|
-
path: "/foo/bar/baz/qux",
|
|
40
|
-
expected: true,
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
desc: "does not match a prefix-collision path like /foo/barbaz",
|
|
44
|
-
path: "/foo/barbaz",
|
|
45
|
-
expected: false,
|
|
46
|
-
},
|
|
47
|
-
])("$desc", ({ path, expected }) => {
|
|
48
|
-
expect(isPathAllowed(path, ["/foo/bar/"])).toBe(expected);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
describe("when allowedPaths has multiple entries", () => {
|
|
53
|
-
it("returns true if any entry matches", () => {
|
|
54
|
-
expect(isPathAllowed("/b", ["/a", "/b"])).toBe(true);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it.each([
|
|
58
|
-
{ path: "/foo/file.ts", expected: true },
|
|
59
|
-
{ path: "/bar/anything", expected: true },
|
|
60
|
-
{ path: "/foo/other.ts", expected: false },
|
|
61
|
-
{ path: "/baz", expected: false },
|
|
62
|
-
])("with mixed file + directory grants, returns $expected for $path", ({
|
|
63
|
-
path,
|
|
64
|
-
expected,
|
|
65
|
-
}) => {
|
|
66
|
-
expect(isPathAllowed(path, ["/foo/file.ts", "/bar/"])).toBe(expected);
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
describe("checkPathAccess", () => {
|
|
72
|
-
const cwd = "/work/project";
|
|
73
|
-
const base = (overrides: Partial<PathAccessState> = {}): PathAccessState => ({
|
|
74
|
-
cwd,
|
|
75
|
-
mode: "ask",
|
|
76
|
-
allowedPaths: [],
|
|
77
|
-
hasUI: true,
|
|
78
|
-
...overrides,
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe("when mode is allow", () => {
|
|
82
|
-
it("always returns allow, even for paths outside cwd and without UI", () => {
|
|
83
|
-
const state = base({ mode: "allow", hasUI: false, allowedPaths: [] });
|
|
84
|
-
expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
|
|
85
|
-
"allow",
|
|
86
|
-
);
|
|
87
|
-
expect(checkPathAccess("/work/project/src", "src", state).kind).toBe(
|
|
88
|
-
"allow",
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
describe("when path is inside cwd", () => {
|
|
94
|
-
it.each([
|
|
95
|
-
{ mode: "block" as const },
|
|
96
|
-
{ mode: "ask" as const },
|
|
97
|
-
])("returns allow in $mode mode", ({ mode }) => {
|
|
98
|
-
const state = base({ mode });
|
|
99
|
-
expect(
|
|
100
|
-
checkPathAccess("/work/project/src/file", "src/file", state).kind,
|
|
101
|
-
).toBe("allow");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("returns allow when path equals cwd", () => {
|
|
105
|
-
expect(checkPathAccess(cwd, ".", base({ mode: "ask" })).kind).toBe(
|
|
106
|
-
"allow",
|
|
107
|
-
);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
describe("when path is outside cwd and mode is block", () => {
|
|
112
|
-
it("returns deny with the displayPath in the reason", () => {
|
|
113
|
-
const state = base({ mode: "block" });
|
|
114
|
-
const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
|
|
115
|
-
assert(decision.kind === "deny", "expected deny decision");
|
|
116
|
-
expect(decision.reason).toContain("/etc/hosts");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("returns allow when the path is in allowedPaths", () => {
|
|
120
|
-
const state = base({ mode: "block", allowedPaths: ["/etc/hosts"] });
|
|
121
|
-
expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
|
|
122
|
-
"allow",
|
|
123
|
-
);
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe("when path is outside cwd and mode is ask", () => {
|
|
128
|
-
it("returns ask with displayPath when UI is available", () => {
|
|
129
|
-
const state = base({ mode: "ask", hasUI: true });
|
|
130
|
-
const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
|
|
131
|
-
assert(decision.kind === "ask", "expected ask decision");
|
|
132
|
-
expect(decision.displayPath).toBe("/etc/hosts");
|
|
133
|
-
expect(decision.absolutePath).toBe("/etc/hosts");
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("returns deny with a 'no UI' reason when UI is unavailable", () => {
|
|
137
|
-
const state = base({ mode: "ask", hasUI: false });
|
|
138
|
-
const decision = checkPathAccess("/etc/hosts", "/etc/hosts", state);
|
|
139
|
-
assert(decision.kind === "deny", "expected deny decision");
|
|
140
|
-
expect(decision.reason).toContain("no UI");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
it("returns allow when the path is in allowedPaths (no prompt)", () => {
|
|
144
|
-
const state = base({ mode: "ask", allowedPaths: ["/etc/hosts"] });
|
|
145
|
-
expect(checkPathAccess("/etc/hosts", "/etc/hosts", state).kind).toBe(
|
|
146
|
-
"allow",
|
|
147
|
-
);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
});
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
expandHomePath,
|
|
5
|
-
isWithinBoundary,
|
|
6
|
-
maybePathLike,
|
|
7
|
-
normalizeForDisplay,
|
|
8
|
-
resolveFromCwd,
|
|
9
|
-
toStorageForm,
|
|
10
|
-
} from "./path";
|
|
11
|
-
|
|
12
|
-
const HOME = homedir();
|
|
13
|
-
|
|
14
|
-
describe("expandHomePath", () => {
|
|
15
|
-
it.each([
|
|
16
|
-
{ desc: "bare ~", input: "~", expected: HOME },
|
|
17
|
-
{ desc: "~/foo", input: "~/foo", expected: `${HOME}/foo` },
|
|
18
|
-
{
|
|
19
|
-
desc: "~\\foo (Windows tilde)",
|
|
20
|
-
input: "~\\foo",
|
|
21
|
-
expected: `${HOME}/foo`,
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
desc: "an absolute path",
|
|
25
|
-
input: "/absolute/path",
|
|
26
|
-
expected: "/absolute/path",
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
desc: "a relative path",
|
|
30
|
-
input: "relative/path",
|
|
31
|
-
expected: "relative/path",
|
|
32
|
-
},
|
|
33
|
-
{ desc: "an empty string", input: "", expected: "" },
|
|
34
|
-
])("given $desc, returns the expanded path", ({ input, expected }) => {
|
|
35
|
-
expect(expandHomePath(input)).toBe(expected);
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("resolveFromCwd", () => {
|
|
40
|
-
const cwd = "/some/cwd";
|
|
41
|
-
|
|
42
|
-
it.each([
|
|
43
|
-
{
|
|
44
|
-
desc: "a relative path",
|
|
45
|
-
input: "sub/file",
|
|
46
|
-
expected: "/some/cwd/sub/file",
|
|
47
|
-
},
|
|
48
|
-
{ desc: "an absolute path", input: "/etc/hosts", expected: "/etc/hosts" },
|
|
49
|
-
{ desc: "a ~ path", input: "~/foo", expected: `${HOME}/foo` },
|
|
50
|
-
{ desc: "'.'", input: ".", expected: "/some/cwd" },
|
|
51
|
-
])("given $desc, resolves against cwd", ({ input, expected }) => {
|
|
52
|
-
expect(resolveFromCwd(input, cwd)).toBe(expected);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
describe("isWithinBoundary", () => {
|
|
57
|
-
it.each([
|
|
58
|
-
{
|
|
59
|
-
desc: "paths are identical",
|
|
60
|
-
target: "/foo/bar",
|
|
61
|
-
root: "/foo/bar",
|
|
62
|
-
expected: true,
|
|
63
|
-
},
|
|
64
|
-
{
|
|
65
|
-
desc: "target is a direct child",
|
|
66
|
-
target: "/foo/bar/baz",
|
|
67
|
-
root: "/foo/bar",
|
|
68
|
-
expected: true,
|
|
69
|
-
},
|
|
70
|
-
{
|
|
71
|
-
desc: "target is a grandchild",
|
|
72
|
-
target: "/foo/bar/baz/qux",
|
|
73
|
-
root: "/foo/bar",
|
|
74
|
-
expected: true,
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
desc: "target is a parent",
|
|
78
|
-
target: "/foo",
|
|
79
|
-
root: "/foo/bar",
|
|
80
|
-
expected: false,
|
|
81
|
-
},
|
|
82
|
-
{
|
|
83
|
-
desc: "target is a sibling",
|
|
84
|
-
target: "/foo/other",
|
|
85
|
-
root: "/foo/bar",
|
|
86
|
-
expected: false,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
desc: "target shares a string prefix but is not a child (critical case)",
|
|
90
|
-
target: "/foo/barbaz",
|
|
91
|
-
root: "/foo/bar",
|
|
92
|
-
expected: false,
|
|
93
|
-
},
|
|
94
|
-
{
|
|
95
|
-
desc: "paths are completely unrelated",
|
|
96
|
-
target: "/tmp",
|
|
97
|
-
root: "/home/user",
|
|
98
|
-
expected: false,
|
|
99
|
-
},
|
|
100
|
-
])("when $desc, returns $expected", ({ target, root, expected }) => {
|
|
101
|
-
expect(isWithinBoundary(target, root)).toBe(expected);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
describe("normalizeForDisplay", () => {
|
|
106
|
-
const cwd = "/work/project";
|
|
107
|
-
|
|
108
|
-
it.each([
|
|
109
|
-
{ desc: "path equals cwd", input: cwd, expected: "." },
|
|
110
|
-
{
|
|
111
|
-
desc: "path is a child of cwd",
|
|
112
|
-
input: "/work/project/src/file.ts",
|
|
113
|
-
expected: "src/file.ts",
|
|
114
|
-
},
|
|
115
|
-
{
|
|
116
|
-
desc: "path is under home but not cwd",
|
|
117
|
-
input: `${HOME}/config/file`,
|
|
118
|
-
expected: "~/config/file",
|
|
119
|
-
},
|
|
120
|
-
{
|
|
121
|
-
desc: "path is outside both cwd and home",
|
|
122
|
-
input: "/etc/hosts",
|
|
123
|
-
expected: "/etc/hosts",
|
|
124
|
-
},
|
|
125
|
-
{ desc: "path is home itself", input: HOME, expected: "~" },
|
|
126
|
-
])("when $desc, returns $expected", ({ input, expected }) => {
|
|
127
|
-
expect(normalizeForDisplay(input, cwd)).toBe(expected);
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
describe("toStorageForm", () => {
|
|
132
|
-
it.each([
|
|
133
|
-
{
|
|
134
|
-
desc: "file under home",
|
|
135
|
-
absPath: `${HOME}/code/file.ts`,
|
|
136
|
-
isDirectory: false,
|
|
137
|
-
expected: "~/code/file.ts",
|
|
138
|
-
},
|
|
139
|
-
{
|
|
140
|
-
desc: "directory under home",
|
|
141
|
-
absPath: `${HOME}/code`,
|
|
142
|
-
isDirectory: true,
|
|
143
|
-
expected: "~/code/",
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
desc: "absolute file outside home",
|
|
147
|
-
absPath: "/etc/hosts",
|
|
148
|
-
isDirectory: false,
|
|
149
|
-
expected: "/etc/hosts",
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
desc: "absolute directory outside home",
|
|
153
|
-
absPath: "/etc",
|
|
154
|
-
isDirectory: true,
|
|
155
|
-
expected: "/etc/",
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
desc: "input has trailing slash but isDirectory=false",
|
|
159
|
-
absPath: "/etc/hosts/",
|
|
160
|
-
isDirectory: false,
|
|
161
|
-
expected: "/etc/hosts",
|
|
162
|
-
},
|
|
163
|
-
{
|
|
164
|
-
desc: "input uses Windows backslashes",
|
|
165
|
-
absPath: "C:\\Users\\foo",
|
|
166
|
-
isDirectory: false,
|
|
167
|
-
expected: "C:/Users/foo",
|
|
168
|
-
},
|
|
169
|
-
{
|
|
170
|
-
desc: "input is home itself with isDirectory=true",
|
|
171
|
-
absPath: HOME,
|
|
172
|
-
isDirectory: true,
|
|
173
|
-
expected: "~/",
|
|
174
|
-
},
|
|
175
|
-
])("when $desc, returns $expected", ({ absPath, isDirectory, expected }) => {
|
|
176
|
-
expect(toStorageForm(absPath, isDirectory)).toBe(expected);
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe("maybePathLike", () => {
|
|
181
|
-
it.each([
|
|
182
|
-
// --- True cases: structural path signals ---
|
|
183
|
-
{
|
|
184
|
-
desc: "absolute Unix path",
|
|
185
|
-
input: "/etc/hosts",
|
|
186
|
-
expected: true,
|
|
187
|
-
},
|
|
188
|
-
{
|
|
189
|
-
desc: "relative path with /",
|
|
190
|
-
input: "src/index.ts",
|
|
191
|
-
expected: true,
|
|
192
|
-
},
|
|
193
|
-
{
|
|
194
|
-
desc: "./ prefix",
|
|
195
|
-
input: "./foo",
|
|
196
|
-
expected: true,
|
|
197
|
-
},
|
|
198
|
-
{
|
|
199
|
-
desc: "../ prefix",
|
|
200
|
-
input: "../bar",
|
|
201
|
-
expected: true,
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
desc: "backslash path (Windows)",
|
|
205
|
-
input: "foo\\bar",
|
|
206
|
-
expected: true,
|
|
207
|
-
},
|
|
208
|
-
{
|
|
209
|
-
desc: "Windows drive letter",
|
|
210
|
-
input: "C:\\tmp",
|
|
211
|
-
expected: true,
|
|
212
|
-
},
|
|
213
|
-
{
|
|
214
|
-
desc: "Windows drive with forward slash",
|
|
215
|
-
input: "C:/tmp",
|
|
216
|
-
expected: true,
|
|
217
|
-
},
|
|
218
|
-
{
|
|
219
|
-
desc: "tilde home path",
|
|
220
|
-
input: "~/code",
|
|
221
|
-
expected: true,
|
|
222
|
-
},
|
|
223
|
-
{
|
|
224
|
-
desc: "MIME type (has / — safe false positive)",
|
|
225
|
-
input: "application/json",
|
|
226
|
-
expected: true,
|
|
227
|
-
},
|
|
228
|
-
{
|
|
229
|
-
desc: "regular expression with braces (has / — safe false positive)",
|
|
230
|
-
input: "/abc/{2,3}",
|
|
231
|
-
expected: true,
|
|
232
|
-
},
|
|
233
|
-
// --- False cases: non-path tokens ---
|
|
234
|
-
{
|
|
235
|
-
desc: "empty string",
|
|
236
|
-
input: "",
|
|
237
|
-
expected: false,
|
|
238
|
-
},
|
|
239
|
-
{
|
|
240
|
-
desc: "simple command name",
|
|
241
|
-
input: "rm",
|
|
242
|
-
expected: false,
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
desc: "flag",
|
|
246
|
-
input: "--force",
|
|
247
|
-
expected: false,
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
desc: "short flag",
|
|
251
|
-
input: "-rf",
|
|
252
|
-
expected: false,
|
|
253
|
-
},
|
|
254
|
-
{
|
|
255
|
-
desc: "bare word",
|
|
256
|
-
input: "build",
|
|
257
|
-
expected: false,
|
|
258
|
-
},
|
|
259
|
-
{
|
|
260
|
-
desc: "bare tilde (no slash)",
|
|
261
|
-
input: "~",
|
|
262
|
-
expected: false,
|
|
263
|
-
},
|
|
264
|
-
{
|
|
265
|
-
desc: "version number",
|
|
266
|
-
input: "3.14",
|
|
267
|
-
expected: false,
|
|
268
|
-
},
|
|
269
|
-
{
|
|
270
|
-
desc: "domain name",
|
|
271
|
-
input: "example.com",
|
|
272
|
-
expected: false,
|
|
273
|
-
},
|
|
274
|
-
{
|
|
275
|
-
desc: "bare filename with extension",
|
|
276
|
-
input: "README.md",
|
|
277
|
-
expected: false,
|
|
278
|
-
},
|
|
279
|
-
{
|
|
280
|
-
desc: "dotfile without slash",
|
|
281
|
-
input: ".env",
|
|
282
|
-
expected: false,
|
|
283
|
-
},
|
|
284
|
-
])("when $desc, returns $expected", ({ input, expected }) => {
|
|
285
|
-
expect(maybePathLike(input)).toBe(expected);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// maybePathLike is command-agnostic. Command-specific regex/code args are
|
|
289
|
-
// filtered by extractBashPathCandidates before this fallback heuristic runs.
|
|
290
|
-
it("treats awk-looking regex text as path-like without command context", () => {
|
|
291
|
-
expect(maybePathLike("/aaa/{flag=1} flag{print}")).toBe(true);
|
|
292
|
-
});
|
|
293
|
-
});
|
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { classifyCommandArgs } from "./command-args";
|
|
3
|
-
|
|
4
|
-
const tokens = (command: string, args: string[]) =>
|
|
5
|
-
classifyCommandArgs(command, args).map((arg) => arg.token);
|
|
6
|
-
|
|
7
|
-
describe("classifyCommandArgs", () => {
|
|
8
|
-
it("keeps unknown command arguments unchanged", () => {
|
|
9
|
-
expect(tokens("cat", ["/etc/hosts", "./file"])).toEqual([
|
|
10
|
-
"/etc/hosts",
|
|
11
|
-
"./file",
|
|
12
|
-
]);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("normalizes command basenames", () => {
|
|
16
|
-
expect(tokens("/usr/bin/awk", ["/aaa/{print}", "./input"])).toEqual([
|
|
17
|
-
"./input",
|
|
18
|
-
]);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("ignores awk inline program and keeps file operands", () => {
|
|
22
|
-
expect(tokens("awk", ["/aaa/{print}", "./input"])).toEqual(["./input"]);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it.each([
|
|
26
|
-
["-f as separate option", ["-f", "./prog.awk", "./input"]],
|
|
27
|
-
["-f as joined option", ["-f./prog.awk", "./input"]],
|
|
28
|
-
])("keeps awk program files with %s", (_label, args) => {
|
|
29
|
-
expect(tokens("awk", args)).toEqual(["./prog.awk", "./input"]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("ignores sed inline scripts and keeps file operands", () => {
|
|
33
|
-
expect(tokens("sed", ["s#/old#/new#g", "./file"])).toEqual(["./file"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it.each([
|
|
37
|
-
["-f as separate option", ["-f", "./script.sed", "./file"]],
|
|
38
|
-
["--file as long option", ["--file", "./script.sed", "./file"]],
|
|
39
|
-
["-f as joined option", ["-f./script.sed", "./file"]],
|
|
40
|
-
])("keeps sed script files with %s", (_label, args) => {
|
|
41
|
-
expect(tokens("sed", args)).toEqual(["./script.sed", "./file"]);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("ignores grep patterns and keeps file operands", () => {
|
|
45
|
-
expect(tokens("grep", ["/api/v1", "./src"])).toEqual(["./src"]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it.each([
|
|
49
|
-
["-f as separate option", ["-f", "./patterns", "./src"]],
|
|
50
|
-
["--file as long option", ["--file", "./patterns", "./src"]],
|
|
51
|
-
["-f as joined option", ["-f./patterns", "./src"]],
|
|
52
|
-
])("keeps grep pattern files with %s", (_label, args) => {
|
|
53
|
-
expect(tokens("grep", args)).toEqual(["./patterns", "./src"]);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
it("keeps find roots and ignores expression patterns", () => {
|
|
57
|
-
expect(tokens("find", ["./src", "-regex", ".*/test/.*"])).toEqual([
|
|
58
|
-
"./src",
|
|
59
|
-
]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("ignores jq filters and keeps file operands", () => {
|
|
63
|
-
expect(tokens("jq", ['.path | test("^/tmp/")', "./data.json"])).toEqual([
|
|
64
|
-
"./data.json",
|
|
65
|
-
]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it.each([
|
|
69
|
-
["-f as separate option", ["-f", "./filter.jq", "./data.json"]],
|
|
70
|
-
[
|
|
71
|
-
"--from-file as long option",
|
|
72
|
-
["--from-file", "./filter.jq", "./data.json"],
|
|
73
|
-
],
|
|
74
|
-
])("keeps jq filter files with %s", (_label, args) => {
|
|
75
|
-
expect(tokens("jq", args)).toEqual(["./filter.jq", "./data.json"]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it("ignores interpreter inline code", () => {
|
|
79
|
-
expect(tokens("python3", ["-c", 'open("/etc/passwd")'])).toEqual([]);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("keeps interpreter script operands", () => {
|
|
83
|
-
expect(tokens("python3", ["./script.py", "./data.json"])).toEqual([
|
|
84
|
-
"./script.py",
|
|
85
|
-
"./data.json",
|
|
86
|
-
]);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
it("ignores delimiter args", () => {
|
|
90
|
-
expect(tokens("cut", ["-d", "/", "./file"])).toEqual(["./file"]);
|
|
91
|
-
expect(tokens("sort", ["-t", "/", "./file"])).toEqual(["./file"]);
|
|
92
|
-
expect(tokens("tr", ["/", ":"])).toEqual([]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
describe("go subcommand", () => {
|
|
96
|
-
it("skips Go package wildcard patterns", () => {
|
|
97
|
-
expect(tokens("go", ["test", "./..."])).toEqual([]);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("keeps go run .go file operands", () => {
|
|
101
|
-
expect(tokens("go", ["run", "main.go"])).toEqual(["main.go"]);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it("skips non-.go positionals for go run", () => {
|
|
105
|
-
expect(tokens("go", ["run", "-exec", "/bin/env", "main.go"])).toEqual([
|
|
106
|
-
"main.go",
|
|
107
|
-
]);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("skips package patterns for build/vet/list", () => {
|
|
111
|
-
expect(tokens("go", ["build", "./..."])).toEqual([]);
|
|
112
|
-
expect(tokens("go", ["vet", "./pkg/..."])).toEqual([]);
|
|
113
|
-
expect(tokens("go", ["list", "./..."])).toEqual([]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("keeps file-valued flags", () => {
|
|
117
|
-
expect(tokens("go", ["build", "-modfile", "./go.mod", "./..."])).toEqual([
|
|
118
|
-
"./go.mod",
|
|
119
|
-
]);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it("keeps -o flag value for go build", () => {
|
|
123
|
-
expect(tokens("go", ["build", "-o", "./bin/app", "./..."])).toEqual([
|
|
124
|
-
"./bin/app",
|
|
125
|
-
]);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("handles -C global flag before subcommand", () => {
|
|
129
|
-
expect(tokens("go", ["-C", "/tmp", "test", "./..."])).toEqual([]);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it("handles -C joined form before subcommand", () => {
|
|
133
|
-
expect(tokens("go", ["-C=/tmp", "test", "./..."])).toEqual([]);
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
it("keeps go run .go file operands with -C", () => {
|
|
137
|
-
expect(tokens("go", ["-C", "/tmp", "run", "main.go"])).toEqual([
|
|
138
|
-
"main.go",
|
|
139
|
-
]);
|
|
140
|
-
});
|
|
141
|
-
});
|
|
142
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
compileCommandPattern,
|
|
4
|
-
compileFilePattern,
|
|
5
|
-
normalizeFilePath,
|
|
6
|
-
} from "./matching";
|
|
7
|
-
import { drainPendingWarnings } from "./warnings";
|
|
8
|
-
|
|
9
|
-
describe("normalizeFilePath", () => {
|
|
10
|
-
it.each([
|
|
11
|
-
["./src//file.ts", "src/file.ts"],
|
|
12
|
-
["src\\file.ts", "src/file.ts"],
|
|
13
|
-
["./foo\\bar//baz", "foo/bar/baz"],
|
|
14
|
-
])("normalizes %s", (input, expected) => {
|
|
15
|
-
expect(normalizeFilePath(input)).toBe(expected);
|
|
16
|
-
});
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
describe("compileFilePattern", () => {
|
|
20
|
-
it("matches basename when the pattern has no slash", () => {
|
|
21
|
-
const pattern = compileFilePattern({ pattern: ".env" });
|
|
22
|
-
|
|
23
|
-
expect(pattern.test(".env")).toBe(true);
|
|
24
|
-
expect(pattern.test("config/.env")).toBe(true);
|
|
25
|
-
expect(pattern.test("config/.env.local")).toBe(false);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("matches full normalized paths when the pattern has a slash", () => {
|
|
29
|
-
const pattern = compileFilePattern({ pattern: "config/*.env" });
|
|
30
|
-
|
|
31
|
-
expect(pattern.test("config/app.env")).toBe(true);
|
|
32
|
-
expect(pattern.test("./config//app.env")).toBe(true);
|
|
33
|
-
expect(pattern.test("nested/config/app.env")).toBe(false);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("uses case-insensitive regex matching for file patterns", () => {
|
|
37
|
-
const pattern = compileFilePattern({
|
|
38
|
-
pattern: "SECRET\\.TXT$",
|
|
39
|
-
regex: true,
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
expect(pattern.test("docs/secret.txt")).toBe(true);
|
|
43
|
-
expect(pattern.test("docs/public.txt")).toBe(false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("records a warning and returns a non-matching pattern for invalid regex", () => {
|
|
47
|
-
drainPendingWarnings();
|
|
48
|
-
|
|
49
|
-
const pattern = compileFilePattern({ pattern: "[", regex: true });
|
|
50
|
-
|
|
51
|
-
expect(pattern.test("anything")).toBe(false);
|
|
52
|
-
expect(drainPendingWarnings()).toEqual([
|
|
53
|
-
"Invalid regex in guardrails config: [",
|
|
54
|
-
]);
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe("compileCommandPattern", () => {
|
|
59
|
-
it("uses substring matching by default", () => {
|
|
60
|
-
const pattern = compileCommandPattern({ pattern: "deploy production" });
|
|
61
|
-
|
|
62
|
-
expect(pattern.test("please deploy production now")).toBe(true);
|
|
63
|
-
expect(pattern.test("deploy staging")).toBe(false);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("uses regex matching when requested", () => {
|
|
67
|
-
const pattern = compileCommandPattern({
|
|
68
|
-
pattern: "terraform\\s+apply",
|
|
69
|
-
regex: true,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
expect(pattern.test("terraform apply -auto-approve")).toBe(true);
|
|
73
|
-
expect(pattern.test("terraform plan")).toBe(false);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("records a warning and returns a non-matching pattern for invalid regex", () => {
|
|
77
|
-
drainPendingWarnings();
|
|
78
|
-
|
|
79
|
-
const pattern = compileCommandPattern({ pattern: "[", regex: true });
|
|
80
|
-
|
|
81
|
-
expect(pattern.test("anything")).toBe(false);
|
|
82
|
-
expect(drainPendingWarnings()).toEqual([
|
|
83
|
-
"Invalid regex in guardrails config: [",
|
|
84
|
-
]);
|
|
85
|
-
});
|
|
86
|
-
});
|