@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 CHANGED
@@ -1,4 +1,4 @@
1
- ![banner](https://assets.aliou.me/pi-extensions/banners/pi-guardrails.png)
1
+ ![banner](https://assets.aliou.me/github/aliou/pi-guardrails/banner.png)
2
2
 
3
3
  # Guardrails
4
4
 
@@ -24,7 +24,7 @@ After installing, run the onboarding command to choose a starting setup:
24
24
  /guardrails:onboarding
25
25
  ```
26
26
 
27
- ![Guardrails onboarding walkthrough](https://assets.aliou.me/pi-extensions/demos/guardrails/v0.12.0/onboarding.gif)
27
+ [![Guardrails onboarding walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/onboarding.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/onboarding.mp4)
28
28
 
29
29
  You can change everything later with:
30
30
 
@@ -40,7 +40,7 @@ The `guardrails` extension owns file protection policies and the user-facing com
40
40
 
41
41
  Use it to protect files like `.env`, private keys, local credentials, generated logs, database dumps, or any project-specific path you do not want Pi to read or modify without clear intent.
42
42
 
43
- ![Guardrails policies and settings walkthrough](https://assets.aliou.me/pi-extensions/demos/guardrails/v0.12.0/policies.gif)
43
+ [![Guardrails policies and settings walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/policies.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/policies.mp4)
44
44
 
45
45
  Useful commands:
46
46
 
@@ -56,7 +56,7 @@ The `path-access` extension checks tool calls that target paths outside the curr
56
56
 
57
57
  It can allow, block, or ask before Pi accesses files elsewhere on your machine. In ask mode, you can allow one file or a directory once, for the session, or always.
58
58
 
59
- ![Guardrails path access prompt walkthrough](https://assets.aliou.me/pi-extensions/demos/guardrails/v0.12.0/path-access.gif)
59
+ [![Guardrails path access prompt walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/path-access.mp4)
60
60
 
61
61
  ### permission-gate
62
62
 
@@ -64,7 +64,7 @@ The `permission-gate` extension detects dangerous bash commands before they run.
64
64
 
65
65
  It catches built-in risky patterns like recursive deletes, privileged commands, disk formatting, broad permission changes, and configured custom patterns. You can allow once, allow for the session, deny, or configure auto-deny rules.
66
66
 
67
- ![Guardrails permission gate walkthrough](https://assets.aliou.me/pi-extensions/demos/guardrails/v0.12.0/permission-gate.gif)
67
+ [![Guardrails permission gate walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/permission-gate.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/permission-gate.mp4)
68
68
 
69
69
  ## Configuration
70
70
 
@@ -89,7 +89,7 @@ Use the examples command to add common policy and command presets without replac
89
89
  /guardrails:examples
90
90
  ```
91
91
 
92
- ![Guardrails examples command walkthrough](https://assets.aliou.me/pi-extensions/demos/guardrails/v0.12.0/examples.gif)
92
+ [![Guardrails examples command walkthrough](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/examples.gif)](https://assets.aliou.me/github/aliou/pi-guardrails/v0.12.0/examples.mp4)
93
93
 
94
94
  The available presets live in [`extensions/guardrails/commands/settings/examples.ts`](extensions/guardrails/commands/settings/examples.ts).
95
95
 
@@ -97,6 +97,8 @@ The available presets live in [`extensions/guardrails/commands/settings/examples
97
97
 
98
98
  Pi is designed to make agent safety extensible. Guardrails focuses on deterministic, configurable file policies, outside-workspace path access, and dangerous-command prompts. Other packages tend to fall into two useful groups.
99
99
 
100
+ See [pi.dev/packages](https://pi.dev/packages) for the full registry of Pi extensions.
101
+
100
102
  ### Make one yourself!
101
103
 
102
104
  If Guardrails or the alternatives below do not fit your needs, you can also make your own. Start from the [Pi permission gate example](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/examples/extensions/permission-gate.ts), then ask Pi to customize it for your workflow.
@@ -9,6 +9,7 @@ import { configLoader } from "../../src/shared/config";
9
9
  import {
10
10
  createFeatureRegisterPayload,
11
11
  emitActionBlocked,
12
+ emitActionPrompted,
12
13
  GUARDRAILS_FEATURE_REGISTER_EVENT,
13
14
  GUARDRAILS_FEATURE_REQUEST_EVENT,
14
15
  } from "../../src/shared/events";
@@ -85,6 +86,17 @@ export default async function pathAccess(pi: ExtensionAPI) {
85
86
  const parentDir = dirname(absolutePath);
86
87
  const showFileOptions =
87
88
  event.toolName !== "ls" && event.toolName !== "find";
89
+ emitActionPrompted(pi, {
90
+ feature: "pathAccess",
91
+ action: safety.action,
92
+ reason: safety.reason,
93
+ prompt: {
94
+ kind: "confirmation",
95
+ metadata: safety.metadata,
96
+ },
97
+ context: { toolName: event.toolName, input },
98
+ });
99
+
88
100
  const result = await ctx.ui.custom<PromptResult>(
89
101
  createPathAccessPromptComponent(
90
102
  event.toolName,
@@ -7,6 +7,7 @@ import { configLoader } from "../../src/shared/config";
7
7
  import {
8
8
  createFeatureRegisterPayload,
9
9
  emitActionBlocked,
10
+ emitActionPrompted,
10
11
  emitRiskDetected,
11
12
  GUARDRAILS_FEATURE_REGISTER_EVENT,
12
13
  GUARDRAILS_FEATURE_REQUEST_EVENT,
@@ -89,6 +90,17 @@ export default async function permissionGate(pi: ExtensionAPI) {
89
90
  }
90
91
 
91
92
  type ConfirmResult = "allow" | "allow-session" | "deny";
93
+ emitActionPrompted(pi, {
94
+ feature: "permissionGate",
95
+ action: safety.action,
96
+ reason: safety.reason,
97
+ prompt: {
98
+ kind: "permission",
99
+ metadata: safety.metadata,
100
+ },
101
+ context: { toolName: "bash", input: event.input },
102
+ });
103
+
92
104
  let result = await ctx.ui.custom<ConfirmResult>(
93
105
  createPermissionGateConfirmComponent(command, safety.reason),
94
106
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -21,7 +21,7 @@
21
21
  "./extensions/guardrails/index.ts",
22
22
  "./extensions/permission-gate/index.ts"
23
23
  ],
24
- "video": "https://assets.aliou.me/pi-extensions/demos/pi-guardrails.mp4"
24
+ "video": "https://assets.aliou.me/github/aliou/pi-guardrails/demo.mp4"
25
25
  },
26
26
  "publishConfig": {
27
27
  "access": "public"
@@ -30,7 +30,9 @@
30
30
  "src",
31
31
  "extensions",
32
32
  "README.md",
33
- "schema.json"
33
+ "schema.json",
34
+ "!src/**/*.test.ts",
35
+ "!extensions/**/*.test.ts"
34
36
  ],
35
37
  "dependencies": {
36
38
  "@aliou/pi-utils-settings": "^0.15.1",
@@ -41,8 +43,8 @@
41
43
  "@earendil-works/pi-tui": "0.74.0"
42
44
  },
43
45
  "devDependencies": {
44
- "@aliou/biome-plugins": "^0.3.2",
45
- "@biomejs/biome": "^2.3.13",
46
+ "@aliou/biome-plugins": "^0.8.1",
47
+ "@biomejs/biome": "^2.4.15",
46
48
  "@changesets/cli": "^2.27.11",
47
49
  "@earendil-works/pi-coding-agent": "0.74.0",
48
50
  "@earendil-works/pi-tui": "0.74.0",
@@ -5,6 +5,7 @@ export const GUARDRAILS_ACTION_BLOCKED_EVENT = "guardrails:action:blocked";
5
5
  export const GUARDRAILS_RISK_DETECTED_EVENT = "guardrails:risk:detected";
6
6
  export const GUARDRAILS_FEATURE_REQUEST_EVENT = "guardrails:feature:request";
7
7
  export const GUARDRAILS_FEATURE_REGISTER_EVENT = "guardrails:feature:register";
8
+ export const GUARDRAILS_ACTION_PROMPTED_EVENT = "guardrails:action:prompted";
8
9
 
9
10
  export type GuardrailsFeatureId = "policies" | "permissionGate" | "pathAccess";
10
11
 
@@ -47,6 +48,22 @@ export type GuardrailsActionBlockedPayload<TMeta = unknown> =
47
48
  };
48
49
  };
49
50
 
51
+ export type GuardrailsActionPromptedPayload<TMeta = unknown> =
52
+ GuardrailsEventBase & {
53
+ action: Action;
54
+ reason: string;
55
+ prompt: {
56
+ /** What kind of prompt was shown */
57
+ kind: "confirmation" | "permission";
58
+ /** The feature-specific metadata about the risk */
59
+ metadata?: TMeta;
60
+ };
61
+ context?: {
62
+ toolName?: string;
63
+ input?: Record<string, unknown>;
64
+ };
65
+ };
66
+
50
67
  export type GuardrailsRiskDetectedPayload<TMeta = unknown> =
51
68
  GuardrailsEventBase & {
52
69
  risk: Safety<TMeta> & { kind: "dangerous" };
@@ -98,3 +115,14 @@ export function emitRiskDetected<TMeta = unknown>(
98
115
  ...event,
99
116
  });
100
117
  }
118
+
119
+ export function emitActionPrompted<TMeta = unknown>(
120
+ pi: ExtensionAPI,
121
+ event: Omit<GuardrailsActionPromptedPayload<TMeta>, "source" | "timestamp">,
122
+ ): void {
123
+ pi.events.emit(GUARDRAILS_ACTION_PROMPTED_EVENT, {
124
+ source: "guardrails",
125
+ timestamp: timestamp(),
126
+ ...event,
127
+ });
128
+ }
@@ -1,107 +0,0 @@
1
- import { join } from "node:path";
2
- import { vol } from "memfs";
3
- import { describe, expect, it } from "vitest";
4
- import { compilePolicies, createPolicyRules, normalizeTarget } from "./rules";
5
-
6
- function singleRule(
7
- cwd: string,
8
- policy: Parameters<typeof compilePolicies>[0][number],
9
- ) {
10
- const [rule] = createPolicyRules(compilePolicies([policy]), cwd);
11
- return rule;
12
- }
13
-
14
- describe("normalizeTarget", () => {
15
- it("prefers cwd-relative paths for targets inside cwd", () => {
16
- const cwd = "/repo";
17
- expect(normalizeTarget("/repo/config/locked.json", cwd)).toBe(
18
- "config/locked.json",
19
- );
20
- });
21
- });
22
-
23
- describe("compilePolicies", () => {
24
- it("skips disabled and empty rules", () => {
25
- const policies = compilePolicies([
26
- {
27
- id: "disabled",
28
- name: "Disabled",
29
- enabled: false,
30
- patterns: [{ pattern: "*.env" }],
31
- protection: "noAccess",
32
- },
33
- { id: "empty", name: "Empty", patterns: [], protection: "noAccess" },
34
- {
35
- id: "active",
36
- name: "Active",
37
- patterns: [{ pattern: "*.env" }],
38
- protection: "readOnly",
39
- },
40
- ]);
41
-
42
- expect(policies.map((policy) => policy.id)).toEqual(["active"]);
43
- });
44
- });
45
-
46
- describe("createPolicyRules", () => {
47
- const cwd = "/repo";
48
-
49
- it("matches protected files and returns policy metadata", async () => {
50
- vol.fromJSON({ "/repo/.env": "SECRET=1" });
51
- const rule = singleRule(cwd, {
52
- id: "secrets",
53
- name: "Secrets",
54
- patterns: [{ pattern: ".env" }],
55
- protection: "noAccess",
56
- });
57
-
58
- await expect(
59
- rule.check({ kind: "file", path: join(cwd, ".env") }),
60
- ).resolves.toMatchObject({
61
- kind: "match",
62
- metadata: { ruleId: "secrets", protection: "noAccess", path: ".env" },
63
- });
64
- });
65
-
66
- it("passes allowed patterns", async () => {
67
- vol.fromJSON({ "/repo/.env.example": "SECRET=" });
68
- const rule = singleRule(cwd, {
69
- id: "secrets",
70
- name: "Secrets",
71
- patterns: [{ pattern: ".env*" }],
72
- allowedPatterns: [{ pattern: ".env.example" }],
73
- protection: "noAccess",
74
- });
75
-
76
- await expect(
77
- rule.check({ kind: "file", path: join(cwd, ".env.example") }),
78
- ).resolves.toEqual({ kind: "pass" });
79
- });
80
-
81
- it("passes missing files when onlyIfExists is true", async () => {
82
- const rule = singleRule(cwd, {
83
- id: "secrets",
84
- name: "Secrets",
85
- patterns: [{ pattern: ".env" }],
86
- protection: "noAccess",
87
- });
88
-
89
- await expect(
90
- rule.check({ kind: "file", path: join(cwd, ".env") }),
91
- ).resolves.toEqual({ kind: "pass" });
92
- });
93
-
94
- it("matches missing files when onlyIfExists is false", async () => {
95
- const rule = singleRule(cwd, {
96
- id: "secrets",
97
- name: "Secrets",
98
- patterns: [{ pattern: ".env" }],
99
- protection: "noAccess",
100
- onlyIfExists: false,
101
- });
102
-
103
- await expect(
104
- rule.check({ kind: "file", path: join(cwd, ".env") }),
105
- ).resolves.toMatchObject({ kind: "match" });
106
- });
107
- });
@@ -1,44 +0,0 @@
1
- import { join } from "node:path";
2
- import { vol } from "memfs";
3
- import { describe, expect, it } from "vitest";
4
- import { compilePolicies } from "./rules";
5
- import { extractTargets } from "./targets";
6
-
7
- describe("extractTargets", () => {
8
- it("returns direct file tool targets", async () => {
9
- await expect(
10
- extractTargets(
11
- { toolName: "read", input: { path: "config/locked.json" } },
12
- "/repo",
13
- [],
14
- ),
15
- ).resolves.toEqual(["config/locked.json"]);
16
- });
17
-
18
- it("extracts only bash targets matching configured policies", async () => {
19
- const cwd = "/repo";
20
- vol.fromJSON({
21
- "/repo/config/locked.json": "{}",
22
- "/repo/README.md": "hello",
23
- });
24
- const policies = compilePolicies([
25
- {
26
- id: "locked",
27
- name: "Locked",
28
- patterns: [{ pattern: "config/locked.json" }],
29
- protection: "readOnly",
30
- },
31
- ]);
32
-
33
- await expect(
34
- extractTargets(
35
- {
36
- toolName: "bash",
37
- input: { command: "cat README.md config/locked.json" },
38
- },
39
- cwd,
40
- policies,
41
- ),
42
- ).resolves.toEqual([join("config", "locked.json")]);
43
- });
44
- });
@@ -1,47 +0,0 @@
1
- import { homedir } from "node:os";
2
- import { describe, expect, it } from "vitest";
3
- import {
4
- createPendingGrant,
5
- isGrantTooBroad,
6
- pendingAllowedPaths,
7
- resolveAllowedPaths,
8
- } from "./grants";
9
-
10
- describe("path access grants", () => {
11
- it("resolves allowed paths relative to cwd", () => {
12
- expect(resolveAllowedPaths(["../shared", "logs/"], "/repo/app")).toEqual([
13
- "/repo/shared",
14
- "/repo/app/logs/",
15
- ]);
16
- });
17
-
18
- it("converts pending grants to absolute allowed paths", () => {
19
- expect(
20
- pendingAllowedPaths([
21
- {
22
- storagePath: "/tmp/file.txt",
23
- absolutePath: "/tmp/file.txt",
24
- scope: "memory",
25
- },
26
- {
27
- storagePath: "/tmp/logs/",
28
- absolutePath: "/tmp/logs",
29
- scope: "local",
30
- },
31
- ]),
32
- ).toEqual(["/tmp/file.txt", "/tmp/logs/"]);
33
- });
34
-
35
- it("rejects home grants as too broad", () => {
36
- expect(isGrantTooBroad(`${homedir()}/`)).toBe(true);
37
- expect(isGrantTooBroad(`${homedir()}/project`)).toBe(false);
38
- });
39
-
40
- it("creates pending grants with storage form", () => {
41
- expect(createPendingGrant("/tmp/logs", true, "local")).toEqual({
42
- absolutePath: "/tmp/logs",
43
- scope: "local",
44
- storagePath: "/tmp/logs/",
45
- });
46
- });
47
- });
@@ -1,46 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import { createPathAccessRule } from "./rules";
3
-
4
- const cwd = "/repo";
5
- const state = (allowedPaths: string[] = []) => ({
6
- cwd,
7
- mode: "block" as const,
8
- allowedPaths,
9
- hasUI: true,
10
- });
11
-
12
- describe("createPathAccessRule", () => {
13
- it("passes command actions", () => {
14
- const rule = createPathAccessRule(state());
15
- expect(rule.check({ kind: "command", command: "cat /tmp/a" })).toEqual({
16
- kind: "pass",
17
- });
18
- });
19
-
20
- it("passes files inside cwd", () => {
21
- const rule = createPathAccessRule(state());
22
- expect(rule.check({ kind: "file", path: "/repo/src/index.ts" })).toEqual({
23
- kind: "pass",
24
- });
25
- });
26
-
27
- it("matches outside files in block mode", () => {
28
- const rule = createPathAccessRule(state());
29
- expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toMatchObject(
30
- {
31
- kind: "match",
32
- metadata: {
33
- absolutePath: "/tmp/secret.txt",
34
- displayPath: "/tmp/secret.txt",
35
- },
36
- },
37
- );
38
- });
39
-
40
- it("passes explicitly allowed outside paths", () => {
41
- const rule = createPathAccessRule(state(["/tmp/"]));
42
- expect(rule.check({ kind: "file", path: "/tmp/secret.txt" })).toEqual({
43
- kind: "pass",
44
- });
45
- });
46
- });
@@ -1,40 +0,0 @@
1
- import { join } from "node:path";
2
- import { vol } from "memfs";
3
- import { describe, expect, it } from "vitest";
4
- import { targetsForTool } from "./targets";
5
-
6
- describe("targetsForTool", () => {
7
- it("resolves direct file tool targets from cwd", async () => {
8
- await expect(
9
- targetsForTool("read", { path: "README.md" }, "/repo"),
10
- ).resolves.toEqual(["/repo/README.md"]);
11
- });
12
-
13
- it("extracts bash path candidates", async () => {
14
- const cwd = "/repo";
15
- vol.fromJSON({ "/repo/README.md": "hello" });
16
-
17
- await expect(
18
- targetsForTool("bash", { command: "cat ./README.md" }, cwd),
19
- ).resolves.toEqual([join(cwd, "README.md")]);
20
- });
21
-
22
- it("does not treat awk regexes as paths", async () => {
23
- const cwd = "/repo";
24
- vol.fromJSON({ "/repo/test.txt": "aaa" });
25
-
26
- await expect(
27
- targetsForTool(
28
- "bash",
29
- { command: "awk '/aaa/{flag=1} flag{print}' ./test.txt" },
30
- cwd,
31
- ),
32
- ).resolves.toEqual([join(cwd, "test.txt")]);
33
- });
34
-
35
- it("ignores unrelated tools", async () => {
36
- await expect(
37
- targetsForTool("custom", { path: "README.md" }, "/repo"),
38
- ).resolves.toEqual([]);
39
- });
40
- });
@@ -1,132 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- createPermissionGateRule,
4
- formatAutoDenyReason,
5
- matchCommandPattern,
6
- matchesAnyCommandPattern,
7
- } from "./rules";
8
-
9
- describe("createPermissionGateRule", () => {
10
- it("passes file actions", async () => {
11
- const rule = createPermissionGateRule({
12
- patterns: [{ pattern: "rm -rf", description: "recursive delete" }],
13
- useBuiltinMatchers: false,
14
- });
15
- expect(rule.check({ kind: "file", path: "package.json" })).toEqual({
16
- kind: "pass",
17
- });
18
- });
19
-
20
- it("matches configured dangerous command patterns", async () => {
21
- const rule = createPermissionGateRule({
22
- patterns: [
23
- { pattern: "terraform destroy", description: "Destroy infra" },
24
- ],
25
- useBuiltinMatchers: false,
26
- });
27
-
28
- expect(
29
- rule.check({
30
- kind: "command",
31
- command: "terraform destroy -auto-approve",
32
- }),
33
- ).toEqual({
34
- kind: "match",
35
- reason: "Destroy infra",
36
- metadata: {
37
- command: "terraform destroy -auto-approve",
38
- description: "Destroy infra",
39
- pattern: "terraform destroy",
40
- },
41
- });
42
- });
43
-
44
- it("can use builtin dangerous command matchers", async () => {
45
- const rule = createPermissionGateRule({
46
- patterns: [],
47
- useBuiltinMatchers: true,
48
- });
49
- expect(
50
- rule.check({ kind: "command", command: "rm -rf dist" }),
51
- ).toMatchObject({ kind: "match" });
52
- });
53
- });
54
-
55
- describe("matchesAnyCommandPattern", () => {
56
- it("matches substring and regex command patterns", () => {
57
- expect(
58
- matchesAnyCommandPattern("npm publish --dry-run", [
59
- { pattern: "npm publish" },
60
- ]),
61
- ).toBe(true);
62
- expect(
63
- matchesAnyCommandPattern("DROP TABLE users", [
64
- { pattern: "^DROP TABLE", regex: true },
65
- ]),
66
- ).toBe(true);
67
- expect(
68
- matchesAnyCommandPattern("npm test", [{ pattern: "npm publish" }]),
69
- ).toBe(false);
70
- });
71
- });
72
-
73
- describe("matchCommandPattern", () => {
74
- it("returns the matched PatternConfig", () => {
75
- const patterns = [{ pattern: "npm publish" }, { pattern: "rm -rf" }];
76
- expect(matchCommandPattern("npm publish --dry-run", patterns)).toBe(
77
- patterns[0],
78
- );
79
- });
80
-
81
- it("returns the matching regex pattern", () => {
82
- const patterns = [{ pattern: "^DROP TABLE", regex: true }];
83
- expect(matchCommandPattern("DROP TABLE users", patterns)).toBe(patterns[0]);
84
- });
85
-
86
- it("returns null when no pattern matches", () => {
87
- expect(
88
- matchCommandPattern("npm test", [{ pattern: "npm publish" }]),
89
- ).toBeNull();
90
- });
91
-
92
- it("preserves description on the returned pattern", () => {
93
- const patterns = [
94
- {
95
- pattern: "python -m venv",
96
- description: "Use the project .venv instead",
97
- },
98
- ];
99
- const result = matchCommandPattern("python -m venv .venv", patterns);
100
- expect(result).not.toBeNull();
101
- expect(result?.description).toBe("Use the project .venv instead");
102
- });
103
- });
104
-
105
- describe("formatAutoDenyReason", () => {
106
- it("uses description when present", () => {
107
- expect(
108
- formatAutoDenyReason({
109
- pattern: "python -m venv",
110
- description: "Use the project .venv instead",
111
- }),
112
- ).toBe("Command auto-denied: Use the project .venv instead");
113
- });
114
-
115
- it("falls back to generic reason when description is missing", () => {
116
- expect(formatAutoDenyReason({ pattern: "python -m venv" })).toBe(
117
- "Command matched auto-deny pattern and was blocked automatically.",
118
- );
119
- });
120
-
121
- it("falls back when description is empty string", () => {
122
- expect(
123
- formatAutoDenyReason({ pattern: "python -m venv", description: "" }),
124
- ).toBe("Command matched auto-deny pattern and was blocked automatically.");
125
- });
126
-
127
- it("falls back when description is whitespace-only", () => {
128
- expect(
129
- formatAutoDenyReason({ pattern: "python -m venv", description: " " }),
130
- ).toBe("Command matched auto-deny pattern and was blocked automatically.");
131
- });
132
- });