@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 +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 +29 -0
- package/src/config.ts +44 -1
- 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} +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/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
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BashToolCallEvent,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { createEventBus } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
8
|
+
import { createEventContext } from "../../../tests/utils/pi-context";
|
|
9
|
+
import type { ResolvedConfig } from "../../config";
|
|
10
|
+
import { configLoader } from "../../config";
|
|
11
|
+
import { setupPermissionGateHook } from "./index";
|
|
12
|
+
|
|
13
|
+
// Mock configLoader so allow-session path doesn't throw.
|
|
14
|
+
vi.mock("../../config", async (importOriginal) => {
|
|
15
|
+
const original = (await importOriginal()) as Record<string, unknown>;
|
|
16
|
+
return {
|
|
17
|
+
...original,
|
|
18
|
+
configLoader: {
|
|
19
|
+
getConfig: vi.fn(() => ({
|
|
20
|
+
permissionGate: { allowedPatterns: [] },
|
|
21
|
+
})),
|
|
22
|
+
save: vi.fn(async () => {}),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Constants — must match the production code's SELECT_* constants
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const SELECT_ALLOW_ONCE = "Allow once";
|
|
32
|
+
const SELECT_ALLOW_SESSION = "Allow for session";
|
|
33
|
+
const SELECT_DENY = "Deny";
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Helpers
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Minimal config enabling the permission gate with defaults.
|
|
41
|
+
* No custom patterns — relies on built-in structural matchers.
|
|
42
|
+
*/
|
|
43
|
+
function makeConfig(
|
|
44
|
+
overrides: Partial<ResolvedConfig["permissionGate"]> = {},
|
|
45
|
+
): ResolvedConfig {
|
|
46
|
+
return {
|
|
47
|
+
version: "1",
|
|
48
|
+
enabled: true,
|
|
49
|
+
applyBuiltinDefaults: true,
|
|
50
|
+
features: { policies: false, permissionGate: true, pathAccess: false },
|
|
51
|
+
policies: { rules: [] },
|
|
52
|
+
pathAccess: { mode: "ask", allowedPaths: [] },
|
|
53
|
+
permissionGate: {
|
|
54
|
+
patterns: [],
|
|
55
|
+
useBuiltinMatchers: true,
|
|
56
|
+
requireConfirmation: true,
|
|
57
|
+
allowedPatterns: [],
|
|
58
|
+
autoDenyPatterns: [],
|
|
59
|
+
explainCommands: false,
|
|
60
|
+
explainModel: null,
|
|
61
|
+
explainTimeout: 5000,
|
|
62
|
+
...overrides,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ToolCallHandler = (
|
|
68
|
+
event: BashToolCallEvent,
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
) => Promise<{ block: true; reason: string } | undefined>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Create a mock ExtensionAPI that captures tool_call handler registrations.
|
|
74
|
+
* Returns the mock and a function to retrieve the registered handler.
|
|
75
|
+
*/
|
|
76
|
+
function createMockPi() {
|
|
77
|
+
const handlers: ToolCallHandler[] = [];
|
|
78
|
+
const eventBus = createEventBus();
|
|
79
|
+
|
|
80
|
+
const pi = {
|
|
81
|
+
on(event: string, handler: ToolCallHandler) {
|
|
82
|
+
if (event === "tool_call") {
|
|
83
|
+
handlers.push(handler);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
events: eventBus,
|
|
87
|
+
// Stubs for any other ExtensionAPI methods that might be called.
|
|
88
|
+
registerCommand: vi.fn(),
|
|
89
|
+
registerTool: vi.fn(),
|
|
90
|
+
emit: vi.fn(),
|
|
91
|
+
} as unknown as ExtensionAPI;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
pi,
|
|
95
|
+
getHandler(): ToolCallHandler {
|
|
96
|
+
if (handlers.length === 0) {
|
|
97
|
+
throw new Error("No tool_call handler registered");
|
|
98
|
+
}
|
|
99
|
+
return handlers[0];
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function bashEvent(command: string): BashToolCallEvent {
|
|
105
|
+
return {
|
|
106
|
+
type: "tool_call",
|
|
107
|
+
toolCallId: "tc_test",
|
|
108
|
+
toolName: "bash",
|
|
109
|
+
input: { command },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Tests
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
describe("permission gate", () => {
|
|
118
|
+
let handle: ReturnType<typeof createMockPi>;
|
|
119
|
+
let handler: ToolCallHandler;
|
|
120
|
+
|
|
121
|
+
beforeEach(() => {
|
|
122
|
+
handle = createMockPi();
|
|
123
|
+
setupPermissionGateHook(handle.pi, makeConfig());
|
|
124
|
+
handler = handle.getHandler();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("allows safe commands", async () => {
|
|
128
|
+
const ctx = createEventContext({ hasUI: true });
|
|
129
|
+
const result = await handler(bashEvent("echo hello"), ctx);
|
|
130
|
+
expect(result).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("blocks dangerous commands when user denies", async () => {
|
|
134
|
+
const ctx = createEventContext({
|
|
135
|
+
hasUI: true,
|
|
136
|
+
ui: {
|
|
137
|
+
custom: vi.fn(async () => "deny") as ExtensionContext["ui"]["custom"],
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
141
|
+
expect(result).toEqual({
|
|
142
|
+
block: true,
|
|
143
|
+
reason: "User denied dangerous command",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("allows dangerous commands when user explicitly allows", async () => {
|
|
148
|
+
const ctx = createEventContext({
|
|
149
|
+
hasUI: true,
|
|
150
|
+
ui: {
|
|
151
|
+
custom: vi.fn(async () => "allow") as ExtensionContext["ui"]["custom"],
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
155
|
+
expect(result).toBeUndefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("blocks when hasUI is false (print/RPC mode)", async () => {
|
|
159
|
+
const ctx = createEventContext({ hasUI: false });
|
|
160
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
161
|
+
expect(result).toEqual(expect.objectContaining({ block: true }));
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("blocks when ctx.ui.custom() returns undefined (RPC stub)", async () => {
|
|
165
|
+
// This is the bug from issue #19: in RPC mode, ctx.ui.custom() returns
|
|
166
|
+
// undefined. The permission gate only checks for "deny", so undefined
|
|
167
|
+
// falls through and the command is silently allowed.
|
|
168
|
+
const ctx = createEventContext({
|
|
169
|
+
hasUI: true,
|
|
170
|
+
ui: {
|
|
171
|
+
custom: vi.fn(
|
|
172
|
+
async () => undefined,
|
|
173
|
+
) as ExtensionContext["ui"]["custom"],
|
|
174
|
+
select: vi.fn(
|
|
175
|
+
async () => undefined,
|
|
176
|
+
) as ExtensionContext["ui"]["select"],
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
180
|
+
expect(result).toEqual(expect.objectContaining({ block: true }));
|
|
181
|
+
expect(ctx.ui.select).toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("blocks auto-deny patterns without prompting", async () => {
|
|
185
|
+
const { pi, getHandler } = createMockPi();
|
|
186
|
+
setupPermissionGateHook(
|
|
187
|
+
pi,
|
|
188
|
+
makeConfig({
|
|
189
|
+
autoDenyPatterns: [{ pattern: "DROP TABLE" }],
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
const h = getHandler();
|
|
193
|
+
const ctx = createEventContext({ hasUI: true });
|
|
194
|
+
const result = await h(bashEvent("psql -c 'DROP TABLE users'"), ctx);
|
|
195
|
+
expect(result).toEqual(expect.objectContaining({ block: true }));
|
|
196
|
+
// Should not have prompted the user.
|
|
197
|
+
expect(ctx.ui.custom).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("skips allowed patterns", async () => {
|
|
201
|
+
const { pi, getHandler } = createMockPi();
|
|
202
|
+
setupPermissionGateHook(
|
|
203
|
+
pi,
|
|
204
|
+
makeConfig({
|
|
205
|
+
allowedPatterns: [{ pattern: "sudo echo" }],
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
const h = getHandler();
|
|
209
|
+
const ctx = createEventContext({ hasUI: true });
|
|
210
|
+
const result = await h(bashEvent("sudo echo hello"), ctx);
|
|
211
|
+
expect(result).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// RPC mode: ctx.ui.select() fallback when ctx.ui.custom() returns undefined
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
it("falls back to select() when custom() returns undefined and allows on 'Allow once'", async () => {
|
|
219
|
+
const ctx = createEventContext({
|
|
220
|
+
hasUI: true,
|
|
221
|
+
ui: {
|
|
222
|
+
custom: vi.fn(
|
|
223
|
+
async () => undefined,
|
|
224
|
+
) as ExtensionContext["ui"]["custom"],
|
|
225
|
+
select: vi.fn(
|
|
226
|
+
async () => SELECT_ALLOW_ONCE,
|
|
227
|
+
) as ExtensionContext["ui"]["select"],
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
231
|
+
expect(result).toBeUndefined(); // not blocked → allowed
|
|
232
|
+
expect(ctx.ui.select).toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("falls back to select() when custom() returns undefined and allows-session on 'Allow for session'", async () => {
|
|
236
|
+
const ctx = createEventContext({
|
|
237
|
+
hasUI: true,
|
|
238
|
+
ui: {
|
|
239
|
+
custom: vi.fn(
|
|
240
|
+
async () => undefined,
|
|
241
|
+
) as ExtensionContext["ui"]["custom"],
|
|
242
|
+
select: vi.fn(
|
|
243
|
+
async () => SELECT_ALLOW_SESSION,
|
|
244
|
+
) as ExtensionContext["ui"]["select"],
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
248
|
+
expect(result).toBeUndefined(); // not blocked → allowed with session grant
|
|
249
|
+
expect(ctx.ui.select).toHaveBeenCalled();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("falls back to select() when custom() returns undefined and blocks on 'Deny'", async () => {
|
|
253
|
+
const ctx = createEventContext({
|
|
254
|
+
hasUI: true,
|
|
255
|
+
ui: {
|
|
256
|
+
custom: vi.fn(
|
|
257
|
+
async () => undefined,
|
|
258
|
+
) as ExtensionContext["ui"]["custom"],
|
|
259
|
+
select: vi.fn(
|
|
260
|
+
async () => SELECT_DENY,
|
|
261
|
+
) as ExtensionContext["ui"]["select"],
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
265
|
+
expect(result).toEqual({
|
|
266
|
+
block: true,
|
|
267
|
+
reason: "User denied dangerous command",
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("blocks when both custom() and select() return undefined", async () => {
|
|
272
|
+
const ctx = createEventContext({
|
|
273
|
+
hasUI: true,
|
|
274
|
+
ui: {
|
|
275
|
+
custom: vi.fn(
|
|
276
|
+
async () => undefined,
|
|
277
|
+
) as ExtensionContext["ui"]["custom"],
|
|
278
|
+
select: vi.fn(
|
|
279
|
+
async () => undefined,
|
|
280
|
+
) as ExtensionContext["ui"]["select"],
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
284
|
+
expect(result).toEqual(expect.objectContaining({ block: true }));
|
|
285
|
+
expect(ctx.ui.select).toHaveBeenCalled();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("does not call select() when custom() returns a valid result", async () => {
|
|
289
|
+
const ctx = createEventContext({
|
|
290
|
+
hasUI: true,
|
|
291
|
+
ui: {
|
|
292
|
+
custom: vi.fn(async () => "deny") as ExtensionContext["ui"]["custom"],
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
296
|
+
expect(ctx.ui.select).not.toHaveBeenCalled();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("blocks when select() returns an unrecognized string", async () => {
|
|
300
|
+
const ctx = createEventContext({
|
|
301
|
+
hasUI: true,
|
|
302
|
+
ui: {
|
|
303
|
+
custom: vi.fn(
|
|
304
|
+
async () => undefined,
|
|
305
|
+
) as ExtensionContext["ui"]["custom"],
|
|
306
|
+
select: vi.fn(async () => "maybe") as ExtensionContext["ui"]["select"],
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const result = await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
310
|
+
expect(result).toEqual(expect.objectContaining({ block: true }));
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("saves session grant via configLoader when select() returns 'Allow for session'", async () => {
|
|
314
|
+
const ctx = createEventContext({
|
|
315
|
+
hasUI: true,
|
|
316
|
+
ui: {
|
|
317
|
+
custom: vi.fn(
|
|
318
|
+
async () => undefined,
|
|
319
|
+
) as ExtensionContext["ui"]["custom"],
|
|
320
|
+
select: vi.fn(
|
|
321
|
+
async () => SELECT_ALLOW_SESSION,
|
|
322
|
+
) as ExtensionContext["ui"]["select"],
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
await handler(bashEvent("sudo rm -rf /"), ctx);
|
|
326
|
+
expect(configLoader.save).toHaveBeenCalledWith("memory", {
|
|
327
|
+
permissionGate: {
|
|
328
|
+
allowedPatterns: [{ pattern: "sudo rm -rf /" }],
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
@@ -18,15 +18,19 @@ import {
|
|
|
18
18
|
visibleWidth,
|
|
19
19
|
wrapTextWithAnsi,
|
|
20
20
|
} from "@mariozechner/pi-tui";
|
|
21
|
-
import type { DangerousPattern, ResolvedConfig } from "
|
|
22
|
-
import { configLoader } from "
|
|
23
|
-
import { executeSubagent, resolveModel } from "
|
|
24
|
-
import { emitBlocked, emitDangerous } from "
|
|
21
|
+
import type { DangerousPattern, ResolvedConfig } from "../../config";
|
|
22
|
+
import { configLoader } from "../../config";
|
|
23
|
+
import { executeSubagent, resolveModel } from "../../lib";
|
|
24
|
+
import { emitBlocked, emitDangerous } from "../../utils/events";
|
|
25
25
|
import {
|
|
26
26
|
type CompiledPattern,
|
|
27
27
|
compileCommandPatterns,
|
|
28
|
-
} from "
|
|
29
|
-
import { walkCommands, wordToString } from "
|
|
28
|
+
} from "../../utils/matching";
|
|
29
|
+
import { walkCommands, wordToString } from "../../utils/shell-utils";
|
|
30
|
+
import {
|
|
31
|
+
BUILTIN_KEYWORD_PATTERNS,
|
|
32
|
+
BUILTIN_MATCHERS,
|
|
33
|
+
} from "./dangerous-commands";
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
36
|
* Permission gate that prompts user confirmation for dangerous commands.
|
|
@@ -36,53 +40,6 @@ import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
|
36
40
|
* Allowed/auto-deny patterns match against the raw command string.
|
|
37
41
|
*/
|
|
38
42
|
|
|
39
|
-
/**
|
|
40
|
-
* Structural matcher for a built-in dangerous command.
|
|
41
|
-
* Returns a description if matched, undefined otherwise.
|
|
42
|
-
*/
|
|
43
|
-
type StructuralMatcher = (words: string[]) => string | undefined;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Built-in dangerous command matchers. These check the parsed command
|
|
47
|
-
* structure instead of regex against the raw string.
|
|
48
|
-
*/
|
|
49
|
-
const BUILTIN_MATCHERS: StructuralMatcher[] = [
|
|
50
|
-
// rm -rf
|
|
51
|
-
(words) => {
|
|
52
|
-
if (words[0] !== "rm") return undefined;
|
|
53
|
-
const hasRF = words.some(
|
|
54
|
-
(w) =>
|
|
55
|
-
w === "-rf" ||
|
|
56
|
-
w === "-fr" ||
|
|
57
|
-
(w.startsWith("-") && w.includes("r") && w.includes("f")),
|
|
58
|
-
);
|
|
59
|
-
return hasRF ? "recursive force delete" : undefined;
|
|
60
|
-
},
|
|
61
|
-
// sudo
|
|
62
|
-
(words) => (words[0] === "sudo" ? "superuser command" : undefined),
|
|
63
|
-
// dd if=
|
|
64
|
-
(words) => {
|
|
65
|
-
if (words[0] !== "dd") return undefined;
|
|
66
|
-
return words.some((w) => w.startsWith("if="))
|
|
67
|
-
? "disk write operation"
|
|
68
|
-
: undefined;
|
|
69
|
-
},
|
|
70
|
-
// mkfs.*
|
|
71
|
-
(words) => (words[0]?.startsWith("mkfs.") ? "filesystem format" : undefined),
|
|
72
|
-
// chmod -R 777
|
|
73
|
-
(words) => {
|
|
74
|
-
if (words[0] !== "chmod") return undefined;
|
|
75
|
-
return words.includes("-R") && words.includes("777")
|
|
76
|
-
? "insecure recursive permissions"
|
|
77
|
-
: undefined;
|
|
78
|
-
},
|
|
79
|
-
// chown -R
|
|
80
|
-
(words) => {
|
|
81
|
-
if (words[0] !== "chown") return undefined;
|
|
82
|
-
return words.includes("-R") ? "recursive ownership change" : undefined;
|
|
83
|
-
},
|
|
84
|
-
];
|
|
85
|
-
|
|
86
43
|
interface DangerMatch {
|
|
87
44
|
description: string;
|
|
88
45
|
pattern: string;
|
|
@@ -117,14 +74,6 @@ interface CommandViewportState {
|
|
|
117
74
|
}
|
|
118
75
|
|
|
119
76
|
const COMMAND_VIEWPORT_LINES = 12;
|
|
120
|
-
const BUILTIN_KEYWORD_PATTERNS = new Set([
|
|
121
|
-
"rm -rf",
|
|
122
|
-
"sudo",
|
|
123
|
-
"dd if=",
|
|
124
|
-
"mkfs.",
|
|
125
|
-
"chmod -R 777",
|
|
126
|
-
"chown -R",
|
|
127
|
-
]);
|
|
128
77
|
|
|
129
78
|
function buildNumberedWrappedLines(
|
|
130
79
|
command: string,
|
|
@@ -580,10 +529,34 @@ export function setupPermissionGateHook(
|
|
|
580
529
|
|
|
581
530
|
type ConfirmResult = "allow" | "allow-session" | "deny";
|
|
582
531
|
|
|
583
|
-
|
|
532
|
+
// Fallback select options for RPC mode (ctx.ui.custom is unimplemented).
|
|
533
|
+
const SELECT_ALLOW_ONCE = "Allow once";
|
|
534
|
+
const SELECT_ALLOW_SESSION = "Allow for session";
|
|
535
|
+
const SELECT_DENY = "Deny";
|
|
536
|
+
const SELECT_OPTIONS = [
|
|
537
|
+
SELECT_ALLOW_ONCE,
|
|
538
|
+
SELECT_ALLOW_SESSION,
|
|
539
|
+
SELECT_DENY,
|
|
540
|
+
] as const;
|
|
541
|
+
|
|
542
|
+
let result = await ctx.ui.custom<ConfirmResult>(
|
|
584
543
|
createPermissionGateConfirmComponent(command, description, explanation),
|
|
585
544
|
);
|
|
586
545
|
|
|
546
|
+
// Fallback: ctx.ui.custom() returns undefined in RPC/headless mode
|
|
547
|
+
// (Pi's RPC runtime stubs it as `async custom() { return undefined; }`).
|
|
548
|
+
// Fall back to ctx.ui.select() which works over the RPC protocol.
|
|
549
|
+
// If select() also returns undefined/malformed, deny by default.
|
|
550
|
+
if (result === undefined) {
|
|
551
|
+
const selection = await ctx.ui.select(
|
|
552
|
+
`Dangerous command: ${description}`,
|
|
553
|
+
[...SELECT_OPTIONS],
|
|
554
|
+
);
|
|
555
|
+
if (selection === SELECT_ALLOW_ONCE) result = "allow";
|
|
556
|
+
else if (selection === SELECT_ALLOW_SESSION) result = "allow-session";
|
|
557
|
+
else result = "deny";
|
|
558
|
+
}
|
|
559
|
+
|
|
587
560
|
if (result === "allow-session") {
|
|
588
561
|
// Save command as allowed in memory scope (session-only).
|
|
589
562
|
// Spread the resolved allowed patterns and append the new one.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { extractBashPathCandidates } from "./bash-paths";
|
|
4
|
+
|
|
5
|
+
const CWD = "/work/project";
|
|
6
|
+
const HOME = homedir();
|
|
7
|
+
|
|
8
|
+
describe("extractBashPathCandidates", () => {
|
|
9
|
+
describe("when command has path arguments", () => {
|
|
10
|
+
it("extracts a single absolute path", async () => {
|
|
11
|
+
expect(await extractBashPathCandidates("cat /etc/hosts", CWD)).toEqual([
|
|
12
|
+
"/etc/hosts",
|
|
13
|
+
]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("extracts multiple absolute paths", async () => {
|
|
17
|
+
expect(await extractBashPathCandidates("cp /a /b", CWD)).toEqual([
|
|
18
|
+
"/a",
|
|
19
|
+
"/b",
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("resolves a relative path with ./ against cwd", async () => {
|
|
24
|
+
expect(await extractBashPathCandidates("cat ./foo/bar", CWD)).toEqual([
|
|
25
|
+
"/work/project/foo/bar",
|
|
26
|
+
]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("expands ~ to home", async () => {
|
|
30
|
+
expect(await extractBashPathCandidates("cat ~/file", CWD)).toEqual([
|
|
31
|
+
`${HOME}/file`,
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("detects Windows-style paths", async () => {
|
|
36
|
+
const result = await extractBashPathCandidates("type C:\\foo\\bar", CWD);
|
|
37
|
+
expect(result.length).toBeGreaterThan(0);
|
|
38
|
+
// On POSIX, resolve() treats backslash path as a single component under cwd
|
|
39
|
+
expect(result[0]).toContain("C:\\foo\\bar");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("when command has flags and redirects", () => {
|
|
44
|
+
it("ignores flag arguments", async () => {
|
|
45
|
+
expect(await extractBashPathCandidates("ls -la /tmp", CWD)).toEqual([
|
|
46
|
+
"/tmp",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("extracts redirect targets", async () => {
|
|
51
|
+
expect(
|
|
52
|
+
await extractBashPathCandidates("echo foo > /tmp/out", CWD),
|
|
53
|
+
).toEqual(["/tmp/out"]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("when command has no path-like tokens", () => {
|
|
58
|
+
it("returns an empty array for bare filenames (no separators)", async () => {
|
|
59
|
+
expect(await extractBashPathCandidates("cat README.md", CWD)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("returns an empty array for commands with no file arguments", async () => {
|
|
63
|
+
expect(await extractBashPathCandidates("echo hello", CWD)).toEqual([]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("when command uses quoting", () => {
|
|
68
|
+
it("handles quoted paths with spaces", async () => {
|
|
69
|
+
expect(
|
|
70
|
+
await extractBashPathCandidates('cat "/tmp/hello world"', CWD),
|
|
71
|
+
).toEqual(["/tmp/hello world"]);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("when command has duplicate paths", () => {
|
|
76
|
+
it("deduplicates results", async () => {
|
|
77
|
+
expect(await extractBashPathCandidates("cat /a /a", CWD)).toEqual(["/a"]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("when command is malformed", () => {
|
|
82
|
+
it("falls back to regex tokenization on parse failure", async () => {
|
|
83
|
+
// Unbalanced quote triggers parse error; regex fallback still finds paths
|
|
84
|
+
const result = await extractBashPathCandidates(
|
|
85
|
+
"cat /tmp/foo 'unterminated",
|
|
86
|
+
CWD,
|
|
87
|
+
);
|
|
88
|
+
expect(result).toContain("/tmp/foo");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { parse } from "@aliou/sh";
|
|
3
|
+
import { expandGlob, hasGlobChars } from "./glob-expander";
|
|
4
|
+
import { expandHomePath } from "./path";
|
|
5
|
+
import { walkCommands, wordToString } from "./shell-utils";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Heuristic: is this token likely a filesystem path?
|
|
9
|
+
* Intentionally conservative — only structural signals.
|
|
10
|
+
* Known false positives: "application/json", URL paths. These cause
|
|
11
|
+
* spurious prompts in ask mode but are safe (better to over-prompt than miss).
|
|
12
|
+
* Known false negatives: bare filenames without path separators (e.g. "README.md").
|
|
13
|
+
* These are usually cwd-relative and would pass the boundary check anyway.
|
|
14
|
+
*/
|
|
15
|
+
function maybePathLike(token: string): boolean {
|
|
16
|
+
if (token.includes("/")) return true;
|
|
17
|
+
if (token.includes("\\")) return true;
|
|
18
|
+
if (/^[A-Za-z]:[\\/]/.test(token)) return true;
|
|
19
|
+
if (token.startsWith("~")) return true;
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function expandCandidate(
|
|
24
|
+
candidate: string,
|
|
25
|
+
cwd: string,
|
|
26
|
+
): Promise<string[]> {
|
|
27
|
+
if (!hasGlobChars(candidate)) return [candidate];
|
|
28
|
+
const matches = await expandGlob(candidate, { cwd });
|
|
29
|
+
return matches.length > 0 ? matches : [candidate];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Extract path-like candidates from a bash command string.
|
|
34
|
+
* Returns absolute paths. Best-effort: uses AST parsing with regex fallback.
|
|
35
|
+
* Does NOT filter by any policy — returns all path-like arguments.
|
|
36
|
+
*/
|
|
37
|
+
export async function extractBashPathCandidates(
|
|
38
|
+
command: string,
|
|
39
|
+
cwd: string,
|
|
40
|
+
): Promise<string[]> {
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
const results: string[] = [];
|
|
43
|
+
|
|
44
|
+
const addCandidate = async (
|
|
45
|
+
token: string,
|
|
46
|
+
forcePath = false,
|
|
47
|
+
): Promise<void> => {
|
|
48
|
+
if (!token || token.startsWith("-")) return;
|
|
49
|
+
if (!forcePath && !maybePathLike(token)) return;
|
|
50
|
+
|
|
51
|
+
const expanded = await expandCandidate(token, cwd);
|
|
52
|
+
for (const file of expanded) {
|
|
53
|
+
const abs = resolve(cwd, expandHomePath(file));
|
|
54
|
+
if (!seen.has(abs)) {
|
|
55
|
+
seen.add(abs);
|
|
56
|
+
results.push(abs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const { ast } = parse(command);
|
|
63
|
+
const pending: Promise<void>[] = [];
|
|
64
|
+
|
|
65
|
+
walkCommands(ast, (cmd) => {
|
|
66
|
+
const words = (cmd.words ?? []).map(wordToString);
|
|
67
|
+
for (let i = 1; i < words.length; i++) {
|
|
68
|
+
pending.push(addCandidate(words[i] as string));
|
|
69
|
+
}
|
|
70
|
+
for (const redir of cmd.redirects ?? []) {
|
|
71
|
+
pending.push(addCandidate(wordToString(redir.target), true));
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await Promise.all(pending);
|
|
77
|
+
return results;
|
|
78
|
+
} catch {
|
|
79
|
+
// Fallback: regex tokenization
|
|
80
|
+
const tokenRegex = /"([^"]+)"|'([^']+)'|`([^`]+)`|([^\s"'`<>|;&]+)/g;
|
|
81
|
+
for (const match of command.matchAll(tokenRegex)) {
|
|
82
|
+
const token = match[1] ?? match[2] ?? match[3] ?? match[4] ?? "";
|
|
83
|
+
if (token && !token.startsWith("-") && maybePathLike(token)) {
|
|
84
|
+
const expanded = await expandCandidate(token, cwd);
|
|
85
|
+
for (const file of expanded) {
|
|
86
|
+
const abs = resolve(cwd, expandHomePath(file));
|
|
87
|
+
if (!seen.has(abs)) {
|
|
88
|
+
seen.add(abs);
|
|
89
|
+
results.push(abs);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return results;
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/utils/events.ts
CHANGED
|
@@ -4,7 +4,7 @@ export const GUARDRAILS_BLOCKED_EVENT = "guardrails:blocked";
|
|
|
4
4
|
export const GUARDRAILS_DANGEROUS_EVENT = "guardrails:dangerous";
|
|
5
5
|
|
|
6
6
|
export interface GuardrailsBlockedEvent {
|
|
7
|
-
feature: "policies" | "permissionGate";
|
|
7
|
+
feature: "policies" | "permissionGate" | "pathAccess";
|
|
8
8
|
toolName: string;
|
|
9
9
|
input: Record<string, unknown>;
|
|
10
10
|
reason: string;
|