@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.
Files changed (94) hide show
  1. package/README.md +72 -167
  2. package/extensions/guardrails/commands/examples/index.ts +520 -0
  3. package/extensions/guardrails/commands/onboarding/config.ts +54 -0
  4. package/{src/commands/onboarding-command.ts → extensions/guardrails/commands/onboarding/index.ts} +5 -31
  5. package/extensions/guardrails/commands/settings/add-rule-wizard.ts +267 -0
  6. package/extensions/guardrails/commands/settings/examples.ts +399 -0
  7. package/extensions/guardrails/commands/settings/index.ts +596 -0
  8. package/extensions/guardrails/commands/settings/path-list-editor.ts +158 -0
  9. package/extensions/guardrails/commands/settings/scope-picker-submenu.ts +69 -0
  10. package/extensions/guardrails/commands/settings/utils.ts +108 -0
  11. package/extensions/guardrails/components/onboarding-choice-step.ts +140 -0
  12. package/extensions/guardrails/components/onboarding-finish-step.ts +50 -0
  13. package/extensions/guardrails/components/onboarding-intro-step.ts +30 -0
  14. package/extensions/guardrails/components/onboarding-types.ts +10 -0
  15. package/extensions/guardrails/components/onboarding-wizard.ts +116 -0
  16. package/{src → extensions/guardrails}/components/pattern-editor.ts +11 -10
  17. package/extensions/guardrails/index.ts +106 -0
  18. package/extensions/guardrails/rules.test.ts +107 -0
  19. package/extensions/guardrails/rules.ts +119 -0
  20. package/extensions/guardrails/targets.test.ts +44 -0
  21. package/extensions/guardrails/targets.ts +66 -0
  22. package/extensions/path-access/grants.test.ts +47 -0
  23. package/extensions/path-access/grants.ts +68 -0
  24. package/extensions/path-access/index.ts +143 -0
  25. package/extensions/path-access/prompt.ts +196 -0
  26. package/extensions/path-access/rules.test.ts +46 -0
  27. package/extensions/path-access/rules.ts +37 -0
  28. package/extensions/path-access/targets.test.ts +40 -0
  29. package/extensions/path-access/targets.ts +19 -0
  30. package/extensions/permission-gate/grants.ts +21 -0
  31. package/extensions/permission-gate/index.ts +122 -0
  32. package/extensions/permission-gate/prompt.ts +222 -0
  33. package/extensions/permission-gate/rules.test.ts +132 -0
  34. package/extensions/permission-gate/rules.ts +72 -0
  35. package/package.json +18 -20
  36. package/schema.json +286 -0
  37. package/src/core/check.test.ts +169 -0
  38. package/src/core/check.ts +38 -0
  39. package/src/{hooks/permission-gate/dangerous-commands.test.ts → core/commands/dangerous.test.ts} +134 -2
  40. package/src/{hooks/permission-gate/dangerous-commands.ts → core/commands/dangerous.ts} +119 -1
  41. package/src/core/commands/index.ts +15 -0
  42. package/src/core/index.ts +13 -0
  43. package/src/{utils/path-access.test.ts → core/paths/access.test.ts} +1 -5
  44. package/src/core/paths/index.ts +14 -0
  45. package/src/{utils → core/shell}/command-args.test.ts +31 -20
  46. package/src/core/shell/index.ts +2 -0
  47. package/src/core/types.ts +55 -0
  48. package/src/shared/config/defaults.ts +118 -0
  49. package/src/shared/config/index.ts +17 -0
  50. package/src/shared/config/loader.ts +64 -0
  51. package/src/shared/config/migration/001-v0-format-upgrade.ts +107 -0
  52. package/src/shared/config/migration/002-strip-toolchain-fields.ts +39 -0
  53. package/src/shared/config/migration/003-strip-command-explainer-fields.ts +42 -0
  54. package/src/shared/config/migration/004-env-files-to-policies.ts +87 -0
  55. package/src/shared/config/migration/005-normalize-allowed-paths.ts +43 -0
  56. package/src/shared/config/migration/006-apply-builtin-defaults.ts +19 -0
  57. package/src/shared/config/migration/007-mark-onboarding-done.ts +25 -0
  58. package/src/shared/config/migration/index.ts +44 -0
  59. package/src/shared/config/migration/version.ts +7 -0
  60. package/src/shared/config/types.ts +141 -0
  61. package/src/shared/events.ts +100 -0
  62. package/src/shared/index.ts +6 -0
  63. package/src/shared/matching.test.ts +86 -0
  64. package/src/{utils → shared}/matching.ts +4 -4
  65. package/src/{utils → shared/paths}/bash-paths.test.ts +11 -2
  66. package/src/{utils → shared/paths}/bash-paths.ts +4 -4
  67. package/src/shared/paths/index.ts +1 -0
  68. package/src/shared/warnings.ts +17 -0
  69. package/docs/defaults.md +0 -140
  70. package/docs/examples.md +0 -170
  71. package/src/commands/onboarding.ts +0 -390
  72. package/src/commands/settings-command.ts +0 -1616
  73. package/src/config.ts +0 -392
  74. package/src/hooks/index.ts +0 -11
  75. package/src/hooks/path-access.ts +0 -395
  76. package/src/hooks/permission-gate/index.test.ts +0 -332
  77. package/src/hooks/permission-gate/index.ts +0 -595
  78. package/src/hooks/policies.ts +0 -322
  79. package/src/index.ts +0 -96
  80. package/src/lib/executor.ts +0 -280
  81. package/src/lib/index.ts +0 -16
  82. package/src/lib/model-resolver.ts +0 -47
  83. package/src/lib/timing.ts +0 -42
  84. package/src/lib/types.ts +0 -115
  85. package/src/utils/events.ts +0 -32
  86. package/src/utils/migration.test.ts +0 -58
  87. package/src/utils/migration.ts +0 -340
  88. package/src/utils/warnings.ts +0 -7
  89. /package/src/{utils/path-access.ts → core/paths/access.ts} +0 -0
  90. /package/src/{utils → core/paths}/path.test.ts +0 -0
  91. /package/src/{utils → core/paths}/path.ts +0 -0
  92. /package/src/{utils/shell-utils.ts → core/shell/ast.ts} +0 -0
  93. /package/src/{utils → core/shell}/command-args.ts +0 -0
  94. /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
- });