@aliou/pi-guardrails 0.11.2 → 0.12.1

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 (95) hide show
  1. package/README.md +72 -167
  2. package/extensions/guardrails/commands/examples/index.ts +520 -0
  3. package/extensions/guardrails/commands/onboarding/config.ts +54 -0
  4. package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
  5. package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
  6. package/extensions/guardrails/commands/settings/examples.ts +399 -0
  7. package/extensions/guardrails/commands/settings/index.ts +596 -0
  8. package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
  9. package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
  10. package/extensions/guardrails/commands/settings/utils.ts +108 -0
  11. package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
  12. package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
  13. package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
  14. package/extensions/guardrails/components/onboarding-types.ts +10 -0
  15. package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
  16. package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
  17. package/extensions/guardrails/index.ts +106 -0
  18. package/extensions/guardrails/rules.test.ts +107 -0
  19. package/extensions/guardrails/rules.ts +119 -0
  20. package/extensions/guardrails/targets.test.ts +44 -0
  21. package/extensions/guardrails/targets.ts +66 -0
  22. package/extensions/path-access/grants.test.ts +47 -0
  23. package/extensions/path-access/grants.ts +68 -0
  24. package/extensions/path-access/index.ts +143 -0
  25. package/extensions/path-access/prompt.ts +196 -0
  26. package/extensions/path-access/rules.test.ts +46 -0
  27. package/extensions/path-access/rules.ts +37 -0
  28. package/extensions/path-access/targets.test.ts +40 -0
  29. package/extensions/path-access/targets.ts +19 -0
  30. package/extensions/permission-gate/grants.ts +21 -0
  31. package/extensions/permission-gate/index.ts +122 -0
  32. package/extensions/permission-gate/prompt.ts +222 -0
  33. package/extensions/permission-gate/rules.test.ts +132 -0
  34. package/extensions/permission-gate/rules.ts +72 -0
  35. package/package.json +18 -20
  36. package/schema.json +286 -0
  37. package/src/core/check.test.ts +169 -0
  38. package/src/core/check.ts +38 -0
  39. package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
  40. package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
  41. package/src/core/commands/index.ts +15 -0
  42. package/src/core/index.ts +13 -0
  43. package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
  44. package/src/core/paths/index.ts +14 -0
  45. package/src/core/shell/command-args.test.ts +142 -0
  46. package/src/{utils → core/shell}/command-args.ts +71 -0
  47. package/src/core/shell/index.ts +2 -0
  48. package/src/core/types.ts +55 -0
  49. package/src/shared/config/defaults.ts +118 -0
  50. package/src/shared/config/index.ts +17 -0
  51. package/src/shared/config/loader.ts +64 -0
  52. package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
  53. package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
  54. package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
  55. package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
  56. package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
  57. package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
  58. package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
  59. package/src/shared/config/migration/index.ts +44 -0
  60. package/src/shared/config/migration/version.ts +7 -0
  61. package/src/shared/config/types.ts +141 -0
  62. package/src/shared/events.ts +100 -0
  63. package/src/shared/index.ts +6 -0
  64. package/src/shared/matching.test.ts +86 -0
  65. package/src/{utils → shared}/matching.ts +4 -4
  66. package/src/{utils → shared/paths}/bash-paths.test.ts +32 -2
  67. package/src/{utils → shared/paths}/bash-paths.ts +4 -4
  68. package/src/shared/paths/index.ts +1 -0
  69. package/src/shared/warnings.ts +17 -0
  70. package/docs/defaults.md +0 -140
  71. package/docs/examples.md +0 -170
  72. package/src/commands/onboarding.ts +0 -390
  73. package/src/commands/settings-command.ts +0 -1616
  74. package/src/config.ts +0 -392
  75. package/src/hooks/index.ts +0 -11
  76. package/src/hooks/path-access.ts +0 -395
  77. package/src/hooks/permission-gate/index.test.ts +0 -332
  78. package/src/hooks/permission-gate/index.ts +0 -595
  79. package/src/hooks/policies.ts +0 -322
  80. package/src/index.ts +0 -96
  81. package/src/lib/executor.ts +0 -280
  82. package/src/lib/index.ts +0 -16
  83. package/src/lib/model-resolver.ts +0 -47
  84. package/src/lib/timing.ts +0 -42
  85. package/src/lib/types.ts +0 -115
  86. package/src/utils/command-args.test.ts +0 -83
  87. package/src/utils/events.ts +0 -32
  88. package/src/utils/migration.test.ts +0 -58
  89. package/src/utils/migration.ts +0 -340
  90. package/src/utils/warnings.ts +0 -7
  91. /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
  92. /package/src/{utils → core/paths}/path.test.ts +0 -0
  93. /package/src/{utils → core/paths}/path.ts +0 -0
  94. /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
  95. /package/src/{utils/glob-expander.ts → shared/glob.ts} +0 -0
