@gotgenes/pi-permission-system 5.16.0 → 5.18.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.
@@ -6,6 +6,7 @@
6
6
  * permission prompts without importing this package.
7
7
  */
8
8
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { buildInputForSurface } from "./input-normalizer";
9
10
  import type {
10
11
  PermissionPromptDecision,
11
12
  RequestPermissionOptions,
@@ -79,26 +80,6 @@ function errorReply(error: string): PermissionsRpcReply {
79
80
  };
80
81
  }
81
82
 
82
- /**
83
- * Construct a surface-appropriate input object from a raw value string.
84
- *
85
- * Note: MCP inputs are complex (server name + tool name derivation). Callers
86
- * providing an MCP surface receive a best-effort policy evaluation using the
87
- * value as a pre-qualified target string. Pass the fully-qualified target
88
- * (e.g. "exa:search" or "exa") directly.
89
- */
90
- function buildInputForSurface(
91
- surface: string,
92
- value: string | undefined,
93
- ): unknown {
94
- const v = value ?? "";
95
- if (surface === "bash") return { command: v };
96
- if (surface === "skill") return { name: v };
97
- if (surface === "external_directory") return { path: v };
98
- // MCP and tool surfaces: normalizeInput handles them from the surface alone.
99
- return {};
100
- }
101
-
102
83
  // ── RPC handler: permissions:rpc:check ────────────────────────────────────
103
84
 
