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