@gotgenes/pi-permission-system 8.2.0 → 8.3.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 +23 -0
- package/package.json +1 -1
- package/src/builtin-tool-input-formatters.ts +82 -0
- package/src/config-loader.ts +53 -46
- package/src/handlers/gates/bash-path-extractor.ts +135 -169
- package/src/handlers/gates/bash-token-classification.ts +105 -0
- package/src/handlers/permission-gate-handler.ts +3 -0
- package/src/index.ts +13 -1
- package/src/permission-prompts.ts +5 -1
- package/src/service.ts +21 -1
- package/src/tool-input-formatter-registry.ts +57 -0
- package/src/tool-preview-formatter.ts +18 -1
- package/test/builtin-tool-input-formatters.test.ts +109 -0
- package/test/config-loader.test.ts +82 -0
- package/test/handlers/before-agent-start.test.ts +2 -20
- package/test/handlers/external-directory-integration.test.ts +43 -81
- package/test/handlers/external-directory-session-dedup.test.ts +2 -29
- package/test/handlers/gates/bash-path.test.ts +5 -26
- package/test/handlers/gates/bash-token-classification.test.ts +241 -0
- package/test/handlers/gates/path.test.ts +3 -12
- package/test/handlers/gates/runner.test.ts +78 -91
- package/test/handlers/input-events.test.ts +42 -95
- package/test/handlers/input.test.ts +3 -71
- package/test/handlers/lifecycle.test.ts +3 -20
- package/test/handlers/tool-call-events.test.ts +30 -127
- package/test/handlers/tool-call.test.ts +21 -110
- package/test/helpers/gate-fixtures.ts +105 -0
- package/test/helpers/handler-fixtures.ts +141 -0
- package/test/helpers/manager-harness.ts +51 -0
- package/test/permission-prompts.test.ts +53 -7
- package/test/permission-session.test.ts +1 -19
- package/test/permission-system.test.ts +4 -40
- package/test/service.test.ts +52 -0
- package/test/tool-input-formatter-registry.test.ts +75 -0
- package/test/tool-preview-formatter.test.ts +73 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
classifyTokenAsPathCandidate,
|
|
5
|
+
classifyTokenAsRuleCandidate,
|
|
6
|
+
} from "#src/handlers/gates/bash-token-classification";
|
|
7
|
+
|
|
8
|
+
// ── Shared rejection behaviour ─────────────────────────────────────────────
|
|
9
|
+
//
|
|
10
|
+
// Both classifiers delegate to the private `rejectNonPathToken` predicate for
|
|
11
|
+
// the seven shared rejection cases tested below. Testing via both exports
|
|
12
|
+
// pins that predicate through each caller.
|
|
13
|
+
|
|
14
|
+
describe("classifyTokenAsPathCandidate", () => {
|
|
15
|
+
describe("shared rejection: rejectNonPathToken", () => {
|
|
16
|
+
test("empty string → null", () => {
|
|
17
|
+
expect(classifyTokenAsPathCandidate("")).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("flag (leading dash) → null", () => {
|
|
21
|
+
expect(classifyTokenAsPathCandidate("-r")).toBeNull();
|
|
22
|
+
expect(classifyTokenAsPathCandidate("--recursive")).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("env assignment (= before any /) → null", () => {
|
|
26
|
+
expect(classifyTokenAsPathCandidate("FOO=/bar")).toBeNull();
|
|
27
|
+
expect(classifyTokenAsPathCandidate("HOME=/home/user")).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
31
|
+
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues
|
|
32
|
+
// Starts with /, so path candidate accepts it.
|
|
33
|
+
expect(classifyTokenAsPathCandidate("/foo=bar")).toBe("/foo=bar");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("URL → null", () => {
|
|
37
|
+
expect(classifyTokenAsPathCandidate("https://example.com")).toBeNull();
|
|
38
|
+
expect(classifyTokenAsPathCandidate("http://localhost:3000")).toBeNull();
|
|
39
|
+
expect(classifyTokenAsPathCandidate("file:///tmp/foo")).toBeNull();
|
|
40
|
+
expect(
|
|
41
|
+
classifyTokenAsPathCandidate("git+ssh://github.com/a/b"),
|
|
42
|
+
).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("@scope/package → null", () => {
|
|
46
|
+
expect(classifyTokenAsPathCandidate("@foo/bar")).toBeNull();
|
|
47
|
+
expect(classifyTokenAsPathCandidate("@scope/pkg")).toBeNull();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("@/ prefix is NOT rejected (it looks like an absolute-rooted scoped path)", () => {
|
|
51
|
+
// @/ passes the @ guard; then for path candidate it doesn't start with /
|
|
52
|
+
// or ~/, and doesn't contain .., so it returns null anyway from the
|
|
53
|
+
// acceptance gate — but the rejection is not due to the @ guard.
|
|
54
|
+
// This test documents that @/ is not rejected by the shared rejection.
|
|
55
|
+
// The path classifier then rejects it for not matching any acceptance shape.
|
|
56
|
+
expect(classifyTokenAsPathCandidate("@/foo/bar")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("bare-slash token → null", () => {
|
|
60
|
+
expect(classifyTokenAsPathCandidate("/")).toBeNull();
|
|
61
|
+
expect(classifyTokenAsPathCandidate("//")).toBeNull();
|
|
62
|
+
expect(classifyTokenAsPathCandidate("///")).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("regex metacharacters → null", () => {
|
|
66
|
+
// REGEX_METACHAR_PATTERN: .*, .+, \|, \(, \), [...], ^/
|
|
67
|
+
expect(classifyTokenAsPathCandidate("foo.*")).toBeNull();
|
|
68
|
+
expect(classifyTokenAsPathCandidate("bar.+")).toBeNull();
|
|
69
|
+
expect(classifyTokenAsPathCandidate("a\\|b")).toBeNull();
|
|
70
|
+
expect(classifyTokenAsPathCandidate("\\(group\\)")).toBeNull();
|
|
71
|
+
expect(classifyTokenAsPathCandidate("[abc]")).toBeNull();
|
|
72
|
+
expect(classifyTokenAsPathCandidate("^/start")).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("path-candidate acceptance gate", () => {
|
|
77
|
+
test("absolute path (starts with /) → returned as-is", () => {
|
|
78
|
+
expect(classifyTokenAsPathCandidate("/etc/hosts")).toBe("/etc/hosts");
|
|
79
|
+
expect(classifyTokenAsPathCandidate("/tmp")).toBe("/tmp");
|
|
80
|
+
expect(classifyTokenAsPathCandidate("/home/user/file.txt")).toBe(
|
|
81
|
+
"/home/user/file.txt",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
86
|
+
expect(classifyTokenAsPathCandidate("~/Documents")).toBe("~/Documents");
|
|
87
|
+
expect(classifyTokenAsPathCandidate("~/.ssh/config")).toBe(
|
|
88
|
+
"~/.ssh/config",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
93
|
+
expect(classifyTokenAsPathCandidate("../../etc/passwd")).toBe(
|
|
94
|
+
"../../etc/passwd",
|
|
95
|
+
);
|
|
96
|
+
expect(classifyTokenAsPathCandidate("../foo")).toBe("../foo");
|
|
97
|
+
expect(classifyTokenAsPathCandidate("..")).toBe("..");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("plain word with no path shape → null", () => {
|
|
101
|
+
expect(classifyTokenAsPathCandidate("hello")).toBeNull();
|
|
102
|
+
expect(classifyTokenAsPathCandidate("myfile.txt")).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("dot-file (starts with .) → null (strict path gate)", () => {
|
|
106
|
+
// Path candidate does NOT accept dot-files; rule candidate does.
|
|
107
|
+
expect(classifyTokenAsPathCandidate(".env")).toBeNull();
|
|
108
|
+
expect(classifyTokenAsPathCandidate(".gitignore")).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("relative path with / but no leading / or ~/ → null (strict path gate)", () => {
|
|
112
|
+
// Path candidate does NOT accept bare relative paths; rule candidate does.
|
|
113
|
+
expect(classifyTokenAsPathCandidate("src/foo.ts")).toBeNull();
|
|
114
|
+
expect(classifyTokenAsPathCandidate("./build")).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("classifyTokenAsRuleCandidate", () => {
|
|
120
|
+
describe("shared rejection: rejectNonPathToken", () => {
|
|
121
|
+
test("empty string → null", () => {
|
|
122
|
+
expect(classifyTokenAsRuleCandidate("")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("flag (leading dash) → null", () => {
|
|
126
|
+
expect(classifyTokenAsRuleCandidate("-r")).toBeNull();
|
|
127
|
+
expect(classifyTokenAsRuleCandidate("--recursive")).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("env assignment (= before any /) → null", () => {
|
|
131
|
+
expect(classifyTokenAsRuleCandidate("FOO=/bar")).toBeNull();
|
|
132
|
+
expect(classifyTokenAsRuleCandidate("HOME=/home/user")).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("env-like token where = comes after / is NOT rejected as assignment", () => {
|
|
136
|
+
// /foo=bar: slashIndex (0) < eqIndex (4) → not an assignment → continues.
|
|
137
|
+
// Contains /, so rule candidate accepts it.
|
|
138
|
+
expect(classifyTokenAsRuleCandidate("/foo=bar")).toBe("/foo=bar");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("URL → null", () => {
|
|
142
|
+
expect(classifyTokenAsRuleCandidate("https://example.com")).toBeNull();
|
|
143
|
+
expect(classifyTokenAsRuleCandidate("http://localhost:3000")).toBeNull();
|
|
144
|
+
expect(classifyTokenAsRuleCandidate("file:///tmp/foo")).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("@scope/package → null", () => {
|
|
148
|
+
expect(classifyTokenAsRuleCandidate("@foo/bar")).toBeNull();
|
|
149
|
+
expect(classifyTokenAsRuleCandidate("@scope/pkg")).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("bare-slash token → null", () => {
|
|
153
|
+
expect(classifyTokenAsRuleCandidate("/")).toBeNull();
|
|
154
|
+
expect(classifyTokenAsRuleCandidate("//")).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("regex metacharacters → null", () => {
|
|
158
|
+
expect(classifyTokenAsRuleCandidate("foo.*")).toBeNull();
|
|
159
|
+
expect(classifyTokenAsRuleCandidate("bar.+")).toBeNull();
|
|
160
|
+
expect(classifyTokenAsRuleCandidate("a\\|b")).toBeNull();
|
|
161
|
+
expect(classifyTokenAsRuleCandidate("[abc]")).toBeNull();
|
|
162
|
+
expect(classifyTokenAsRuleCandidate("^/start")).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("rule-candidate acceptance gate (broader than path)", () => {
|
|
167
|
+
test("absolute path (starts with /) → returned as-is", () => {
|
|
168
|
+
expect(classifyTokenAsRuleCandidate("/etc/hosts")).toBe("/etc/hosts");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("home-relative path (starts with ~/) → returned as-is", () => {
|
|
172
|
+
expect(classifyTokenAsRuleCandidate("~/Documents")).toBe("~/Documents");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("parent-traversal (contains ..) → returned as-is", () => {
|
|
176
|
+
expect(classifyTokenAsRuleCandidate("../foo")).toBe("../foo");
|
|
177
|
+
expect(classifyTokenAsRuleCandidate("..")).toBe("..");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("dot-file (starts with .) → returned as-is", () => {
|
|
181
|
+
// Rule candidate accepts dot-files; path candidate does not.
|
|
182
|
+
expect(classifyTokenAsRuleCandidate(".env")).toBe(".env");
|
|
183
|
+
expect(classifyTokenAsRuleCandidate(".gitignore")).toBe(".gitignore");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("current-dir relative (starts with ./) → returned as-is", () => {
|
|
187
|
+
expect(classifyTokenAsRuleCandidate("./src")).toBe("./src");
|
|
188
|
+
expect(classifyTokenAsRuleCandidate("./build/output.js")).toBe(
|
|
189
|
+
"./build/output.js",
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("relative path containing / → returned as-is", () => {
|
|
194
|
+
// Rule candidate accepts any token with / (not already rejected).
|
|
195
|
+
expect(classifyTokenAsRuleCandidate("src/foo.ts")).toBe("src/foo.ts");
|
|
196
|
+
expect(classifyTokenAsRuleCandidate("packages/pi-foo/index.ts")).toBe(
|
|
197
|
+
"packages/pi-foo/index.ts",
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("plain word with no path shape → null", () => {
|
|
202
|
+
expect(classifyTokenAsRuleCandidate("hello")).toBeNull();
|
|
203
|
+
expect(classifyTokenAsRuleCandidate("myfile.txt")).toBeNull();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("rule-vs-path divergence", () => {
|
|
208
|
+
const dotFiles = [".env", ".gitignore", ".eslintrc"];
|
|
209
|
+
const relPaths = ["src/index.ts", "lib/utils.js", "config/settings.json"];
|
|
210
|
+
|
|
211
|
+
for (const tok of dotFiles) {
|
|
212
|
+
test(`dot-file "${tok}": rule accepts, path rejects`, () => {
|
|
213
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
214
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const tok of relPaths) {
|
|
219
|
+
test(`relative path "${tok}": rule accepts, path rejects`, () => {
|
|
220
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
221
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const sharedAccepted = ["/etc/hosts", "~/docs", "../sibling"];
|
|
226
|
+
for (const tok of sharedAccepted) {
|
|
227
|
+
test(`"${tok}": both classifiers accept`, () => {
|
|
228
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBe(tok);
|
|
229
|
+
expect(classifyTokenAsPathCandidate(tok)).toBe(tok);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const sharedRejected = ["hello", "--flag", "FOO=/bar", "https://x.com"];
|
|
234
|
+
for (const tok of sharedRejected) {
|
|
235
|
+
test(`"${tok}": both classifiers reject`, () => {
|
|
236
|
+
expect(classifyTokenAsRuleCandidate(tok)).toBeNull();
|
|
237
|
+
expect(classifyTokenAsPathCandidate(tok)).toBeNull();
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -7,8 +7,11 @@ import type { ToolCallContext } from "#src/handlers/gates/types";
|
|
|
7
7
|
import type { Rule } from "#src/rule";
|
|
8
8
|
import type { PermissionCheckResult } from "#src/types";
|
|
9
9
|
|
|
10
|
+
import { makeGateCheckResult as makeCheckResult } from "#test/helpers/gate-fixtures";
|
|
11
|
+
|
|
10
12
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
11
13
|
|
|
14
|
+
// path.test.ts uses read-tool defaults; the shared makeTcc uses bash defaults.
|
|
12
15
|
function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
13
16
|
return {
|
|
14
17
|
toolName: "read",
|
|
@@ -20,18 +23,6 @@ function makeTcc(overrides: Partial<ToolCallContext> = {}): ToolCallContext {
|
|
|
20
23
|
};
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
function makeCheckResult(
|
|
24
|
-
overrides: Partial<PermissionCheckResult> = {},
|
|
25
|
-
): PermissionCheckResult {
|
|
26
|
-
return {
|
|
27
|
-
toolName: "path",
|
|
28
|
-
state: "allow",
|
|
29
|
-
source: "special",
|
|
30
|
-
origin: "global",
|
|
31
|
-
...overrides,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
26
|
type CheckPermissionFn = (
|
|
36
27
|
surface: string,
|
|
37
28
|
input: unknown,
|
|
@@ -2,76 +2,11 @@ import { describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import type { DenialContext } from "#src/denial-messages";
|
|
4
4
|
import { EXTENSION_TAG } from "#src/denial-messages";
|
|
5
|
-
import type {
|
|
6
|
-
GateDescriptor,
|
|
7
|
-
GateRunnerDeps,
|
|
8
|
-
} from "#src/handlers/gates/descriptor";
|
|
5
|
+
import type { GateDescriptor } from "#src/handlers/gates/descriptor";
|
|
9
6
|
import { runGateCheck } from "#src/handlers/gates/runner";
|
|
10
7
|
import { SessionApproval } from "#src/session-approval";
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
function makeDescriptor(
|
|
16
|
-
overrides: Partial<GateDescriptor> = {},
|
|
17
|
-
): GateDescriptor {
|
|
18
|
-
return {
|
|
19
|
-
surface: "read",
|
|
20
|
-
input: {},
|
|
21
|
-
denialContext: {
|
|
22
|
-
kind: "tool",
|
|
23
|
-
check: makeCheckResult("deny"),
|
|
24
|
-
},
|
|
25
|
-
promptDetails: {
|
|
26
|
-
source: "tool_call",
|
|
27
|
-
agentName: null,
|
|
28
|
-
message: "Allow tool 'read'?",
|
|
29
|
-
toolCallId: "tc-1",
|
|
30
|
-
toolName: "read",
|
|
31
|
-
},
|
|
32
|
-
logContext: {
|
|
33
|
-
source: "tool_call",
|
|
34
|
-
toolCallId: "tc-1",
|
|
35
|
-
toolName: "read",
|
|
36
|
-
},
|
|
37
|
-
decision: {
|
|
38
|
-
surface: "read",
|
|
39
|
-
value: "read",
|
|
40
|
-
},
|
|
41
|
-
...overrides,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function makeCheckResult(
|
|
46
|
-
state: "allow" | "deny" | "ask",
|
|
47
|
-
overrides: Partial<PermissionCheckResult> = {},
|
|
48
|
-
): PermissionCheckResult {
|
|
49
|
-
return {
|
|
50
|
-
state,
|
|
51
|
-
toolName: "read",
|
|
52
|
-
source: "tool",
|
|
53
|
-
origin: "builtin",
|
|
54
|
-
matchedPattern: "*",
|
|
55
|
-
...overrides,
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function makeRunnerDeps(
|
|
60
|
-
overrides: Partial<GateRunnerDeps> = {},
|
|
61
|
-
): GateRunnerDeps {
|
|
62
|
-
return {
|
|
63
|
-
checkPermission: vi.fn().mockReturnValue(makeCheckResult("allow")),
|
|
64
|
-
getSessionRuleset: vi.fn().mockReturnValue([]),
|
|
65
|
-
recordSessionApproval: vi.fn(),
|
|
66
|
-
writeReviewLog: vi.fn(),
|
|
67
|
-
emitDecision: vi.fn(),
|
|
68
|
-
canConfirm: vi.fn().mockReturnValue(true),
|
|
69
|
-
promptPermission: vi
|
|
70
|
-
.fn()
|
|
71
|
-
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
72
|
-
...overrides,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
8
|
+
import { makeDescriptor, makeRunnerDeps } from "#test/helpers/gate-fixtures";
|
|
9
|
+
import { makeCheckResult } from "#test/helpers/handler-fixtures";
|
|
75
10
|
|
|
76
11
|
// ── tests ──────────────────────────────────────────────────────────────────
|
|
77
12
|
|
|
@@ -92,7 +27,11 @@ describe("runGateCheck", () => {
|
|
|
92
27
|
|
|
93
28
|
it("returns block and emits policy_deny when policy is deny", async () => {
|
|
94
29
|
const deps = makeRunnerDeps({
|
|
95
|
-
checkPermission: vi
|
|
30
|
+
checkPermission: vi
|
|
31
|
+
.fn()
|
|
32
|
+
.mockReturnValue(
|
|
33
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
34
|
+
),
|
|
96
35
|
});
|
|
97
36
|
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
98
37
|
expect(result).toMatchObject({ action: "block" });
|
|
@@ -110,12 +49,11 @@ describe("runGateCheck", () => {
|
|
|
110
49
|
|
|
111
50
|
it("returns allow and emits session_approved on session hit", async () => {
|
|
112
51
|
const deps = makeRunnerDeps({
|
|
113
|
-
checkPermission: vi
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
matchedPattern: "git *",
|
|
117
|
-
|
|
118
|
-
),
|
|
52
|
+
checkPermission: vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockReturnValue(
|
|
55
|
+
makeCheckResult({ source: "session", matchedPattern: "git *" }),
|
|
56
|
+
),
|
|
119
57
|
});
|
|
120
58
|
const result = await runGateCheck(
|
|
121
59
|
makeDescriptor({
|
|
@@ -145,7 +83,11 @@ describe("runGateCheck", () => {
|
|
|
145
83
|
|
|
146
84
|
it("returns allow and emits user_approved when ask + user approves", async () => {
|
|
147
85
|
const deps = makeRunnerDeps({
|
|
148
|
-
checkPermission: vi
|
|
86
|
+
checkPermission: vi
|
|
87
|
+
.fn()
|
|
88
|
+
.mockReturnValue(
|
|
89
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
90
|
+
),
|
|
149
91
|
promptPermission: vi
|
|
150
92
|
.fn()
|
|
151
93
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -162,7 +104,11 @@ describe("runGateCheck", () => {
|
|
|
162
104
|
|
|
163
105
|
it("returns allow, emits user_approved_for_session, and records session rule on approved_for_session", async () => {
|
|
164
106
|
const deps = makeRunnerDeps({
|
|
165
|
-
checkPermission: vi
|
|
107
|
+
checkPermission: vi
|
|
108
|
+
.fn()
|
|
109
|
+
.mockReturnValue(
|
|
110
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
111
|
+
),
|
|
166
112
|
promptPermission: vi
|
|
167
113
|
.fn()
|
|
168
114
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -184,7 +130,11 @@ describe("runGateCheck", () => {
|
|
|
184
130
|
|
|
185
131
|
it("calls recordSessionApproval once with the full SessionApproval when sessionApproval has multiple patterns", async () => {
|
|
186
132
|
const deps = makeRunnerDeps({
|
|
187
|
-
checkPermission: vi
|
|
133
|
+
checkPermission: vi
|
|
134
|
+
.fn()
|
|
135
|
+
.mockReturnValue(
|
|
136
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
137
|
+
),
|
|
188
138
|
promptPermission: vi
|
|
189
139
|
.fn()
|
|
190
140
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -202,7 +152,11 @@ describe("runGateCheck", () => {
|
|
|
202
152
|
|
|
203
153
|
it("returns block and emits user_denied when ask + user denies", async () => {
|
|
204
154
|
const deps = makeRunnerDeps({
|
|
205
|
-
checkPermission: vi
|
|
155
|
+
checkPermission: vi
|
|
156
|
+
.fn()
|
|
157
|
+
.mockReturnValue(
|
|
158
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
159
|
+
),
|
|
206
160
|
promptPermission: vi
|
|
207
161
|
.fn()
|
|
208
162
|
.mockResolvedValue({ approved: false, state: "denied" }),
|
|
@@ -219,7 +173,11 @@ describe("runGateCheck", () => {
|
|
|
219
173
|
|
|
220
174
|
it("returns block and emits confirmation_unavailable when ask + no UI", async () => {
|
|
221
175
|
const deps = makeRunnerDeps({
|
|
222
|
-
checkPermission: vi
|
|
176
|
+
checkPermission: vi
|
|
177
|
+
.fn()
|
|
178
|
+
.mockReturnValue(
|
|
179
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
180
|
+
),
|
|
223
181
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
224
182
|
});
|
|
225
183
|
const result = await runGateCheck(makeDescriptor(), null, "tc-1", deps);
|
|
@@ -234,7 +192,11 @@ describe("runGateCheck", () => {
|
|
|
234
192
|
|
|
235
193
|
it("emits auto_approved resolution when decision has autoApproved flag", async () => {
|
|
236
194
|
const deps = makeRunnerDeps({
|
|
237
|
-
checkPermission: vi
|
|
195
|
+
checkPermission: vi
|
|
196
|
+
.fn()
|
|
197
|
+
.mockReturnValue(
|
|
198
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
199
|
+
),
|
|
238
200
|
promptPermission: vi.fn().mockResolvedValue({
|
|
239
201
|
approved: true,
|
|
240
202
|
state: "approved",
|
|
@@ -304,7 +266,11 @@ describe("runGateCheck", () => {
|
|
|
304
266
|
|
|
305
267
|
it("passes requestId from toolCallId to promptPermission", async () => {
|
|
306
268
|
const deps = makeRunnerDeps({
|
|
307
|
-
checkPermission: vi
|
|
269
|
+
checkPermission: vi
|
|
270
|
+
.fn()
|
|
271
|
+
.mockReturnValue(
|
|
272
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
273
|
+
),
|
|
308
274
|
});
|
|
309
275
|
await runGateCheck(makeDescriptor(), null, "tc-42", deps);
|
|
310
276
|
expect(deps.promptPermission).toHaveBeenCalledWith(
|
|
@@ -314,7 +280,11 @@ describe("runGateCheck", () => {
|
|
|
314
280
|
|
|
315
281
|
it("does not call recordSessionApproval when user approves once (no sessionApproval)", async () => {
|
|
316
282
|
const deps = makeRunnerDeps({
|
|
317
|
-
checkPermission: vi
|
|
283
|
+
checkPermission: vi
|
|
284
|
+
.fn()
|
|
285
|
+
.mockReturnValue(
|
|
286
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
287
|
+
),
|
|
318
288
|
promptPermission: vi
|
|
319
289
|
.fn()
|
|
320
290
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
@@ -326,7 +296,8 @@ describe("runGateCheck", () => {
|
|
|
326
296
|
it("uses preCheck result directly instead of calling checkPermission", async () => {
|
|
327
297
|
const deps = makeRunnerDeps();
|
|
328
298
|
const descriptor = makeDescriptor({
|
|
329
|
-
preCheck: makeCheckResult(
|
|
299
|
+
preCheck: makeCheckResult({
|
|
300
|
+
state: "deny",
|
|
330
301
|
origin: "global",
|
|
331
302
|
matchedPattern: "rm *",
|
|
332
303
|
}),
|
|
@@ -345,7 +316,11 @@ describe("runGateCheck", () => {
|
|
|
345
316
|
|
|
346
317
|
it("does not call recordSessionApproval when user approves for session but no sessionApproval on descriptor", async () => {
|
|
347
318
|
const deps = makeRunnerDeps({
|
|
348
|
-
checkPermission: vi
|
|
319
|
+
checkPermission: vi
|
|
320
|
+
.fn()
|
|
321
|
+
.mockReturnValue(
|
|
322
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
323
|
+
),
|
|
349
324
|
promptPermission: vi
|
|
350
325
|
.fn()
|
|
351
326
|
.mockResolvedValue({ approved: true, state: "approved_for_session" }),
|
|
@@ -386,11 +361,15 @@ describe("runGateCheck", () => {
|
|
|
386
361
|
|
|
387
362
|
it("uses denialContext to format denyReason with extension tag", async () => {
|
|
388
363
|
const deps = makeRunnerDeps({
|
|
389
|
-
checkPermission: vi
|
|
364
|
+
checkPermission: vi
|
|
365
|
+
.fn()
|
|
366
|
+
.mockReturnValue(
|
|
367
|
+
makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
368
|
+
),
|
|
390
369
|
});
|
|
391
370
|
const ctx: DenialContext = {
|
|
392
371
|
kind: "tool",
|
|
393
|
-
check: makeCheckResult("deny"),
|
|
372
|
+
check: makeCheckResult({ state: "deny", matchedPattern: "*" }),
|
|
394
373
|
agentName: "test-agent",
|
|
395
374
|
};
|
|
396
375
|
const result = await runGateCheck(
|
|
@@ -408,12 +387,16 @@ describe("runGateCheck", () => {
|
|
|
408
387
|
|
|
409
388
|
it("uses denialContext to format unavailableReason with extension tag", async () => {
|
|
410
389
|
const deps = makeRunnerDeps({
|
|
411
|
-
checkPermission: vi
|
|
390
|
+
checkPermission: vi
|
|
391
|
+
.fn()
|
|
392
|
+
.mockReturnValue(
|
|
393
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
394
|
+
),
|
|
412
395
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
413
396
|
});
|
|
414
397
|
const ctx: DenialContext = {
|
|
415
398
|
kind: "tool",
|
|
416
|
-
check: makeCheckResult("ask"),
|
|
399
|
+
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
417
400
|
};
|
|
418
401
|
const result = await runGateCheck(
|
|
419
402
|
makeDenialContextDescriptor(ctx),
|
|
@@ -430,7 +413,11 @@ describe("runGateCheck", () => {
|
|
|
430
413
|
|
|
431
414
|
it("uses denialContext to format userDeniedReason with extension tag", async () => {
|
|
432
415
|
const deps = makeRunnerDeps({
|
|
433
|
-
checkPermission: vi
|
|
416
|
+
checkPermission: vi
|
|
417
|
+
.fn()
|
|
418
|
+
.mockReturnValue(
|
|
419
|
+
makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
420
|
+
),
|
|
434
421
|
promptPermission: vi.fn().mockResolvedValue({
|
|
435
422
|
approved: false,
|
|
436
423
|
state: "denied",
|
|
@@ -439,7 +426,7 @@ describe("runGateCheck", () => {
|
|
|
439
426
|
});
|
|
440
427
|
const ctx: DenialContext = {
|
|
441
428
|
kind: "tool",
|
|
442
|
-
check: makeCheckResult("ask"),
|
|
429
|
+
check: makeCheckResult({ state: "ask", matchedPattern: "*" }),
|
|
443
430
|
};
|
|
444
431
|
const result = await runGateCheck(
|
|
445
432
|
makeDenialContextDescriptor(ctx),
|