@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.
- package/CHANGELOG.md +37 -0
- package/README.md +9 -4
- package/config/config.example.json +3 -1
- package/package.json +4 -1
- package/schemas/permissions.schema.json +4 -2
- package/src/handlers/gates/bash-path-extractor.ts +75 -0
- package/src/handlers/gates/bash-path.ts +146 -0
- package/src/handlers/gates/index.ts +2 -0
- package/src/handlers/gates/path.ts +104 -0
- package/src/handlers/permission-gate-handler.ts +46 -0
- package/src/index.ts +21 -0
- package/src/input-normalizer.ts +29 -1
- package/src/permission-event-rpc.ts +1 -20
- package/src/permission-events.ts +25 -3
- package/src/permission-manager.ts +1 -1
- package/src/rule.ts +27 -0
- package/src/service.ts +75 -0
- package/tests/bash-external-directory.test.ts +81 -1
- package/tests/handlers/external-directory-integration.test.ts +84 -3
- package/tests/handlers/gates/bash-path.test.ts +260 -0
- package/tests/handlers/gates/path.test.ts +149 -0
- package/tests/handlers/tool-call.test.ts +78 -0
- package/tests/input-normalizer.test.ts +24 -0
- package/tests/permission-manager-unified.test.ts +210 -0
- package/tests/rule.test.ts +77 -1
- package/tests/service.test.ts +144 -0
|
@@ -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(
|
package/src/permission-events.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
});
|