@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.
@@ -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 "../config";
22
- import { configLoader } from "../config";
23
- import { executeSubagent, resolveModel } from "../lib";
24
- import { emitBlocked, emitDangerous } from "../utils/events";
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 "../utils/matching";
29
- import { walkCommands, wordToString } from "../utils/shell-utils";
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
- const result = await ctx.ui.custom<ConfirmResult>(
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
+ }
@@ -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;