@aliou/pi-guardrails 0.12.0 → 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.0",
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",
@@ -32,6 +32,7 @@ export function classifyCommandArgs(
32
32
  ) {
33
33
  return classifyInterpreterArgs(cmd, args);
34
34
  }
35
+ if (cmd === "go") return classifyGoArgs(args);
35
36
  if (cmd === "cut")
36
37
  return skipOptionValues(args, new Set(["-d", "--delimiter"]));
37
38
  if (cmd === "sort")
@@ -224,3 +225,73 @@ function skipOptionValues(
224
225
  }
225
226
  return out;
226
227
  }
228
+
229
+ /**
230
+ * Classify `go` subcommand arguments.
231
+ *
232
+ * Go commands take package patterns, not file paths, as positional args
233
+ * for most subcommands. Package patterns like `./...`, `pkg/...`,
234
+ * or `github.com/user/repo/...` use Go's `...` wildcard and are not
235
+ * filesystem paths.
236
+ *
237
+ * `go run` is an exception: it takes .go file operands, emitted with
238
+ * `forcePath` since bare filenames like `main.go` don't pass
239
+ * `maybePathLike`.
240
+ *
241
+ * File-valued flags (e.g. `-o`, `-modfile`, `-overlay`) are kept
242
+ * so that policy checks can still gate them.
243
+ *
244
+ * Global flags like `-C dir` are handled before subcommand detection
245
+ * so their values aren't mistaken for the subcommand.
246
+ */
247
+ function classifyGoArgs(args: string[]): ClassifiedArg[] {
248
+ const out: ClassifiedArg[] = [];
249
+ const fileFlags = new Set(["-o", "-modfile", "-overlay"]);
250
+
251
+ // Global flags that consume a value and must be skipped before
252
+ // subcommand detection. E.g. `go -C /tmp test ./...`
253
+ const globalFlagsWithValues = new Set(["-C"]);
254
+
255
+ let subcommand: string | undefined;
256
+ for (let i = 0; i < args.length; i++) {
257
+ const arg = args[i] as string;
258
+
259
+ // Handle file-valued flags
260
+ if (fileFlags.has(arg)) {
261
+ if (args[i + 1]) out.push({ token: args[++i] as string });
262
+ continue;
263
+ }
264
+
265
+ // Handle joined file flags like -o=./bin/app
266
+ if (arg.startsWith("-o=")) {
267
+ out.push({ token: arg.slice(3) });
268
+ continue;
269
+ }
270
+
271
+ // Skip global flags with values before subcommand detection
272
+ if (!subcommand && globalFlagsWithValues.has(arg)) {
273
+ i++; // skip value
274
+ continue;
275
+ }
276
+ if (!subcommand && arg.startsWith("-C=")) {
277
+ // joined form -C=/tmp
278
+ continue;
279
+ }
280
+
281
+ if (isOption(arg)) continue;
282
+
283
+ // First non-flag positional is the subcommand
284
+ if (!subcommand) {
285
+ subcommand = arg;
286
+ continue;
287
+ }
288
+
289
+ // `go run` takes .go file operands; emit with forcePath since
290
+ // bare filenames like main.go don't pass maybePathLike.
291
+ // All other subcommands take package patterns; skip them.
292
+ if (subcommand === "run" && arg.endsWith(".go")) {
293
+ out.push({ token: arg, forcePath: true });
294
+ }
295
+ }
296
+ return out;
297
+ }
@@ -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
- });