104
85
  function handleCheckRpc(
@@ -30,7 +30,19 @@ export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
30
30
  /** Emitted after every permission gate resolution. */
31
31
  export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
32
32
 
33
- /** RPC request channel — query the permission policy (no prompting). */
33
+ /**
34
+ * RPC request channel — query the permission policy (no prompting).
35
+ *
36
+ * @deprecated Use the `Symbol.for()`-backed service accessor instead:
37
+ * ```typescript
38
+ * const { getPermissionsService } = await import("@gotgenes/pi-permission-system");
39
+ * const service = getPermissionsService();
40
+ * if (service) {
41
+ * const result = service.checkPermission("bash", "git push");
42
+ * }
43
+ * ```
44
+ * The event-bus RPC remains available as a zero-dependency fallback.
45
+ */
34
46
  export const PERMISSIONS_RPC_CHECK_CHANNEL = "permissions:rpc:check";
35
47
 
36
48
  /** RPC request channel — forward a permission prompt to the parent UI. */
@@ -88,7 +100,12 @@ export interface PermissionDecisionEvent {
88
100
 
89
101
  // ── permissions:rpc:check ──────────────────────────────────────────────────
90
102
 
91
- /** Request payload for `permissions:rpc:check`. */
103
+ /**
104
+ * Request payload for `permissions:rpc:check`.
105
+ *
106
+ * @deprecated Prefer `getPermissionsService().checkPermission()` from the
107
+ * service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
108
+ */
92
109
  export interface PermissionsCheckRequest {
93
110
  requestId: string;
94
111
  /** Permission surface to evaluate. */
@@ -99,7 +116,12 @@ export interface PermissionsCheckRequest {
99
116
  agentName?: string;
100
117
  }
101
118
 
102
- /** Data field in a successful `permissions:rpc:check` reply. */
119
+ /**
120
+ * Data field in a successful `permissions:rpc:check` reply.
121
+ *
122
+ * @deprecated Prefer `getPermissionsService().checkPermission()` from the
123
+ * service accessor module. See `PERMISSIONS_RPC_CHECK_CHANNEL` for details.
124
+ */
103
125
  export interface PermissionsCheckReplyData {
104
126
  result: "allow" | "deny" | "ask";
105
127
  matchedPattern: string | null;
@@ -30,7 +30,7 @@ const BUILT_IN_TOOL_PERMISSION_NAMES = new Set([
30
30
  "find",
31
31
  "ls",
32
32
  ]);
33
- const SPECIAL_PERMISSION_KEYS = new Set(["external_directory"]);
33
+ const SPECIAL_PERMISSION_KEYS = new Set(["external_directory", "path"]);
34
34
 
35
35
  /** Universal fallback when permission["*"] is absent from all scopes. */
36
36
  const DEFAULT_UNIVERSAL_FALLBACK: PermissionState = "ask";
package/src/rule.ts CHANGED
@@ -79,6 +79,33 @@ export function evaluate(
79
79
  * evaluating the first candidate so the caller always receives a concrete
80
80
  * result.
81
81
  */
82
+ /**
83
+ * Evaluate a surface against multiple values, returning the most restrictive
84
+ * non-allow result (deny > ask > allow).
85
+ *
86
+ * Used by the cross-cutting `path` surface to aggregate permission decisions
87
+ * across multiple file paths extracted from a single tool call or bash command.
88
+ *
89
+ * Returns `null` when all values evaluate to `allow` (no restriction).
90
+ * Returns the first `deny` immediately (short-circuit).
91
+ * Returns the first `ask` if no `deny` is found.
92
+ */
93
+ export function evaluateMostRestrictive(
94
+ surface: string,
95
+ values: string[],
96
+ rules: Ruleset,
97
+ ): { rule: Rule; value: string } | null {
98
+ let worst: { rule: Rule; value: string } | null = null;
99
+ for (const value of values) {
100
+ const rule = evaluate(surface, value, rules);
101
+ if (rule.action === "deny") return { rule, value };
102
+ if (rule.action === "ask" && worst?.rule.action !== "ask") {
103
+ worst = { rule, value };
104
+ }
105
+ }
106
+ return worst;
107
+ }
108
+
82
109
  export function evaluateFirst(
83
110
  surface: string,
84
111
  values: string[],
package/src/service.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
3
+ *
4
+ * `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
5
+ * module isolation (`moduleCache: false`). A consumer doing
6
+ * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
7
+ * `getPermissionsService()` reads from the same `globalThis` slot the provider
8
+ * wrote to — enabling direct, synchronous, type-safe function calls.
9
+ *
10
+ * Best practice: call `getPermissionsService()` per use rather than caching the
11
+ * reference — this ensures resilience across `/reload` and load-order edge cases.
12
+ */
13
+
14
+ import type { PermissionCheckResult, PermissionState } from "./types";
15
+
16
+ export type { PermissionCheckResult, PermissionState };
17
+
18
+ /** Process-global key for the service slot. */
19
+ const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
20
+
21
+ /**
22
+ * Public interface exposed to other extensions via `getPermissionsService()`.
23
+ *
24
+ * Mirrors the simplified RPC signature — surface + optional value + optional
25
+ * agent name — and delegates to `PermissionManager.checkPermission()` with
26
+ * current session rules internally.
27
+ */
28
+ export interface PermissionsService {
29
+ /**
30
+ * Query the permission policy for a surface and value.
31
+ *
32
+ * @param surface - Permission surface: "bash", "read", "mcp", "skill",
33
+ * "external_directory", etc.
34
+ * @param value - The value to evaluate: command string, tool name, skill
35
+ * name, or path. Omit or pass `undefined` for a
36
+ * surface-level query.
37
+ * @param agentName - Optional agent name for per-agent policy resolution.
38
+ * @returns Full check result including state, matched pattern, and origin.
39
+ */
40
+ checkPermission(
41
+ surface: string,
42
+ value?: string,
43
+ agentName?: string,
44
+ ): PermissionCheckResult;
45
+ }
46
+
47
+ /**
48
+ * Store a `PermissionsService` on `globalThis` so other extensions can
49
+ * retrieve it via `getPermissionsService()`.
50
+ *
51
+ * Overwrites any previously published service — safe for `/reload`.
52
+ */
53
+ export function publishPermissionsService(service: PermissionsService): void {
54
+ (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
55
+ }
56
+
57
+ /**
58
+ * Retrieve the published `PermissionsService`, or `undefined` if the
59
+ * permission-system extension has not loaded (or has been unloaded).
60
+ */
61
+ export function getPermissionsService(): PermissionsService | undefined {
62
+ return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
63
+ | PermissionsService
64
+ | undefined;
65
+ }
66
+
67
+ /**
68
+ * Remove the service from `globalThis`.
69
+ *
70
+ * Called during `session_shutdown` to avoid stale references after the
71
+ * extension is torn down.
72
+ */
73
+ export function unpublishPermissionsService(): void {
74
+ delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
75
+ }
@@ -9,7 +9,10 @@ vi.mock("node:os", () => {
9
9
  };
10
10
  });
11
11
 
12
- import { extractExternalPathsFromBashCommand } from "../src/handlers/gates/bash-path-extractor";
12
+ import {
13
+ extractExternalPathsFromBashCommand,
14
+ extractTokensForPathRules,
15
+ } from "../src/handlers/gates/bash-path-extractor";
13
16
  import {
14
17
  formatBashExternalDirectoryAskPrompt,
15
18
  formatBashExternalDirectoryDenyReason,
@@ -887,3 +890,80 @@ describe("formatBashExternalDirectoryDenyReason", () => {
887
890
  expect(result).toContain("my-agent");
888
891
  });
889
892
  });
893
+
894
+ describe("extractTokensForPathRules", () => {
895
+ test("extracts dot-files: cat .env", async () => {
896
+ const tokens = await extractTokensForPathRules("cat .env");
897
+ expect(tokens).toContain(".env");
898
+ });
899
+
900
+ test("extracts relative dot-paths: git add src/.env", async () => {
901
+ const tokens = await extractTokensForPathRules("git add src/.env");
902
+ expect(tokens).toContain("src/.env");
903
+ });
904
+
905
+ test("extracts nothing from plain words: echo hello", async () => {
906
+ const tokens = await extractTokensForPathRules("echo hello");
907
+ expect(tokens).toHaveLength(0);
908
+ });
909
+
910
+ test("extracts ./src and skips flags: rm -rf ./src", async () => {
911
+ const tokens = await extractTokensForPathRules("rm -rf ./src");
912
+ expect(tokens).toContain("./src");
913
+ expect(tokens).not.toContain("-rf");
914
+ });
915
+
916
+ test("extracts absolute paths: cat /etc/hosts", async () => {
917
+ const tokens = await extractTokensForPathRules("cat /etc/hosts");
918
+ expect(tokens).toContain("/etc/hosts");
919
+ });
920
+
921
+ test("skips URLs: curl https://example.com", async () => {
922
+ const tokens = await extractTokensForPathRules("curl https://example.com");
923
+ expect(tokens).not.toContain("https://example.com");
924
+ });
925
+
926
+ test("extracts slash-containing tokens: cat src/foo.ts", async () => {
927
+ const tokens = await extractTokensForPathRules("cat src/foo.ts");
928
+ expect(tokens).toContain("src/foo.ts");
929
+ });
930
+
931
+ test("skips heredoc content", async () => {
932
+ const tokens = await extractTokensForPathRules("cat <<EOF\n.env\nEOF");
933
+ expect(tokens).not.toContain(".env");
934
+ });
935
+
936
+ test("skips @scope/package patterns", async () => {
937
+ const tokens = await extractTokensForPathRules(
938
+ "npm install @scope/package",
939
+ );
940
+ expect(tokens).not.toContain("@scope/package");
941
+ });
942
+
943
+ test("skips env assignments", async () => {
944
+ const tokens = await extractTokensForPathRules("FOO=/bar command");
945
+ expect(tokens).not.toContain("FOO=/bar");
946
+ });
947
+
948
+ test("skips bare-slash tokens", async () => {
949
+ const tokens = await extractTokensForPathRules("ls /");
950
+ expect(tokens).not.toContain("/");
951
+ });
952
+
953
+ test("extracts redirect targets: echo test > .env", async () => {
954
+ const tokens = await extractTokensForPathRules("echo test > .env");
955
+ expect(tokens).toContain(".env");
956
+ });
957
+
958
+ test("extracts multiple path tokens: cp .env .env.backup", async () => {
959
+ const tokens = await extractTokensForPathRules("cp .env .env.backup");
960
+ expect(tokens).toContain(".env");
961
+ expect(tokens).toContain(".env.backup");
962
+ });
963
+
964
+ test("deduplicates repeated tokens", async () => {
965
+ const tokens = await extractTokensForPathRules("cat .env && rm .env");
966
+ const envCount = tokens.filter((t) => t === ".env").length;
967
+ expect(envCount).toBe(1);
968
+ });
969
+ });
@@ -47,9 +47,29 @@ function makeCheckPermission(
47
47
  return vi
48
48
  .fn()
49
49
  .mockImplementation((surface: string): PermissionCheckResult => {
50
- const state =
51
- surface === "external_directory" ? externalDirectoryState : toolState;
52
- return { state, toolName: surface, source: "tool", origin: "builtin" };
50
+ if (surface === "external_directory") {
51
+ return {
52
+ state: externalDirectoryState,
53
+ toolName: surface,
54
+ source: "tool",
55
+ origin: "builtin",
56
+ };
57
+ }
58
+ // The cross-cutting path gate runs before ext-dir; keep it transparent.
59
+ if (surface === "path") {
60
+ return {
61
+ state: "allow",
62
+ toolName: surface,
63
+ source: "special",
64
+ origin: "builtin",
65
+ };
66
+ }
67
+ return {
68
+ state: toolState,
69
+ toolName: surface,
70
+ source: "tool",
71
+ origin: "builtin",
72
+ };
53
73
  });
54
74
  }
55
75
 
@@ -294,6 +314,67 @@ describe("external_directory policy state — allow", () => {
294
314
  });
295
315
  });
296
316
 
317
+ // #144: allow external reads, gate external writes
318
+ describe("external_directory — allow external reads, gate external writes (#144)", () => {
319
+ it("allows read of external path when external_directory and read are both allow", async () => {
320
+ const { handler } = makeHandler({
321
+ session: { checkPermission: makeCheckPermission("allow", "allow") },
322
+ });
323
+ const event = makeToolCallEvent("read", { path: EXTERNAL_PATH });
324
+ const result = await handler.handleToolCall(event, makeCtx());
325
+ expect(result).toEqual({});
326
+ });
327
+
328
+ it("prompts for write to external path when external_directory allows but write is ask", async () => {
329
+ const prompt = vi
330
+ .fn()
331
+ .mockResolvedValue({ approved: true, state: "approved" });
332
+ const { handler } = makeHandler({
333
+ session: {
334
+ checkPermission: makeCheckPermission("allow", "ask"),
335
+ prompt,
336
+ },
337
+ });
338
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
339
+ const result = await handler.handleToolCall(event, makeCtx());
340
+ // external_directory passes; write gate prompts and user approves
341
+ expect(result).toEqual({});
342
+ expect(prompt).toHaveBeenCalledOnce();
343
+ });
344
+
345
+ it("blocks write to external path when external_directory allows but write is deny", async () => {
346
+ const { handler } = makeHandler({
347
+ session: { checkPermission: makeCheckPermission("allow", "deny") },
348
+ });
349
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
350
+ const result = await handler.handleToolCall(event, makeCtx());
351
+ expect(result.block).toBe(true);
352
+ });
353
+
354
+ it("emits separate decision events for external_directory and write surfaces", async () => {
355
+ const { handler, events } = makeHandler({
356
+ session: { checkPermission: makeCheckPermission("allow", "deny") },
357
+ });
358
+ const event = makeToolCallEvent("write", { path: EXTERNAL_PATH });
359
+ await handler.handleToolCall(event, makeCtx());
360
+ const decisions = getDecisionEvents(events);
361
+ const extDirDecision = decisions.find(
362
+ (d) => d.surface === "external_directory",
363
+ );
364
+ const writeDecision = decisions.find((d) => d.surface === "write");
365
+ expect(extDirDecision).toMatchObject({
366
+ surface: "external_directory",
367
+ result: "allow",
368
+ resolution: "policy_allow",
369
+ });
370
+ expect(writeDecision).toMatchObject({
371
+ surface: "write",
372
+ result: "deny",
373
+ resolution: "policy_deny",
374
+ });
375
+ });
376
+ });
377
+
297
378
  describe("external_directory policy state — deny", () => {
298
379
  it("blocks with reason containing the external path", async () => {
299
380
  const { handler } = makeHandler({
@@ -0,0 +1,260 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // Mock node:os so tilde-expansion is deterministic across platforms.
4
+ vi.mock("node:os", () => {
5
+ const homedir = vi.fn(() => "/mock/home");
6
+ return {
7
+ homedir,
8
+ default: { homedir },
9
+ };
10
+ });
11
+
12
+ import { describeBashPathGate } from "../../../src/handlers/gates/bash-path";
13
+ import type {
14
+ GateBypass,
15
+ GateDescriptor,
16
+ } from "../../../src/handlers/gates/descriptor";
17
+ import {
18
+ isGateBypass,
19
+ isGateDescriptor,
20
+ } from "../../../src/handlers/gates/descriptor";
21
+ import type { ToolCallContext } from "../../../src/handlers/gates/types";
22
+ import type { Rule } from "../../../src/rule";
23
+ import type { PermissionCheckResult } from "../../../src/types";
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ // ── helpers ────────────────────────────────────────────────────────────────
30
+
31
+ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
32
+ return {
33
+ toolName: "bash",
34
+ agentName: null,
35
+ input: { command: "cat .env" },
36
+ toolCallId: "tc-1",
37
+ cwd: "/test/project",
38
+ ...overrides,
39
+ };
40
+ }
41
+
42
+ function makeCheckResult(
43
+ overrides: Partial<PermissionCheckResult> = {},
44
+ ): PermissionCheckResult {
45
+ return {
46
+ toolName: "path",
47
+ state: "allow",
48
+ source: "special",
49
+ origin: "global",
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ type CheckPermissionFn = (
55
+ surface: string,
56
+ input: unknown,
57
+ agentName?: string,
58
+ sessionRules?: Rule[],
59
+ ) => PermissionCheckResult;
60
+
61
+ // ── tests ──────────────────────────────────────────────────────────────────
62
+
63
+ describe("describeBashPathGate", () => {
64
+ it("returns null for non-bash tools", async () => {
65
+ const checkPermission = vi.fn<CheckPermissionFn>();
66
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
67
+ const result = await describeBashPathGate(
68
+ makeTcc({ toolName: "read", input: { path: ".env" } }),
69
+ checkPermission,
70
+ getSessionRuleset,
71
+ );
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it("returns null when no tokens are extracted", async () => {
76
+ const checkPermission = vi.fn<CheckPermissionFn>();
77
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
78
+ const result = await describeBashPathGate(
79
+ makeTcc({ input: { command: "echo hello" } }),
80
+ checkPermission,
81
+ getSessionRuleset,
82
+ );
83
+ expect(result).toBeNull();
84
+ });
85
+
86
+ it("returns null when all tokens evaluate to allow", async () => {
87
+ const checkPermission = vi
88
+ .fn<CheckPermissionFn>()
89
+ .mockReturnValue(makeCheckResult({ state: "allow" }));
90
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
91
+ const result = await describeBashPathGate(
92
+ makeTcc({ input: { command: "cat .env" } }),
93
+ checkPermission,
94
+ getSessionRuleset,
95
+ );
96
+ expect(result).toBeNull();
97
+ });
98
+
99
+ it("returns GateDescriptor when a token evaluates to deny", async () => {
100
+ const checkPermission = vi.fn<CheckPermissionFn>().mockReturnValue(
101
+ makeCheckResult({
102
+ state: "deny",
103
+ matchedPattern: "*.env",
104
+ }),
105
+ );
106
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
107
+ const result = await describeBashPathGate(
108
+ makeTcc({ input: { command: "cat .env" } }),
109
+ checkPermission,
110
+ getSessionRuleset,
111
+ );
112
+ expect(result).not.toBeNull();
113
+ expect(isGateDescriptor(result)).toBe(true);
114
+ const desc = result as GateDescriptor;
115
+ expect(desc.surface).toBe("path");
116
+ expect(desc.preCheck?.state).toBe("deny");
117
+ });
118
+
119
+ it("returns GateDescriptor when a token evaluates to ask", async () => {
120
+ const checkPermission = vi
121
+ .fn<CheckPermissionFn>()
122
+ .mockReturnValue(makeCheckResult({ state: "ask" }));
123
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
124
+ const result = await describeBashPathGate(
125
+ makeTcc({ input: { command: "cat .env" } }),
126
+ checkPermission,
127
+ getSessionRuleset,
128
+ );
129
+ expect(result).not.toBeNull();
130
+ expect(isGateDescriptor(result)).toBe(true);
131
+ const desc = result as GateDescriptor;
132
+ expect(desc.preCheck?.state).toBe("ask");
133
+ });
134
+
135
+ it("descriptor includes triggering token in prompt message", async () => {
136
+ const checkPermission = vi
137
+ .fn<CheckPermissionFn>()
138
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
139
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
140
+ const result = (await describeBashPathGate(
141
+ makeTcc({ input: { command: "cat .env" } }),
142
+ checkPermission,
143
+ getSessionRuleset,
144
+ )) as GateDescriptor;
145
+ expect(result.messages.denyReason).toContain(".env");
146
+ expect(result.promptDetails.message).toContain(".env");
147
+ });
148
+
149
+ it("descriptor decision uses surface 'path'", async () => {
150
+ const checkPermission = vi
151
+ .fn<CheckPermissionFn>()
152
+ .mockReturnValue(makeCheckResult({ state: "deny" }));
153
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
154
+ const result = (await describeBashPathGate(
155
+ makeTcc({ input: { command: "cat .env" } }),
156
+ checkPermission,
157
+ getSessionRuleset,
158
+ )) as GateDescriptor;
159
+ expect(result.decision.surface).toBe("path");
160
+ });
161
+
162
+ it("returns GateBypass when session rule covers the path", async () => {
163
+ const checkPermission = vi
164
+ .fn<CheckPermissionFn>()
165
+ .mockReturnValue(makeCheckResult({ state: "allow", source: "session" }));
166
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([
167
+ {
168
+ surface: "path",
169
+ pattern: "*",
170
+ action: "allow",
171
+ layer: "session",
172
+ origin: "session",
173
+ },
174
+ ]);
175
+ const result = await describeBashPathGate(
176
+ makeTcc({ input: { command: "cat .env" } }),
177
+ checkPermission,
178
+ getSessionRuleset,
179
+ );
180
+ expect(result).not.toBeNull();
181
+ expect(isGateBypass(result)).toBe(true);
182
+ expect((result as GateBypass).action).toBe("allow");
183
+ });
184
+
185
+ it("returns null when command is missing", async () => {
186
+ const checkPermission = vi.fn<CheckPermissionFn>();
187
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
188
+ const result = await describeBashPathGate(
189
+ makeTcc({ input: {} }),
190
+ checkPermission,
191
+ getSessionRuleset,
192
+ );
193
+ expect(result).toBeNull();
194
+ });
195
+
196
+ it("evaluates most restrictive across multiple tokens", async () => {
197
+ const checkPermission = vi
198
+ .fn<CheckPermissionFn>()
199
+ .mockImplementation((_surface, input) => {
200
+ const record = input as Record<string, unknown>;
201
+ if (record.path === "src/foo.ts") {
202
+ return makeCheckResult({ state: "allow" });
203
+ }
204
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
205
+ });
206
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
207
+ const result = await describeBashPathGate(
208
+ makeTcc({ input: { command: "cat src/foo.ts .env" } }),
209
+ checkPermission,
210
+ getSessionRuleset,
211
+ );
212
+ expect(result).not.toBeNull();
213
+ expect(isGateDescriptor(result)).toBe(true);
214
+ expect((result as GateDescriptor).preCheck?.state).toBe("deny");
215
+ });
216
+
217
+ it("deny wins in multi-token: cp .env README.md", async () => {
218
+ const checkPermission = vi
219
+ .fn<CheckPermissionFn>()
220
+ .mockImplementation((_surface, input) => {
221
+ const record = input as Record<string, unknown>;
222
+ if (record.path === ".env") {
223
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
224
+ }
225
+ return makeCheckResult({ state: "allow" });
226
+ });
227
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
228
+ const result = await describeBashPathGate(
229
+ makeTcc({ input: { command: "cp .env README.md" } }),
230
+ checkPermission,
231
+ getSessionRuleset,
232
+ );
233
+ expect(result).not.toBeNull();
234
+ expect(isGateDescriptor(result)).toBe(true);
235
+ const desc = result as GateDescriptor;
236
+ expect(desc.preCheck?.state).toBe("deny");
237
+ expect(desc.decision.value).toBe(".env");
238
+ });
239
+
240
+ it("extracts redirect target: echo test > .env triggers deny", async () => {
241
+ const checkPermission = vi
242
+ .fn<CheckPermissionFn>()
243
+ .mockImplementation((_surface, input) => {
244
+ const record = input as Record<string, unknown>;
245
+ if (record.path === ".env") {
246
+ return makeCheckResult({ state: "deny", matchedPattern: "*.env" });
247
+ }
248
+ return makeCheckResult({ state: "allow" });
249
+ });
250
+ const getSessionRuleset = vi.fn<() => Rule[]>().mockReturnValue([]);
251
+ const result = await describeBashPathGate(
252
+ makeTcc({ input: { command: "echo test > .env" } }),
253
+ checkPermission,
254
+ getSessionRuleset,
255
+ );
256
+ expect(result).not.toBeNull();
257
+ expect(isGateDescriptor(result)).toBe(true);
258
+ expect((result as GateDescriptor).preCheck?.state).toBe("deny");
259
+ });
260
+ });