@aliou/pi-guardrails 0.9.5 → 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/src/config.ts CHANGED
@@ -53,18 +53,34 @@ 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
+ /** Deprecated-defaults bridge: when true, applies built-in policy defaults. */
67
+ applyBuiltinDefaults?: boolean;
68
+ onboarding?: {
69
+ completed?: boolean;
70
+ completedAt?: string;
71
+ version?: string;
72
+ };
59
73
  features?: {
60
74
  policies?: boolean;
61
75
  permissionGate?: boolean;
76
+ pathAccess?: boolean;
62
77
  // Deprecated. Kept only for migration.
63
78
  protectEnvFiles?: boolean;
64
79
  };
65
80
  policies?: {
66
81
  rules?: PolicyRule[];
67
82
  };
83
+ pathAccess?: PathAccessConfig;
68
84
  // Deprecated. Kept only for migration.
69
85
  envFiles?: {
70
86
  protectedPatterns?: PatternConfig[];
@@ -90,13 +106,19 @@ export interface GuardrailsConfig {
90
106
  export interface ResolvedConfig {
91
107
  version: string;
92
108
  enabled: boolean;
109
+ applyBuiltinDefaults: boolean;
93
110
  features: {
94
111
  policies: boolean;
95
112
  permissionGate: boolean;
113
+ pathAccess: boolean;
96
114
  };
97
115
  policies: {
98
116
  rules: PolicyRule[];
99
117
  };
118
+ pathAccess: {
119
+ mode: PathAccessMode;
120
+ allowedPaths: string[];
121
+ };
100
122
  permissionGate: {
101
123
  patterns: DangerousPattern[];
102
124
  /** When true, use hardcoded structural matchers for built-in patterns.
@@ -187,9 +209,15 @@ const migrations: Migration<GuardrailsConfig>[] = [
187
209
  const DEFAULT_CONFIG: ResolvedConfig = {
188
210
  version: CURRENT_VERSION,
189
211
  enabled: true,
212
+ applyBuiltinDefaults: true,
190
213
  features: {
191
214
  policies: true,
192
215
  permissionGate: true,
216
+ pathAccess: false,
217
+ },
218
+ pathAccess: {
219
+ mode: "ask",
220
+ allowedPaths: [],
193
221
  },
194
222
  policies: {
195
223
  rules: [
@@ -217,19 +245,75 @@ const DEFAULT_CONFIG: ResolvedConfig = {
217
245
  "Accessing {file} is not allowed. This file contains secrets. " +
218
246
  "Explain to the user why you want to access this file, and if changes are needed ask the user to make them.",
219
247
  },
248
+ {
249
+ id: "home-ssh",
250
+ description: "SSH directory and keys",
251
+ enabled: false,
252
+ patterns: [
253
+ { pattern: "~/.ssh/**" },
254
+ { pattern: "~/.ssh/*_rsa" },
255
+ { pattern: "~/.ssh/*_ed25519" },
256
+ { pattern: "~/.ssh/*.pem" },
257
+ ],
258
+ allowedPatterns: [{ pattern: "~/.ssh/*.pub" }],
259
+ protection: "noAccess",
260
+ onlyIfExists: true,
261
+ blockMessage:
262
+ "Accessing {file} is not allowed. This file is part of your SSH configuration and may contain private keys or sensitive host information.",
263
+ },
264
+ {
265
+ id: "home-config",
266
+ description: "Sensitive user configuration directories",
267
+ enabled: false,
268
+ patterns: [
269
+ { pattern: "~/.config/gh/**" },
270
+ { pattern: "~/.config/gcloud/**" },
271
+ { pattern: "~/.config/op/**" },
272
+ { pattern: "~/.config/sops/**" },
273
+ ],
274
+ protection: "noAccess",
275
+ onlyIfExists: true,
276
+ blockMessage:
277
+ "Accessing {file} is not allowed. This file is in a sensitive user configuration directory and may contain credentials or tokens.",
278
+ },
279
+ {
280
+ id: "home-gpg",
281
+ description: "GPG keys and configuration",
282
+ enabled: false,
283
+ patterns: [
284
+ { pattern: "~/.gnupg/**" },
285
+ { pattern: "~/*.gpg" },
286
+ { pattern: "~/.gpg-agent.conf" },
287
+ ],
288
+ protection: "noAccess",
289
+ onlyIfExists: true,
290
+ blockMessage:
291
+ "Accessing {file} is not allowed. This file is part of your GPG configuration and may contain private keys or trust settings.",
292
+ },
220
293
  ],
221
294
  },
222
295
  permissionGate: {
223
296
  patterns: [
224
297
  { pattern: "rm -rf", description: "recursive force delete" },
225
298
  { pattern: "sudo", description: "superuser command" },
226
- { pattern: "dd if=", description: "disk write operation" },
299
+ { pattern: "dd of=", description: "disk write operation" },
227
300
  { pattern: "mkfs.", description: "filesystem format" },
228
301
  {
229
302
  pattern: "chmod -R 777",
230
303
  description: "insecure recursive permissions",
231
304
  },
232
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
+ },
233
317
  ],
234
318
  useBuiltinMatchers: true,
235
319
  requireConfirmation: true,
@@ -250,8 +334,10 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
250
334
  afterMerge: (resolved, global, local, memory) => {
251
335
  const ruleMap = new Map<string, PolicyRule>();
252
336
 
253
- for (const rule of DEFAULT_CONFIG.policies.rules) {
254
- ruleMap.set(rule.id, rule);
337
+ if (resolved.applyBuiltinDefaults) {
338
+ for (const rule of DEFAULT_CONFIG.policies.rules) {
339
+ ruleMap.set(rule.id, rule);
340
+ }
255
341
  }
256
342
  if (global?.policies?.rules) {
257
343
  for (const rule of global.policies.rules) {
@@ -281,6 +367,19 @@ export const configLoader = new ConfigLoader<GuardrailsConfig, ResolvedConfig>(
281
367
  resolved.permissionGate.patterns = customPatterns;
282
368
  resolved.permissionGate.useBuiltinMatchers = false;
283
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
+
284
383
  return resolved;
285
384
  },
286
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
  }
@@ -0,0 +1,396 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname } from "node:path";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import {
5
+ Container,
6
+ Key,
7
+ matchesKey,
8
+ Spacer,
9
+ Text,
10
+ visibleWidth,
11
+ } from "@mariozechner/pi-tui";
12
+ import { configLoader } from "../config";
13
+ import { extractBashPathCandidates } from "../utils/bash-paths";
14
+ import { emitBlocked } from "../utils/events";
15
+ import {
16
+ normalizeForDisplay,
17
+ resolveFromCwd,
18
+ toStorageForm,
19
+ } from "../utils/path";
20
+ import { checkPathAccess, type PathAccessState } from "../utils/path-access";
21
+
22
+ // Grant result type from the UI prompt
23
+ type PromptResult =
24
+ | "allow-file-once"
25
+ | "allow-dir-once"
26
+ | "allow-file-session"
27
+ | "allow-dir-session"
28
+ | "allow-file-always"
29
+ | "allow-dir-always"
30
+ | "deny";
31
+
32
+ // Pending grant to be persisted after all targets pass
33
+ interface PendingGrant {
34
+ storagePath: string; // in storage form (~/..., trailing / for dirs)
35
+ scope: "memory" | "local";
36
+ absolutePath: string; // for in-loop matching
37
+ }
38
+
39
+ /**
40
+ * Resolve allowedPaths from config to absolute paths, preserving trailing-slash convention.
41
+ */
42
+ function resolveAllowedPaths(allowedPaths: string[], cwd: string): string[] {
43
+ return allowedPaths.map((p) => {
44
+ const isDir = p.endsWith("/");
45
+ const resolved = resolveFromCwd(isDir ? p.slice(0, -1) : p, cwd);
46
+ return isDir ? `${resolved}/` : resolved;
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Check if a grant path would be too broad (/ or home directory).
52
+ */
53
+ function isGrantTooBroad(absPath: string): boolean {
54
+ const home = homedir();
55
+ const normalized = absPath.replace(/[\\/]+$/, "");
56
+ return normalized === "/" || normalized === home;
57
+ }
58
+
59
+ /**
60
+ * Collapse home directory to ~ for display.
61
+ */
62
+ function displayCwd(cwd: string): string {
63
+ const home = homedir();
64
+ if (cwd === home) return "~";
65
+ if (cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
66
+ return `~${cwd.slice(home.length)}`;
67
+ }
68
+ return cwd;
69
+ }
70
+
71
+ interface PromptOption {
72
+ label: string;
73
+ result: PromptResult;
74
+ }
75
+
76
+ const FILE_OPTIONS: PromptOption[] = [
77
+ { label: "Allow once", result: "allow-file-once" },
78
+ { label: "Allow file this session", result: "allow-file-session" },
79
+ { label: "Allow file always", result: "allow-file-always" },
80
+ { label: "Allow directory this session", result: "allow-dir-session" },
81
+ { label: "Allow directory always", result: "allow-dir-always" },
82
+ { label: "Deny", result: "deny" },
83
+ ];
84
+
85
+ const DIR_OPTIONS: PromptOption[] = [
86
+ { label: "Allow once", result: "allow-dir-once" },
87
+ { label: "Allow directory this session", result: "allow-dir-session" },
88
+ { label: "Allow directory always", result: "allow-dir-always" },
89
+ { label: "Deny", result: "deny" },
90
+ ];
91
+
92
+ /**
93
+ * Build the confirmation UI component.
94
+ * For directory-oriented tools (ls, find): only directory grant options.
95
+ * For file tools and bash: both file and directory options.
96
+ * Options rendered as highlighted tabs (selected = accent bg, unselected = dim),
97
+ * navigable with ←/→/Tab/Shift+Tab.
98
+ */
99
+ function createPromptComponent(
100
+ toolName: string,
101
+ displayPath: string,
102
+ displayDir: string,
103
+ cwd: string,
104
+ showFileOptions: boolean,
105
+ ) {
106
+ return (
107
+ tui: { terminal: { columns: number }; requestRender(): void },
108
+ theme: {
109
+ fg(color: string, text: string): string;
110
+ bg(color: string, text: string): string;
111
+ bold(text: string): string;
112
+ },
113
+ _kb: unknown,
114
+ done: (result: PromptResult) => void,
115
+ ) => {
116
+ const options = showFileOptions ? FILE_OPTIONS : DIR_OPTIONS;
117
+ let selectedIndex = 0;
118
+
119
+ const container = new Container();
120
+ const border = (s: string) => theme.fg("warning", s);
121
+ const cwdDisplay = displayCwd(cwd);
122
+
123
+ container.addChild(
124
+ new Text(
125
+ theme.fg("warning", theme.bold("Outside Workspace Access")),
126
+ 1,
127
+ 0,
128
+ ),
129
+ );
130
+ container.addChild(new Spacer(1));
131
+ container.addChild(
132
+ new Text(
133
+ theme.fg(
134
+ "text",
135
+ `\`${toolName}\` targets a path outside the working directory.`,
136
+ ),
137
+ 1,
138
+ 0,
139
+ ),
140
+ );
141
+ container.addChild(new Spacer(1));
142
+ container.addChild(
143
+ new Text(theme.fg("dim", ` Cwd: ${cwdDisplay}`), 1, 0),
144
+ );
145
+ container.addChild(
146
+ new Text(theme.fg("dim", ` Path: ${displayPath}`), 1, 0),
147
+ );
148
+ container.addChild(
149
+ new Text(theme.fg("dim", ` Dir: ${displayDir}`), 1, 0),
150
+ );
151
+ container.addChild(new Spacer(1));
152
+
153
+ // Dynamically rendered option lines
154
+ const optionLines: Text[] = options.map(() => new Text("", 1, 0));
155
+ for (const line of optionLines) {
156
+ container.addChild(line);
157
+ }
158
+
159
+ container.addChild(new Spacer(1));
160
+ container.addChild(
161
+ new Text(
162
+ theme.fg("dim", "↑/↓/Tab select · Enter select · Esc deny"),
163
+ 1,
164
+ 0,
165
+ ),
166
+ );
167
+
168
+ const renderOptions = () => {
169
+ for (let i = 0; i < options.length; i++) {
170
+ const label = options[i].label;
171
+ if (i === selectedIndex) {
172
+ optionLines[i].setText(
173
+ theme.bg("selectedBg", theme.fg("accent", ` ${label} `)),
174
+ );
175
+ } else {
176
+ optionLines[i].setText(theme.fg("dim", ` ${label} `));
177
+ }
178
+ }
179
+ };
180
+
181
+ renderOptions();
182
+
183
+ const moveSelection = (direction: number) => {
184
+ selectedIndex =
185
+ (selectedIndex + direction + options.length) % options.length;
186
+ renderOptions();
187
+ tui.requestRender();
188
+ };
189
+
190
+ return {
191
+ render: (width: number) => {
192
+ const innerWidth = Math.max(1, width - 2);
193
+ const contentWidth = Math.max(1, width - 4);
194
+ const raw = container.render(contentWidth);
195
+ const top = border(`╭${"─".repeat(innerWidth)}╮`);
196
+ const bottom = border(`╰${"─".repeat(innerWidth)}╯`);
197
+ const left = border("│");
198
+ const right = border("│");
199
+ const lines = raw.map((line) => {
200
+ const visible = visibleWidth(line);
201
+ const pad = Math.max(0, contentWidth - visible);
202
+ return `${left} ${line}${" ".repeat(pad)} ${right}`;
203
+ });
204
+ return [top, ...lines, bottom];
205
+ },
206
+ invalidate: () => container.invalidate(),
207
+ handleInput: (data: string) => {
208
+ if (
209
+ matchesKey(data, Key.up) ||
210
+ data === "k" ||
211
+ matchesKey(data, Key.shift("tab"))
212
+ ) {
213
+ moveSelection(-1);
214
+ return;
215
+ }
216
+ if (
217
+ matchesKey(data, Key.down) ||
218
+ data === "j" ||
219
+ matchesKey(data, Key.tab)
220
+ ) {
221
+ moveSelection(1);
222
+ return;
223
+ }
224
+ if (matchesKey(data, Key.enter)) {
225
+ done(options[selectedIndex].result);
226
+ return;
227
+ }
228
+ if (matchesKey(data, Key.escape)) {
229
+ done("deny");
230
+ }
231
+ },
232
+ };
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Persist a grant to the given config scope.
238
+ * Re-reads raw config before saving to avoid clobbering concurrent changes.
239
+ */
240
+ async function persistGrant(
241
+ storagePath: string,
242
+ scope: "memory" | "local",
243
+ ): Promise<void> {
244
+ const raw = (configLoader.getRawConfig(scope) ?? {}) as Record<
245
+ string,
246
+ unknown
247
+ >;
248
+ const pa = (raw.pathAccess ?? {}) as Record<string, unknown>;
249
+ const existing = Array.isArray(pa.allowedPaths)
250
+ ? (pa.allowedPaths as string[])
251
+ : [];
252
+
253
+ if (existing.includes(storagePath)) return;
254
+
255
+ await configLoader.save(scope, {
256
+ ...raw,
257
+ pathAccess: { ...pa, allowedPaths: [...existing, storagePath] },
258
+ });
259
+ }
260
+
261
+ export function setupPathAccessHook(pi: ExtensionAPI): void {
262
+ pi.on("tool_call", async (event, ctx) => {
263
+ // Read config live on every invocation
264
+ const config = configLoader.getConfig();
265
+ if (!config.features.pathAccess || config.pathAccess.mode === "allow")
266
+ return;
267
+
268
+ const toolName = event.toolName;
269
+ let absolutePaths: string[] = [];
270
+
271
+ const input = event.input as Record<string, unknown>;
272
+
273
+ if (["read", "write", "edit", "grep", "find", "ls"].includes(toolName)) {
274
+ const raw = String(input.file_path ?? input.path ?? "").trim();
275
+ if (raw) absolutePaths = [resolveFromCwd(raw, ctx.cwd)];
276
+ } else if (toolName === "bash") {
277
+ const command = String(input.command ?? "");
278
+ absolutePaths = await extractBashPathCandidates(command, ctx.cwd);
279
+ } else {
280
+ return;
281
+ }
282
+
283
+ if (absolutePaths.length === 0) return;
284
+
285
+ // Deduplicate paths
286
+ absolutePaths = [...new Set(absolutePaths)];
287
+
288
+ const pendingGrants: PendingGrant[] = [];
289
+ const isDirectoryTool = toolName === "ls" || toolName === "find";
290
+
291
+ for (const absPath of absolutePaths) {
292
+ // Build state with live config + pending grants from this loop
293
+ const resolvedAllowed = resolveAllowedPaths(
294
+ config.pathAccess.allowedPaths,
295
+ ctx.cwd,
296
+ );
297
+ const pendingAllowedPaths = pendingGrants.map((g) => {
298
+ const isDir = g.storagePath.endsWith("/");
299
+ return isDir ? `${g.absolutePath}/` : g.absolutePath;
300
+ });
301
+
302
+ const state: PathAccessState = {
303
+ cwd: ctx.cwd,
304
+ mode: config.pathAccess.mode,
305
+ allowedPaths: [...resolvedAllowed, ...pendingAllowedPaths],
306
+ hasUI: ctx.hasUI,
307
+ };
308
+
309
+ const displayPath = normalizeForDisplay(absPath, ctx.cwd);
310
+ const decision = checkPathAccess(absPath, displayPath, state);
311
+
312
+ if (decision.kind === "allow") continue;
313
+
314
+ if (decision.kind === "deny") {
315
+ emitBlocked(pi, {
316
+ feature: "pathAccess",
317
+ toolName,
318
+ input: event.input,
319
+ reason: decision.reason,
320
+ });
321
+ return { block: true, reason: decision.reason };
322
+ }
323
+
324
+ // decision.kind === "ask"
325
+ const parentDir = dirname(absPath);
326
+ const displayDir = normalizeForDisplay(parentDir, ctx.cwd);
327
+ const showFileOptions = !isDirectoryTool;
328
+
329
+ const result = await ctx.ui.custom<PromptResult>(
330
+ createPromptComponent(
331
+ toolName,
332
+ displayPath,
333
+ displayDir,
334
+ ctx.cwd,
335
+ showFileOptions,
336
+ ),
337
+ );
338
+
339
+ // Handle "once" grants: just continue, do NOT add to pending
340
+ if (result === "allow-file-once" || result === "allow-dir-once") {
341
+ continue;
342
+ }
343
+
344
+ // Handle session/always grants
345
+ if (result === "allow-file-session" || result === "allow-file-always") {
346
+ const scope = result === "allow-file-session" ? "memory" : "local";
347
+ const storage = toStorageForm(absPath, false);
348
+ pendingGrants.push({
349
+ storagePath: storage,
350
+ scope,
351
+ absolutePath: absPath,
352
+ });
353
+ continue;
354
+ }
355
+
356
+ if (result === "allow-dir-session" || result === "allow-dir-always") {
357
+ const scope = result === "allow-dir-session" ? "memory" : "local";
358
+ const dirPath = isDirectoryTool ? absPath : parentDir;
359
+
360
+ if (isGrantTooBroad(dirPath)) {
361
+ ctx.ui.notify(
362
+ `Cannot grant access to ${normalizeForDisplay(dirPath, ctx.cwd)}/ — too broad. Treating as allow once.`,
363
+ "warning",
364
+ );
365
+ continue;
366
+ }
367
+
368
+ const storage = toStorageForm(dirPath, true);
369
+ pendingGrants.push({
370
+ storagePath: storage,
371
+ scope,
372
+ absolutePath: dirPath,
373
+ });
374
+ continue;
375
+ }
376
+
377
+ // result === "deny"
378
+ const reason = "User denied access outside working directory";
379
+ emitBlocked(pi, {
380
+ feature: "pathAccess",
381
+ toolName,
382
+ input: event.input,
383
+ reason,
384
+ userDenied: true,
385
+ });
386
+ return { block: true, reason };
387
+ }
388
+
389
+ // Persist grants only after ALL targets passed
390
+ for (const grant of pendingGrants) {
391
+ await persistGrant(grant.storagePath, grant.scope);
392
+ }
393
+
394
+ return;
395
+ });
396
+ }