@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/README.md +46 -3
- package/docs/defaults.md +26 -1
- package/package.json +5 -2
- package/src/commands/onboarding-command.ts +19 -2
- package/src/commands/onboarding.ts +122 -6
- package/src/commands/settings-command.ts +197 -0
- package/src/config.ts +50 -1
- package/src/hooks/index.ts +4 -2
- package/src/hooks/path-access.ts +395 -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} +35 -62
- 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.test.ts +58 -0
- package/src/utils/migration.ts +45 -0
- 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 +63 -7
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
|
|
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
|
},
|
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,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
|
+
}
|