@aliou/pi-guardrails 0.11.2 → 0.12.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 +72 -167
- package/extensions/guardrails/commands/examples/index.ts +520 -0
- package/extensions/guardrails/commands/onboarding/config.ts +54 -0
- package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
- package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
- package/extensions/guardrails/commands/settings/examples.ts +399 -0
- package/extensions/guardrails/commands/settings/index.ts +596 -0
- package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
- package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
- package/extensions/guardrails/commands/settings/utils.ts +108 -0
- package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
- package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
- package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
- package/extensions/guardrails/components/onboarding-types.ts +10 -0
- package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
- package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
- package/extensions/guardrails/index.ts +106 -0
- package/extensions/guardrails/rules.test.ts +107 -0
- package/extensions/guardrails/rules.ts +119 -0
- package/extensions/guardrails/targets.test.ts +44 -0
- package/extensions/guardrails/targets.ts +66 -0
- package/extensions/path-access/grants.test.ts +47 -0
- package/extensions/path-access/grants.ts +68 -0
- package/extensions/path-access/index.ts +143 -0
- package/extensions/path-access/prompt.ts +196 -0
- package/extensions/path-access/rules.test.ts +46 -0
- package/extensions/path-access/rules.ts +37 -0
- package/extensions/path-access/targets.test.ts +40 -0
- package/extensions/path-access/targets.ts +19 -0
- package/extensions/permission-gate/grants.ts +21 -0
- package/extensions/permission-gate/index.ts +122 -0
- package/extensions/permission-gate/prompt.ts +222 -0
- package/extensions/permission-gate/rules.test.ts +132 -0
- package/extensions/permission-gate/rules.ts +72 -0
- package/package.json +18 -20
- package/schema.json +286 -0
- package/src/core/check.test.ts +169 -0
- package/src/core/check.ts +38 -0
- package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
- package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
- package/src/core/commands/index.ts +15 -0
- package/src/core/index.ts +13 -0
- package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
- package/src/core/paths/index.ts +14 -0
- package/src/{utils → core/shell}/command-args.test.ts +31 -20
- package/src/core/shell/index.ts +2 -0
- package/src/core/types.ts +55 -0
- package/src/shared/config/defaults.ts +118 -0
- package/src/shared/config/index.ts +17 -0
- package/src/shared/config/loader.ts +64 -0
- package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
- package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
- package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
- package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
- package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
- package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
- package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
- package/src/shared/config/migration/index.ts +44 -0
- package/src/shared/config/migration/version.ts +7 -0
- package/src/shared/config/types.ts +141 -0
- package/src/shared/events.ts +100 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/matching.test.ts +86 -0
- package/src/{utils → shared}/matching.ts +4 -4
- package/src/{utils → shared/paths}/bash-paths.test.ts +11 -2
- package/src/{utils → shared/paths}/bash-paths.ts +4 -4
- package/src/shared/paths/index.ts +1 -0
- package/src/shared/warnings.ts +17 -0
- package/docs/defaults.md +0 -140
- package/docs/examples.md +0 -170
- package/src/commands/onboarding.ts +0 -390
- package/src/commands/settings-command.ts +0 -1616
- package/src/config.ts +0 -392
- package/src/hooks/index.ts +0 -11
- package/src/hooks/path-access.ts +0 -395
- package/src/hooks/permission-gate/index.test.ts +0 -332
- package/src/hooks/permission-gate/index.ts +0 -595
- package/src/hooks/policies.ts +0 -322
- package/src/index.ts +0 -96
- package/src/lib/executor.ts +0 -280
- package/src/lib/index.ts +0 -16
- package/src/lib/model-resolver.ts +0 -47
- package/src/lib/timing.ts +0 -42
- package/src/lib/types.ts +0 -115
- package/src/utils/events.ts +0 -32
- package/src/utils/migration.test.ts +0 -58
- package/src/utils/migration.ts +0 -340
- package/src/utils/warnings.ts +0 -7
- /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
- /package/src/{utils → core/paths}/path.test.ts +0 -0
- /package/src/{utils → core/paths}/path.ts +0 -0
- /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
- /package/src/{utils → core/shell}/command-args.ts +0 -0
- /package/src/{utils/glob-expander.ts → shared/glob.ts} +0 -0
|
@@ -1,332 +0,0 @@
|
|
|
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
|
-
});
|