@aliou/pi-guardrails 0.10.0 → 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.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ ![banner](https://assets.aliou.me/pi-extensions/banners/pi-guardrails.png)
2
+
1
3
  # Guardrails
2
4
 
3
5
  Security hooks for Pi to reduce accidental destructive actions and secret-file access.
@@ -27,6 +29,7 @@ pi install git:github.com/aliou/pi-guardrails
27
29
 
28
30
  - **policies**: named file-protection rules with per-rule protection levels.
29
31
  - **permission-gate**: detects dangerous bash commands and asks for confirmation.
32
+ - **path-access**: restricts tool access to the current working directory with allow/ask/block modes.
30
33
  - **optional command explainer**: can call a small LLM to explain a dangerous command inline in the confirmation dialog.
31
34
 
32
35
  ## Config locations
@@ -48,7 +51,12 @@ Use `/guardrails:settings` to edit config interactively.
48
51
  "enabled": true,
49
52
  "features": {
50
53
  "policies": true,
51
- "permissionGate": true
54
+ "permissionGate": true,
55
+ "pathAccess": false
56
+ },
57
+ "pathAccess": {
58
+ "mode": "ask",
59
+ "allowedPaths": []
52
60
  },
53
61
  "policies": {
54
62
  "rules": [
@@ -121,6 +129,31 @@ Use:
121
129
 
122
130
  This starts a subagent that helps build and save one policy rule.
123
131
 
132
+ ## Path access
133
+
134
+ Restrict tool access to the current working directory. When enabled, any tool call targeting a path outside `cwd` is checked against the configured mode:
135
+
136
+ - **allow**: no restrictions
137
+ - **ask**: prompt with options to grant access (file or directory, for session or always)
138
+ - **block**: deny all outside access
139
+
140
+ ```jsonc
141
+ {
142
+ "features": { "pathAccess": true },
143
+ "pathAccess": {
144
+ "mode": "ask",
145
+ "allowedPaths": ["~/code/shared-libs/", "~/.config/myapp"]
146
+ }
147
+ }
148
+ ```
149
+
150
+ Grants are stored in project config (always) or session memory (session). The `allowedPaths` array is merged across all config scopes.
151
+
152
+ Limitations:
153
+ - Symlinks are not resolved (lexical path comparison only).
154
+ - Bash path extraction is best-effort (AST-based heuristics).
155
+ - In non-interactive mode, `ask` mode degrades to `block`.
156
+
124
157
  ## Permission gate
125
158
 
126
159
  Detects dangerous bash commands and prompts user confirmation.
@@ -161,6 +194,16 @@ Also note:
161
194
 
162
195
  - `preventBrew`, `preventPython`, `enforcePackageManager`, `packageManager` were removed from guardrails and moved to `@aliou/pi-toolchain`.
163
196
 
197
+ ## Development
198
+
199
+ ```bash
200
+ pnpm test # Run tests
201
+ pnpm test:watch # Run tests in watch mode
202
+ pnpm typecheck # Type check
203
+ pnpm lint # Lint
204
+ pnpm format # Format
205
+ ```
206
+
164
207
  ## Events
165
208
 
166
209
  Guardrails emits events for other extensions:
@@ -169,7 +212,7 @@ Guardrails emits events for other extensions:
169
212
 
170
213
  ```ts
171
214
  interface GuardrailsBlockedEvent {
172
- feature: "policies" | "permissionGate";
215
+ feature: "policies" | "permissionGate" | "pathAccess";
173
216
  toolName: string;
174
217
  input: Record<string, unknown>;
175
218
  reason: string;
@@ -185,4 +228,4 @@ interface GuardrailsDangerousEvent {
185
228
  description: string;
186
229
  pattern: string;
187
230
  }
188
- ```
231
+ ```
package/docs/defaults.md CHANGED
@@ -101,6 +101,31 @@ Blocks access to GPG/GnuPG private keys, keyrings, and configuration. Disabled b
101
101
 
102
102
  ---
103
103
 
104
+ ## Path Access
105
+
106
+ | Setting | Default |
107
+ |---|---|
108
+ | `features.pathAccess` | `false` |
109
+ | `pathAccess.mode` | `"ask"` |
110
+ | `pathAccess.allowedPaths` | `[]` |
111
+
112
+ Modes:
113
+ - `allow` — no path restrictions
114
+ - `ask` — prompt when accessing paths outside working directory
115
+ - `block` — deny all access outside working directory
116
+
117
+ Allowed paths use trailing-slash convention:
118
+ - `/path/to/file` — exact file match
119
+ - `/path/to/dir/` — directory and all descendants
120
+ - Supports `~/` for home directory
121
+
122
+ Limitations:
123
+ - Bash path extraction is best-effort (AST-based heuristics). Tokens like `application/json` may trigger false-positive prompts.
124
+ - Symlinks are not resolved. Lexical path comparison only.
125
+ - In non-interactive mode (--print), `ask` mode degrades to `block`.
126
+
127
+ ---
128
+
104
129
  ## Default Permission Gate Patterns
105
130
 
106
131
  These commands are detected using AST-based structural matching for accuracy.
@@ -109,7 +134,7 @@ These commands are detected using AST-based structural matching for accuracy.
109
134
  |-----------------|--------------------------------|
110
135
  | `rm -rf` | Recursive force delete |
111
136
  | `sudo` | Superuser command |
112
- | `dd if=` | Disk write operation |
137
+ | `dd of=` | Disk write operation |
113
138
  | `mkfs.` | Filesystem format |
114
139
  | `chmod -R 777` | Insecure recursive permissions |
115
140
  | `chown -R` | Recursive ownership change |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-guardrails",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -49,7 +49,8 @@
49
49
  "@sinclair/typebox": "^0.34.48",
50
50
  "@types/node": "^25.0.10",
51
51
  "husky": "^9.1.7",
52
- "typescript": "^5.9.3"
52
+ "typescript": "^5.9.3",
53
+ "vitest": "^4.1.4"
53
54
  },
54
55
  "peerDependenciesMeta": {
55
56
  "@mariozechner/pi-agent-core": {
@@ -67,6 +68,8 @@
67
68
  },
68
69
  "scripts": {
69
70
  "typecheck": "tsc --noEmit",
71
+ "test": "vitest run",
72
+ "test:watch": "vitest",
70
73
  "lint": "biome check",
71
74
  "format": "biome check --write",
72
75
  "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
@@ -10,12 +10,25 @@ import {
10
10
  function mergeOnboarding(
11
11
  base: GuardrailsConfig | null,
12
12
  applyBuiltinDefaults: boolean,
13
+ pathAccessEnabled?: boolean | null,
13
14
  ): GuardrailsConfig {
14
15
  const next = structuredClone(base ?? {});
15
- const onboarded = buildOnboardedConfig(applyBuiltinDefaults);
16
+ const onboarded = buildOnboardedConfig(
17
+ applyBuiltinDefaults,
18
+ pathAccessEnabled,
19
+ );
16
20
  next.applyBuiltinDefaults = onboarded.applyBuiltinDefaults;
17
21
  next.version = onboarded.version;
18
22
  next.onboarding = onboarded.onboarding;
23
+ if (onboarded.features?.pathAccess !== undefined) {
24
+ next.features = {
25
+ ...next.features,
26
+ pathAccess: onboarded.features.pathAccess,
27
+ };
28
+ }
29
+ if (onboarded.pathAccess) {
30
+ next.pathAccess = onboarded.pathAccess;
31
+ }
19
32
  return next;
20
33
  }
21
34
 
@@ -48,7 +61,11 @@ export function registerGuardrailsOnboardingCommand(
48
61
  return;
49
62
  }
50
63
 
51
- const merged = mergeOnboarding(globalConfig, result.applyBuiltinDefaults);
64
+ const merged = mergeOnboarding(
65
+ globalConfig,
66
+ result.applyBuiltinDefaults,
67
+ result.pathAccessEnabled,
68
+ );
52
69
  await configLoader.save("global", merged);
53
70
  await configLoader.load();
54
71
 
@@ -11,11 +11,13 @@ import { CURRENT_VERSION } from "../utils/migration";
11
11
 
12
12
  interface OnboardingState {
13
13
  applyBuiltinDefaults: boolean | null;
14
+ pathAccessEnabled: boolean | null;
14
15
  }
15
16
 
16
17
  export interface OnboardingResult {
17
18
  completed: boolean;
18
19
  applyBuiltinDefaults: boolean | null;
20
+ pathAccessEnabled: boolean | null;
19
21
  }
20
22
 
21
23
  class IntroStep implements Component {
@@ -68,7 +70,7 @@ class DefaultsChoiceStep implements Component {
68
70
  "",
69
71
  "- Protect secret files like `.env`, `.env.local`, `.env.production`, and `.dev.vars`",
70
72
  "- Keep safe exceptions like `.env.example` and `*.sample.env`",
71
- "- Require confirmation before running dangerous commands like `rm -rf`, `sudo`, and `dd if=`",
73
+ "- Require confirmation before running dangerous commands like `rm -rf`, `sudo`, and `dd of=`",
72
74
  ].join("\n"),
73
75
  [
74
76
  "Start with no built-in file policy defaults.",
@@ -129,6 +131,88 @@ class DefaultsChoiceStep implements Component {
129
131
  }
130
132
  }
131
133
 
134
+ class PathAccessStep implements Component {
135
+ private selectedIndex = 0;
136
+ private readonly settingsTheme: SettingsTheme;
137
+
138
+ constructor(
139
+ private readonly theme: Theme,
140
+ private readonly state: OnboardingState,
141
+ private readonly onSelect: () => void,
142
+ ) {
143
+ this.settingsTheme = getSettingsTheme(theme);
144
+ }
145
+
146
+ invalidate() {}
147
+
148
+ render(width: number): string[] {
149
+ const options = ["Ask before accessing outside files", "No restrictions"];
150
+ const explanations = [
151
+ [
152
+ "When enabled, guardrails will prompt you before the agent accesses files outside the current working directory.",
153
+ "",
154
+ "- You can grant access per-file or per-directory",
155
+ "- Grants can be session-only or permanent",
156
+ "- In non-interactive mode, outside access is blocked",
157
+ ].join("\n"),
158
+ [
159
+ "The agent can access any path on your system without prompting.",
160
+ "",
161
+ "- You can enable path access later in `/guardrails:settings`",
162
+ ].join("\n"),
163
+ ];
164
+
165
+ const lines: string[] = [
166
+ " Restrict access to your project directory?",
167
+ "",
168
+ ];
169
+
170
+ for (let i = 0; i < options.length; i++) {
171
+ const option = options[i];
172
+ if (!option) continue;
173
+ const selected = i === this.selectedIndex;
174
+ const prefix = selected ? this.settingsTheme.cursor : " ";
175
+ const label = this.settingsTheme.value(` ${option}`, selected);
176
+ lines.push(`${prefix}${label}`);
177
+ }
178
+
179
+ lines.push("");
180
+
181
+ const explanationBox = new Box(1, 0, (s: string) => s);
182
+ explanationBox.addChild(
183
+ new Markdown(
184
+ explanations[this.selectedIndex] ?? "",
185
+ 0,
186
+ 0,
187
+ getMarkdownTheme(),
188
+ {
189
+ color: (s: string) => this.theme.fg("text", s),
190
+ },
191
+ ),
192
+ );
193
+
194
+ lines.push(...explanationBox.render(Math.max(1, width)));
195
+
196
+ return lines;
197
+ }
198
+
199
+ handleInput(data: string): void {
200
+ if (matchesKey(data, Key.up) || data === "k") {
201
+ this.selectedIndex = this.selectedIndex === 0 ? 1 : 0;
202
+ return;
203
+ }
204
+ if (matchesKey(data, Key.down) || data === "j") {
205
+ this.selectedIndex = this.selectedIndex === 1 ? 0 : 1;
206
+ return;
207
+ }
208
+
209
+ if (matchesKey(data, Key.enter)) {
210
+ this.state.pathAccessEnabled = this.selectedIndex === 0;
211
+ this.onSelect();
212
+ }
213
+ }
214
+ }
215
+
132
216
  class FinishStep implements Component {
133
217
  private readonly recapMarkdown = new Markdown("", 2, 0, getMarkdownTheme());
134
218
 
@@ -142,7 +226,7 @@ class FinishStep implements Component {
142
226
  }
143
227
 
144
228
  render(width: number): string[] {
145
- const content =
229
+ const defaultsPart =
146
230
  this.state.applyBuiltinDefaults === true
147
231
  ? [
148
232
  "You selected **Recommended defaults**.",
@@ -150,7 +234,7 @@ class FinishStep implements Component {
150
234
  "Guardrails will start with built-in protection, including:",
151
235
  "- secret files like `.env`, `.env.local`, `.env.production`, `.dev.vars`",
152
236
  "- safe exceptions like `.env.example` and `*.sample.env`",
153
- "- confirmation before running dangerous commands like `rm -rf`, `sudo`, `dd if=`",
237
+ "- confirmation before running dangerous commands like `rm -rf`, `sudo`, `dd of=`",
154
238
  ].join("\n")
155
239
  : [
156
240
  "You selected **Minimal setup**.",
@@ -160,6 +244,12 @@ class FinishStep implements Component {
160
244
  "You can configure policies later with `/guardrails:settings`.",
161
245
  ].join("\n");
162
246
 
247
+ const pathAccessPart = this.state.pathAccessEnabled
248
+ ? "\n\n**Path access**: enabled (ask mode). The agent will prompt before accessing files outside the working directory."
249
+ : "\n\n**Path access**: disabled. No path restrictions.";
250
+
251
+ const content = defaultsPart + pathAccessPart;
252
+
163
253
  this.recapMarkdown.setText(content);
164
254
  return [...this.recapMarkdown.render(Math.max(1, width)), ""];
165
255
  }
@@ -177,6 +267,7 @@ export function createOnboardingWizard(
177
267
  ): Component {
178
268
  const state: OnboardingState = {
179
269
  applyBuiltinDefaults: null,
270
+ pathAccessEnabled: null,
180
271
  };
181
272
 
182
273
  let markWelcomeComplete: (() => void) | null = null;
@@ -210,6 +301,14 @@ export function createOnboardingWizard(
210
301
  ctx.goNext();
211
302
  }),
212
303
  },
304
+ {
305
+ label: "Path access",
306
+ build: (ctx) =>
307
+ new PathAccessStep(theme, state, () => {
308
+ ctx.markComplete();
309
+ ctx.goNext();
310
+ }),
311
+ },
213
312
  {
214
313
  label: "Recap",
215
314
  build: (ctx) =>
@@ -219,21 +318,32 @@ export function createOnboardingWizard(
219
318
  finalize({
220
319
  completed: true,
221
320
  applyBuiltinDefaults: state.applyBuiltinDefaults,
321
+ pathAccessEnabled: state.pathAccessEnabled,
222
322
  });
223
323
  }),
224
324
  },
225
325
  ],
226
326
  onComplete: () => {
227
327
  if (state.applyBuiltinDefaults === null) {
228
- finalize({ completed: false, applyBuiltinDefaults: null });
328
+ finalize({
329
+ completed: false,
330
+ applyBuiltinDefaults: null,
331
+ pathAccessEnabled: null,
332
+ });
229
333
  return;
230
334
  }
231
335
  finalize({
232
336
  completed: true,
233
337
  applyBuiltinDefaults: state.applyBuiltinDefaults,
338
+ pathAccessEnabled: state.pathAccessEnabled,
234
339
  });
235
340
  },
236
- onCancel: () => finalize({ completed: false, applyBuiltinDefaults: null }),
341
+ onCancel: () =>
342
+ finalize({
343
+ completed: false,
344
+ applyBuiltinDefaults: null,
345
+ pathAccessEnabled: null,
346
+ }),
237
347
  hintSuffix: "Enter select/continue",
238
348
  minContentHeight: 12,
239
349
  });
@@ -256,8 +366,9 @@ export function createOnboardingWizard(
256
366
 
257
367
  export function buildOnboardedConfig(
258
368
  applyBuiltinDefaults: boolean,
369
+ pathAccessEnabled?: boolean | null,
259
370
  ): GuardrailsConfig {
260
- return {
371
+ const config: GuardrailsConfig = {
261
372
  version: CURRENT_VERSION,
262
373
  applyBuiltinDefaults,
263
374
  onboarding: {
@@ -266,6 +377,11 @@ export function buildOnboardedConfig(
266
377
  version: CURRENT_VERSION,
267
378
  },
268
379
  };
380
+ if (pathAccessEnabled) {
381
+ config.features = { ...config.features, pathAccess: true };
382
+ config.pathAccess = { mode: "ask" };
383
+ }
384
+ return config;
269
385
  }
270
386
 
271
387
  export function isOnboardingPending(config: GuardrailsConfig | null): boolean {
@@ -40,6 +40,10 @@ const FEATURE_UI: Record<FeatureKey, { label: string; description: string }> = {
40
40
  description:
41
41
  "Prompt for confirmation on dangerous commands (rm -rf, sudo, etc.)",
42
42
  },
43
+ pathAccess: {
44
+ label: "Path access",
45
+ description: "Restrict tool access to the current working directory",
46
+ },
43
47
  };
44
48
 
45
49
  const POLICY_EXAMPLES: Array<{
@@ -1210,6 +1214,31 @@ export function registerGuardrailsSettings(pi: ExtensionAPI): void {
1210
1214
  label: `Policies (${policyRules.length})`,
1211
1215
  items: policyItems,
1212
1216
  },
1217
+ {
1218
+ label: "Path Access",
1219
+ items: [
1220
+ {
1221
+ id: "pathAccess.mode",
1222
+ label: "Mode",
1223
+ description:
1224
+ "allow: no restrictions, ask: prompt for outside paths, block: deny all outside paths",
1225
+ currentValue: scopedConfig.pathAccess?.mode ?? "(inherited)",
1226
+ values: ["allow", "ask", "block"],
1227
+ },
1228
+ {
1229
+ id: "pathAccess.allowedPaths",
1230
+ label: "Allowed paths",
1231
+ description:
1232
+ "Paths always allowed (trailing / for directories). Supports ~/",
1233
+ currentValue: count("pathAccess.allowedPaths"),
1234
+ submenu: patternConfigSubmenu(
1235
+ "pathAccess.allowedPaths",
1236
+ "Allowed Paths",
1237
+ "file",
1238
+ ),
1239
+ },
1240
+ ],
1241
+ },
1213
1242
  {
1214
1243
  label: "Permission Gate",
1215
1244
  items: [
package/src/config.ts CHANGED
@@ -53,6 +53,13 @@ export interface PolicyRule {
53
53
  enabled?: boolean;
54
54
  }
55
55
 
56
+ export type PathAccessMode = "allow" | "ask" | "block";
57
+
58
+ export interface PathAccessConfig {
59
+ mode?: PathAccessMode;
60
+ allowedPaths?: string[];
61
+ }
62
+
56
63
  export interface GuardrailsConfig {
57
64
  version?: string;
58
65
  enabled?: boolean;
@@ -66,12 +73,14 @@ export interface GuardrailsConfig {
66
73
  features?: {
67
74
  policies?: boolean;
68
75
  permissionGate?: boolean;
76
+ pathAccess?: boolean;
69
77
  // Deprecated. Kept only for migration.
70
78
  protectEnvFiles?: boolean;
71
79
  };
72
80
  policies?: {
73
81
  rules?: PolicyRule[];
74
82
  };
83
+ pathAccess?: PathAccessConfig;
75
84
  // Deprecated. Kept only for migration.
76
85
  envFiles?: {
77
86
  protectedPatterns?: PatternConfig[];
@@ -101,10 +110,15 @@ export interface ResolvedConfig {
101
110
  features: {
102
111
  policies: boolean;
103
112
  permissionGate: boolean;
113
+ pathAccess: boolean;
104
114
  };
105
115
  policies: {
106
116
  rules: PolicyRule[];
107
117
  };
118
+ pathAccess: {
119
+ mode: PathAccessMode;
120
+ allowedPaths: string[];
121
+ };
108
122
  permissionGate: {
109
123
  patterns: DangerousPattern[];
110
124
  /** When true, use hardcoded structural matchers for built-in patterns.
@@ -199,6 +213,11 @@ const DEFAULT_CONFIG: ResolvedConfig = {
199
213
  features: {
200
214
  policies: true,
201
215
  permissionGate: true,
216
+ pathAccess: false,
217
+ },
218
+ pathAccess: {
219
+ mode: "ask",
220
+ allowedPaths: [],
202
221
  },
203
222
  policies: {
204
223
  rules: [
@@ -277,13 +296,24 @@ const DEFAULT_CONFIG: ResolvedConfig = {
277
296
  patterns: [
278
297
  { pattern: "rm -rf", description: "recursive force delete" },
279
298
  { pattern: "sudo", description: "superuser command" },
280
- { pattern: "dd if=", description: "disk write operation" },
299
+ { pattern: "dd of=", description: "disk write operation" },
281
300
  { pattern: "mkfs.", description: "filesystem format" },
282
301
  {
283
302
  pattern: "chmod -R 777",
284
303
  description: "insecure recursive permissions",
285
304
  },
286
305
  { pattern: "chown -R", description: "recursive ownership change" },
306
+ { pattern: "doas", description: "privileged command execution" },
307
+ { pattern: "pkexec", description: "privileged command execution" },
308
+ { pattern: "shred", description: "secure file overwrite" },
309
+ { pattern: "wipefs", description: "filesystem signature wipe" },
310
+ { pattern: "blkdiscard", description: "block device discard" },
311
+ { pattern: "fdisk", description: "disk partitioning" },
312
+ { pattern: "parted", description: "disk partitioning" },
313
+ {
314
+ pattern: "docker run --privileged",
315
+ description: "container with privileged mode",
316
+ },
287
317
  ],
288
318
  useBuiltinMatchers: true,
289
319
  requireConfirmation: true,
@@ -337,6 +367,19 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
337
367
  resolved.permissionGate.patterns = customPatterns;
338
368
  resolved.permissionGate.useBuiltinMatchers = false;
339
369
  }
370
+ // Merge allowedPaths across scopes (additive)
371
+ const mergedPaths = new Set<string>();
372
+ for (const paths of [
373
+ global?.pathAccess?.allowedPaths,
374
+ local?.pathAccess?.allowedPaths,
375
+ memory?.pathAccess?.allowedPaths,
376
+ ]) {
377
+ if (paths) {
378
+ for (const p of paths) mergedPaths.add(p);
379
+ }
380
+ }
381
+ resolved.pathAccess.allowedPaths = [...mergedPaths];
382
+
340
383
  return resolved;
341
384
  },
342
385
  },
@@ -1,9 +1,11 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import type { ResolvedConfig } from "../config";
3
+ import { setupPathAccessHook } from "./path-access";
3
4
  import { setupPermissionGateHook } from "./permission-gate";
4
5
  import { setupPoliciesHook } from "./policies";
5
6
 
6
7
  export function setupGuardrailsHooks(pi: ExtensionAPI, config: ResolvedConfig) {
7
- setupPoliciesHook(pi, config);
8
- setupPermissionGateHook(pi, config);
8
+ setupPathAccessHook(pi); // boundary check — runs first
9
+ setupPoliciesHook(pi, config); // policy rules — runs second
10
+ setupPermissionGateHook(pi, config); // dangerous commands — runs third
9
11
  }