@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/README.md +51 -3
- package/docs/defaults.md +140 -0
- package/docs/examples.md +170 -0
- package/package.json +7 -3
- package/src/commands/onboarding-command.ts +76 -0
- package/src/commands/onboarding.ts +390 -0
- package/src/commands/settings-command.ts +158 -3
- package/src/config.ts +102 -3
- package/src/hooks/index.ts +4 -2
- package/src/hooks/path-access.ts +396 -0
- package/src/hooks/permission-gate/dangerous-commands.test.ts +336 -0
- package/src/hooks/permission-gate/dangerous-commands.ts +345 -0
- package/src/hooks/permission-gate/index.test.ts +332 -0
- package/src/hooks/{permission-gate.ts → permission-gate/index.ts} +275 -159
- package/src/hooks/policies.ts +20 -4
- package/src/index.ts +62 -3
- package/src/utils/bash-paths.test.ts +91 -0
- package/src/utils/bash-paths.ts +96 -0
- package/src/utils/events.ts +1 -1
- package/src/utils/migration.ts +55 -1
- package/src/utils/path-access.test.ts +154 -0
- package/src/utils/path-access.ts +62 -0
- package/src/utils/path.test.ts +177 -0
- package/src/utils/path.ts +74 -0
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
|
|
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
|
-
|
|
254
|
-
|
|
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
|
},
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
+
}
|