package/schema.json ADDED
@@ -0,0 +1,286 @@
1
+ {
2
+ "$ref": "#/definitions/GuardrailsConfig",
3
+ "$schema": "http://json-schema.org/draft-07/schema#",
4
+ "definitions": {
5
+ "DangerousPattern": {
6
+ "additionalProperties": false,
7
+ "description": "Permission gate pattern. When regex is false (default), the pattern is matched as substring against the raw command string. When regex is true, uses full regex against the raw string.",
8
+ "properties": {
9
+ "description": {
10
+ "description": "Optional description surfaced to the agent when the pattern triggers (e.g. auto-deny reason).",
11
+ "type": "string"
12
+ },
13
+ "pattern": {
14
+ "type": "string"
15
+ },
16
+ "regex": {
17
+ "type": "boolean"
18
+ }
19
+ },
20
+ "required": [
21
+ "description",
22
+ "pattern"
23
+ ],
24
+ "type": "object"
25
+ },
26
+ "GuardrailsConfig": {
27
+ "additionalProperties": false,
28
+ "properties": {
29
+ "$schema": {
30
+ "description": "JSON Schema URL for editor autocomplete and validation. Added automatically when Guardrails writes the file.",
31
+ "type": "string"
32
+ },
33
+ "applyBuiltinDefaults": {
34
+ "description": "When true, include Guardrails built-in policy rules before user rules are merged.",
35
+ "type": "boolean"
36
+ },
37
+ "enabled": {
38
+ "description": "Enable or disable all Guardrails checks.",
39
+ "type": "boolean"
40
+ },
41
+ "envFiles": {
42
+ "additionalProperties": false,
43
+ "properties": {
44
+ "allowedPatterns": {
45
+ "items": {
46
+ "$ref": "#/definitions/PatternConfig"
47
+ },
48
+ "type": "array"
49
+ },
50
+ "blockMessage": {
51
+ "type": "string"
52
+ },
53
+ "onlyBlockIfExists": {
54
+ "type": "boolean"
55
+ },
56
+ "protectedDirectories": {
57
+ "items": {
58
+ "$ref": "#/definitions/PatternConfig"
59
+ },
60
+ "type": "array"
61
+ },
62
+ "protectedPatterns": {
63
+ "items": {
64
+ "$ref": "#/definitions/PatternConfig"
65
+ },
66
+ "type": "array"
67
+ },
68
+ "protectedTools": {
69
+ "items": {
70
+ "type": "string"
71
+ },
72
+ "type": "array"
73
+ }
74
+ },
75
+ "type": "object"
76
+ },
77
+ "features": {
78
+ "additionalProperties": false,
79
+ "description": "Enable or disable individual Guardrails feature extensions.",
80
+ "properties": {
81
+ "pathAccess": {
82
+ "type": "boolean"
83
+ },
84
+ "permissionGate": {
85
+ "type": "boolean"
86
+ },
87
+ "policies": {
88
+ "type": "boolean"
89
+ },
90
+ "protectEnvFiles": {
91
+ "type": "boolean"
92
+ }
93
+ },
94
+ "type": "object"
95
+ },
96
+ "onboarding": {
97
+ "additionalProperties": false,
98
+ "description": "Tracks whether the setup wizard has been completed. Usually managed by Guardrails.",
99
+ "properties": {
100
+ "completed": {
101
+ "description": "Whether onboarding is complete.",
102
+ "type": "boolean"
103
+ },
104
+ "completedAt": {
105
+ "description": "ISO timestamp for when onboarding completed.",
106
+ "type": "string"
107
+ },
108
+ "version": {
109
+ "description": "Guardrails config schema marker used when onboarding completed.",
110
+ "type": "string"
111
+ }
112
+ },
113
+ "type": "object"
114
+ },
115
+ "pathAccess": {
116
+ "$ref": "#/definitions/PathAccessConfig",
117
+ "description": "Outside-workspace path access settings."
118
+ },
119
+ "permissionGate": {
120
+ "additionalProperties": false,
121
+ "description": "Dangerous bash command detection and confirmation settings.",
122
+ "properties": {
123
+ "allowedPatterns": {
124
+ "description": "Command patterns that bypass dangerous command prompts.",
125
+ "items": {
126
+ "$ref": "#/definitions/PatternConfig"
127
+ },
128
+ "type": "array"
129
+ },
130
+ "autoDenyPatterns": {
131
+ "description": "Command patterns that are always blocked without prompting.",
132
+ "items": {
133
+ "$ref": "#/definitions/PatternConfig"
134
+ },
135
+ "type": "array"
136
+ },
137
+ "customPatterns": {
138
+ "description": "If set, replaces the default dangerous command patterns entirely.",
139
+ "items": {
140
+ "$ref": "#/definitions/DangerousPattern"
141
+ },
142
+ "type": "array"
143
+ },
144
+ "patterns": {
145
+ "description": "Additional dangerous command patterns.",
146
+ "items": {
147
+ "$ref": "#/definitions/DangerousPattern"
148
+ },
149
+ "type": "array"
150
+ },
151
+ "requireConfirmation": {
152
+ "description": "When true, prompt before running dangerous commands. When false, only warn.",
153
+ "type": "boolean"
154
+ }
155
+ },
156
+ "type": "object"
157
+ },
158
+ "policies": {
159
+ "additionalProperties": false,
160
+ "description": "File protection policies.",
161
+ "properties": {
162
+ "rules": {
163
+ "description": "Named policy rules. Rules with the same id override earlier rules across scopes.",
164
+ "items": {
165
+ "$ref": "#/definitions/PolicyRule"
166
+ },
167
+ "type": "array"
168
+ }
169
+ },
170
+ "type": "object"
171
+ },
172
+ "version": {
173
+ "description": "Internal config schema marker for migration/debugging. Not tied to the package version.",
174
+ "type": "string"
175
+ }
176
+ },
177
+ "type": "object"
178
+ },
179
+ "PathAccessConfig": {
180
+ "additionalProperties": false,
181
+ "properties": {
182
+ "allowedPaths": {
183
+ "items": {
184
+ "type": "string"
185
+ },
186
+ "type": "array"
187
+ },
188
+ "mode": {
189
+ "$ref": "#/definitions/PathAccessMode"
190
+ }
191
+ },
192
+ "type": "object"
193
+ },
194
+ "PathAccessMode": {
195
+ "enum": [
196
+ "allow",
197
+ "ask",
198
+ "block"
199
+ ],
200
+ "type": "string"
201
+ },
202
+ "PatternConfig": {
203
+ "additionalProperties": false,
204
+ "description": "A pattern with explicit matching mode. Default: glob for files, substring for commands. regex: true means full regex matching.",
205
+ "properties": {
206
+ "description": {
207
+ "description": "Optional description surfaced to the agent when the pattern triggers (e.g. auto-deny reason).",
208
+ "type": "string"
209
+ },
210
+ "pattern": {
211
+ "type": "string"
212
+ },
213
+ "regex": {
214
+ "type": "boolean"
215
+ }
216
+ },
217
+ "required": [
218
+ "pattern"
219
+ ],
220
+ "type": "object"
221
+ },
222
+ "PolicyRule": {
223
+ "additionalProperties": false,
224
+ "description": "A named policy rule. Matches files by patterns and enforces a protection level.",
225
+ "properties": {
226
+ "allowedPatterns": {
227
+ "description": "Optional exceptions.",
228
+ "items": {
229
+ "$ref": "#/definitions/PatternConfig"
230
+ },
231
+ "type": "array"
232
+ },
233
+ "blockMessage": {
234
+ "description": "Message shown when blocked; supports {file} placeholder.",
235
+ "type": "string"
236
+ },
237
+ "description": {
238
+ "description": "Human-readable description.",
239
+ "type": "string"
240
+ },
241
+ "enabled": {
242
+ "description": "Per-rule toggle. Default true.",
243
+ "type": "boolean"
244
+ },
245
+ "id": {
246
+ "description": "Stable identifier used for deduplication across scopes.",
247
+ "type": "string"
248
+ },
249
+ "name": {
250
+ "description": "Optional display name for settings/UI.",
251
+ "type": "string"
252
+ },
253
+ "onlyIfExists": {
254
+ "description": "Block only when file exists on disk. Default true.",
255
+ "type": "boolean"
256
+ },
257
+ "patterns": {
258
+ "description": "File patterns to protect.",
259
+ "items": {
260
+ "$ref": "#/definitions/PatternConfig"
261
+ },
262
+ "type": "array"
263
+ },
264
+ "protection": {
265
+ "$ref": "#/definitions/Protection",
266
+ "description": "Protection level."
267
+ }
268
+ },
269
+ "required": [
270
+ "id",
271
+ "patterns",
272
+ "protection"
273
+ ],
274
+ "type": "object"
275
+ },
276
+ "Protection": {
277
+ "description": "Protection level for a policy rule.",
278
+ "enum": [
279
+ "none",
280
+ "readOnly",
281
+ "noAccess"
282
+ ],
283
+ "type": "string"
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { checkAction, resolveDecision } from "./check";
3
+ import type { Action, Rule, Safety } from "./types";
4
+
5
+ const commandAction: Action = { kind: "command", command: "rm -rf /tmp/test" };
6
+
7
+ type TestMeta = { pattern: string; source: "test" };
8
+
9
+ const testMetadata: TestMeta = { pattern: "rm -rf", source: "test" };
10
+
11
+ describe("checkAction", () => {
12
+ it("returns safe when no rules match", async () => {
13
+ const rules: Rule[] = [
14
+ {
15
+ key: "sudo",
16
+ check: () => ({ kind: "pass" }),
17
+ },
18
+ ];
19
+
20
+ await expect(checkAction(commandAction, rules)).resolves.toEqual({
21
+ kind: "safe",
22
+ });
23
+ });
24
+
25
+ it("returns dangerous for the first matching rule", async () => {
26
+ const secondCheck = vi.fn(() => ({
27
+ kind: "match" as const,
28
+ reason: "second match",
29
+ metadata: testMetadata,
30
+ }));
31
+ const rules: Rule<TestMeta>[] = [
32
+ {
33
+ key: "first",
34
+ check: () => ({
35
+ kind: "match",
36
+ reason: "first match",
37
+ metadata: testMetadata,
38
+ }),
39
+ },
40
+ {
41
+ key: "second",
42
+ check: secondCheck,
43
+ },
44
+ ];
45
+
46
+ await expect(checkAction(commandAction, rules)).resolves.toEqual({
47
+ kind: "dangerous",
48
+ action: commandAction,
49
+ key: "first",
50
+ reason: "first match",
51
+ metadata: testMetadata,
52
+ });
53
+ expect(secondCheck).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("supports async rules", async () => {
57
+ const rules: Rule[] = [
58
+ {
59
+ key: "async",
60
+ check: async (action) =>
61
+ action.kind === "command"
62
+ ? {
63
+ kind: "match",
64
+ reason: "async match",
65
+ metadata: null,
66
+ }
67
+ : { kind: "pass" },
68
+ },
69
+ ];
70
+
71
+ await expect(checkAction(commandAction, rules)).resolves.toMatchObject({
72
+ kind: "dangerous",
73
+ key: "async",
74
+ reason: "async match",
75
+ metadata: null,
76
+ });
77
+ });
78
+
79
+ it("propagates rule errors", async () => {
80
+ const error = new Error("rule failed");
81
+ const rules: Rule[] = [
82
+ {
83
+ key: "broken",
84
+ check: () => {
85
+ throw error;
86
+ },
87
+ },
88
+ ];
89
+
90
+ await expect(checkAction(commandAction, rules)).rejects.toThrow(error);
91
+ });
92
+
93
+ it("propagates async rule rejections", async () => {
94
+ const error = new Error("async rule failed");
95
+ const rules: Rule[] = [
96
+ {
97
+ key: "broken-async",
98
+ check: async () => {
99
+ throw error;
100
+ },
101
+ },
102
+ ];
103
+
104
+ await expect(checkAction(commandAction, rules)).rejects.toThrow(error);
105
+ });
106
+
107
+ it("preserves typed match metadata", async () => {
108
+ const rules: Rule<TestMeta>[] = [
109
+ {
110
+ key: "typed",
111
+ check: () => ({
112
+ kind: "match",
113
+ reason: "typed match",
114
+ metadata: testMetadata,
115
+ }),
116
+ },
117
+ ];
118
+
119
+ const safety = await checkAction(commandAction, rules);
120
+
121
+ if (safety.kind !== "dangerous") {
122
+ throw new Error("expected dangerous safety");
123
+ }
124
+
125
+ expect(safety.metadata.pattern).toBe("rm -rf");
126
+
127
+ const decision = resolveDecision(safety, "prompt");
128
+
129
+ if (decision.kind !== "prompt") {
130
+ throw new Error("expected prompt decision");
131
+ }
132
+
133
+ expect(decision.risk.metadata.pattern).toBe("rm -rf");
134
+ });
135
+ });
136
+
137
+ describe("resolveDecision", () => {
138
+ const dangerous: Safety = {
139
+ kind: "dangerous",
140
+ action: commandAction,
141
+ key: "rm-rf",
142
+ reason: "recursive force delete",
143
+ metadata: null,
144
+ };
145
+
146
+ it("allows safe actions", () => {
147
+ expect(resolveDecision({ kind: "safe" }, "denied")).toEqual({
148
+ kind: "allow",
149
+ });
150
+ });
151
+
152
+ it("allows dangerous actions when permission is granted", () => {
153
+ expect(resolveDecision(dangerous, "granted")).toEqual({ kind: "allow" });
154
+ });
155
+
156
+ it("denies dangerous actions when permission is denied", () => {
157
+ expect(resolveDecision(dangerous, "denied")).toEqual({
158
+ kind: "deny",
159
+ reason: "recursive force delete",
160
+ });
161
+ });
162
+
163
+ it("prompts for dangerous actions when permission is prompt", () => {
164
+ expect(resolveDecision(dangerous, "prompt")).toEqual({
165
+ kind: "prompt",
166
+ risk: dangerous,
167
+ });
168
+ });
169
+ });
@@ -0,0 +1,38 @@
1
+ import type { Action, Decision, PermissionState, Rule, Safety } from "./types";
2
+
3
+ export async function checkAction<TMeta = null>(
4
+ action: Action,
5
+ rules: readonly Rule<TMeta>[],
6
+ ): Promise<Safety<TMeta>> {
7
+ for (const rule of rules) {
8
+ const result = await rule.check(action);
9
+
10
+ if (result.kind === "match") {
11
+ return {
12
+ kind: "dangerous",
13
+ action,
14
+ key: rule.key,
15
+ reason: result.reason,
16
+ metadata: result.metadata,
17
+ };
18
+ }
19
+ }
20
+
21
+ return { kind: "safe" };
22
+ }
23
+
24
+ export function resolveDecision<TMeta = null>(
25
+ safety: Safety<TMeta>,
26
+ permissionState: PermissionState,
27
+ ): Decision<TMeta> {
28
+ if (safety.kind === "safe") return { kind: "allow" };
29
+
30
+ switch (permissionState) {
31
+ case "granted":
32
+ return { kind: "allow" };
33
+ case "denied":
34
+ return { kind: "deny", reason: safety.reason };
35
+ case "prompt":
36
+ return { kind: "prompt", risk: safety };
37
+ }
38
+ }
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { BUILTIN_MATCHERS, matchDangerousCommand } from "./dangerous-commands";
2
+ import {
3
+ BUILTIN_MATCHERS,
4
+ checkDangerousCommand,
5
+ compileCommandPatterns,
6
+ matchDangerousCommand,
7
+ } from "./dangerous";
3
8
 
4
9
  /**
5
10
  * Helper to run all matchers against a command string.
@@ -108,7 +113,7 @@ describe("dd matcher", () => {
108
113
  ).toBe("disk write operation");
109
114
  });
110
115
 
111
- it("does not match dd with only if= (input only)", () => {
116
+ it("matches dd writing to /dev/null", () => {
112
117
  expect(findMatch(["dd", "if=/dev/sda", "of=/dev/null"])).toBe(
113
118
  "disk write operation",
114
119
  );
@@ -269,6 +274,18 @@ describe("container matcher (docker/podman)", () => {
269
274
  );
270
275
  });
271
276
 
277
+ it("matches docker run --uts=host", () => {
278
+ expect(findMatch(["docker", "run", "--uts=host", "alpine"])).toBe(
279
+ "container with host UTS namespace",
280
+ );
281
+ });
282
+
283
+ it("matches docker run --ipc=host", () => {
284
+ expect(findMatch(["docker", "run", "--ipc=host", "alpine"])).toBe(
285
+ "container with host IPC",
286
+ );
287
+ });
288
+
272
289
  it("matches docker run with root mount", () => {
273
290
  expect(findMatch(["docker", "run", "-v/:/host", "alpine"])).toBe(
274
291
  "container with root filesystem mount",
@@ -315,6 +332,121 @@ describe("container matcher (docker/podman)", () => {
315
332
  });
316
333
  });
317
334
 
335
+ describe("checkDangerousCommand", () => {
336
+ it("matches built-in dangerous commands structurally", () => {
337
+ const result = checkDangerousCommand({
338
+ command: "rm -rf /tmp/example",
339
+ patterns: compileCommandPatterns([
340
+ { pattern: "rm -rf", description: "recursive force delete" },
341
+ ]),
342
+ useBuiltinMatchers: true,
343
+ fallbackPatterns: [
344
+ { pattern: "rm -rf", description: "recursive force delete" },
345
+ ],
346
+ });
347
+
348
+ expect(result).toEqual({
349
+ description: "recursive force delete",
350
+ pattern: "(structural)",
351
+ });
352
+ });
353
+
354
+ it("skips built-in substring matches after a successful parse", () => {
355
+ const result = checkDangerousCommand({
356
+ command: "echo 'rm -rf /tmp/example'",
357
+ patterns: compileCommandPatterns([
358
+ { pattern: "rm -rf", description: "recursive force delete" },
359
+ ]),
360
+ useBuiltinMatchers: true,
361
+ fallbackPatterns: [
362
+ { pattern: "rm -rf", description: "recursive force delete" },
363
+ ],
364
+ });
365
+
366
+ expect(result).toBeUndefined();
367
+ });
368
+
369
+ it("uses configured regex patterns", () => {
370
+ const result = checkDangerousCommand({
371
+ command: "terraform apply -auto-approve",
372
+ patterns: compileCommandPatterns([
373
+ {
374
+ pattern: "terraform\\s+apply",
375
+ description: "terraform apply",
376
+ regex: true,
377
+ },
378
+ ]),
379
+ useBuiltinMatchers: false,
380
+ fallbackPatterns: [],
381
+ });
382
+
383
+ expect(result).toEqual({
384
+ description: "terraform apply",
385
+ pattern: "terraform\\s+apply",
386
+ });
387
+ });
388
+
389
+ it("ignores invalid regex patterns", () => {
390
+ const result = checkDangerousCommand({
391
+ command: "anything",
392
+ patterns: compileCommandPatterns([
393
+ { pattern: "[", description: "invalid", regex: true },
394
+ ]),
395
+ useBuiltinMatchers: false,
396
+ fallbackPatterns: [],
397
+ });
398
+
399
+ expect(result).toBeUndefined();
400
+ });
401
+
402
+ it("uses configured patterns when built-in matchers are disabled", () => {
403
+ const result = checkDangerousCommand({
404
+ command: "deploy production",
405
+ patterns: compileCommandPatterns([
406
+ { pattern: "deploy production", description: "production deploy" },
407
+ ]),
408
+ useBuiltinMatchers: false,
409
+ fallbackPatterns: [],
410
+ });
411
+
412
+ expect(result).toEqual({
413
+ description: "production deploy",
414
+ pattern: "deploy production",
415
+ });
416
+ });
417
+
418
+ it("falls back to raw patterns when parsing fails", () => {
419
+ const result = checkDangerousCommand({
420
+ command: "if then rm -rf /tmp/example",
421
+ patterns: [],
422
+ useBuiltinMatchers: true,
423
+ fallbackPatterns: [
424
+ { pattern: "rm -rf", description: "recursive force delete" },
425
+ ],
426
+ });
427
+
428
+ expect(result).toEqual({
429
+ description: "recursive force delete",
430
+ pattern: "rm -rf",
431
+ });
432
+ });
433
+
434
+ it.each([
435
+ ["logical command", "echo ok && sudo true", "superuser command"],
436
+ ["pipeline", "echo ok | sudo tee /tmp/out", "superuser command"],
437
+ ["subshell", "(sudo true)", "superuser command"],
438
+ ])("matches dangerous commands nested in a %s", (_label, command, description) => {
439
+ const result = checkDangerousCommand({
440
+ command,
441
+ patterns: [],
442
+ useBuiltinMatchers: true,
443
+ fallbackPatterns: [],
444
+ });
445
+
446
+ expect(result).toEqual({ description, pattern: "(structural)" });
447
+ });
448
+ });
449
+
318
450
  describe("matchDangerousCommand", () => {
319
451
  it("returns description and pattern for dangerous commands", () => {
320
452
  const result = matchDangerousCommand(["sudo", "apt", "update"]);