Skip to content
Source
Sign inSign up
Open History to see the last commit

clientPolicy.test.ts

348 lines12.3 KB

1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, describe, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import type * as EffectAcpSchema from "effect-acp/compat";
8import * as NodePath from "node:path";
9​
10import {
11 acpClientExecuteDisposition,
12 acpMcpToolApprovalElicitationDisposition,
13 acpPermissionDisposition,
14 makeAcpClientPolicyGrants,
15 type AcpRuntimePolicy,
16} from "./clientPolicy.ts";
17​
18function permissionRequest(
19 kind: NonNullable<EffectAcpSchema.RequestPermissionRequest["toolCall"]["kind"]>,
20 locations?: ReadonlyArray<EffectAcpSchema.ToolCallLocation>,
21): EffectAcpSchema.RequestPermissionRequest {
22 return {
23 options: [],
24 sessionId: "permission-session",
25 toolCall: {
26 kind,
27 ...(locations === undefined ? {} : { locations }),
28 toolCallId: "permission-tool-call",
29 },
30 };
32​
33describe("acpPermissionDisposition", () => {
34 const cwd = NodePath.resolve(process.cwd(), "acp-permission-workspace");
35 const writableRoot = NodePath.resolve(process.cwd(), "acp-additional-writable-root");
36 const policy: AcpRuntimePolicy = {
37 runtimeMode: "full-access",
38 cwd,
39 approvalPolicy: "never",
40 sandboxPolicy: {
41 type: "workspaceWrite",
42 writableRoots: [writableRoot],
43 networkAccess: false,
44 },
45 };
46​
47 it("auto-allows mutations only when every location is in cwd or an additional writable root", () => {
48 assert.equal(
49 acpPermissionDisposition(policy, permissionRequest("edit", [{ path: "src/index.ts" }])),
50 "allow",
51 );
52 assert.equal(
53 acpPermissionDisposition(
54 policy,
55 permissionRequest("delete", [{ path: NodePath.join(writableRoot, "generated.ts") }]),
56 ),
57 "allow",
58 );
59 assert.equal(
60 acpPermissionDisposition(
61 policy,
62 permissionRequest("move", [
63 { path: NodePath.join(cwd, "from.ts") },
64 { path: NodePath.join(writableRoot, "to.ts") },
65 ]),
66 ),
67 "allow",
68 );
69 });
70​
71 it("denies missing or out-of-root mutation locations", () => {
72 const outside = NodePath.resolve(process.cwd(), "outside-acp-permission-workspace", "file.ts");
73 assert.equal(acpPermissionDisposition(policy, permissionRequest("edit")), "deny");
74 assert.equal(
75 acpPermissionDisposition(policy, permissionRequest("delete", [{ path: "../escape.ts" }])),
76 "deny",
77 );
78 assert.equal(
79 acpPermissionDisposition(
80 policy,
81 permissionRequest("move", [{ path: NodePath.join(cwd, "inside.ts") }, { path: outside }]),
82 ),
83 "deny",
84 );
85 });
86​
87 it("keeps non-mutating workspace permissions and denials unchanged", () => {
88 assert.equal(acpPermissionDisposition(policy, permissionRequest("read")), "allow");
89 assert.equal(acpPermissionDisposition(policy, permissionRequest("execute")), "deny");
90 });
91​
92 it("auto-accept-edits approves file changes without locations and asks for the rest", () => {
93 const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd };
94 for (const kind of ["edit", "delete", "move"] as const) {
95 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow");
96 }
97 for (const kind of ["execute", "fetch", "other"] as const) {
98 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask");
99 }
100 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow");
101 assert.equal(
102 acpPermissionDisposition(
103 { ...autoAcceptEdits, approvalPolicy: "on-request" },
104 permissionRequest("edit"),
105 ),
106 "ask",
107 );
108 });
109​
110 it("auto-allows read-kind permission requests under on-request approval", () => {
111 for (const runtimePolicy of [
112 { ...policy, approvalPolicy: "on-request" },
113 { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } },
114 { runtimeMode: "approval-required", cwd },
115 ] satisfies ReadonlyArray<AcpRuntimePolicy>) {
116 for (const kind of ["read", "search", "think"] as const) {
117 assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow");
118 }
119 for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) {
120 assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask");
121 }
122 }
123 });
124​
125 it.effect("denies mutations through workspace symlinks that escape the writable roots", () =>
126 Effect.gen(function* () {
127 const fileSystem = yield* FileSystem.FileSystem;
128 const path = yield* Path.Path;
129 const workspace = yield* fileSystem.makeTempDirectoryScoped({
130 prefix: "t3-acp-permission-workspace-",
131 });
132 const outside = yield* fileSystem.makeTempDirectoryScoped({
133 prefix: "t3-acp-permission-outside-",
134 });
135 const outsideFile = path.join(outside, "existing.ts");
136 yield* fileSystem.writeFileString(outsideFile, "outside");
137 yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts"));
138 yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory"));
139​
140 const realPolicy: AcpRuntimePolicy = {
141 runtimeMode: "full-access",
142 cwd: workspace,
143 approvalPolicy: "never",
144 sandboxPolicy: {
145 type: "workspaceWrite",
146 writableRoots: [],
147 networkAccess: false,
148 },
149 };
150​
151 assert.equal(
152 acpPermissionDisposition(
153 realPolicy,
154 permissionRequest("edit", [{ path: "linked-file.ts" }]),
155 ),
156 "deny",
157 );
158 assert.equal(
159 acpPermissionDisposition(
160 realPolicy,
161 permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]),
162 ),
163 "deny",
164 "a missing leaf below an escaping directory symlink must not be auto-approved",
165 );
166 assert.equal(
167 acpPermissionDisposition(
168 realPolicy,
169 permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]),
170 ),
171 "deny",
172 "physical symlink traversal must be resolved before parent segments",
173 );
174 }).pipe(Effect.provide(NodeServices.layer)),
175 );
176​
177 it.effect("allows existing and new files beneath canonical writable roots", () =>
178 Effect.gen(function* () {
179 const fileSystem = yield* FileSystem.FileSystem;
180 const path = yield* Path.Path;
181 const workspace = yield* fileSystem.makeTempDirectoryScoped({
182 prefix: "t3-acp-permission-workspace-",
183 });
184 const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({
185 prefix: "t3-acp-permission-link-parent-",
186 });
187 const workspaceLink = path.join(workspaceLinkParent, "workspace-link");
188 yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true });
189 yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing");
190 yield* fileSystem.symlink(workspace, workspaceLink);
191​
192 const realPolicy: AcpRuntimePolicy = {
193 runtimeMode: "full-access",
194 cwd: workspaceLink,
195 approvalPolicy: "never",
196 sandboxPolicy: {
197 type: "workspaceWrite",
198 writableRoots: [],
199 networkAccess: false,
200 },
201 };
202​
203 assert.equal(
204 acpPermissionDisposition(
205 realPolicy,
206 permissionRequest("edit", [{ path: "src/existing.ts" }]),
207 ),
208 "allow",
209 );
210 assert.equal(
211 acpPermissionDisposition(
212 realPolicy,
213 permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]),
214 ),
215 "allow",
216 "non-existent descendants of a real in-root ancestor remain writable",
217 );
218 }).pipe(Effect.provide(NodeServices.layer)),
219 );
220});
221​
222describe("acpMcpToolApprovalElicitationDisposition", () => {
223 it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => {
224 const fullAccess: AcpRuntimePolicy = {
225 runtimeMode: "full-access",
226 cwd: process.cwd(),
227 };
228 const approvalRequired: AcpRuntimePolicy = {
229 runtimeMode: "approval-required",
230 cwd: process.cwd(),
231 };
232 const tagged = {
233 sessionId: "session-1",
234 message: "Approve this request?",
235 mode: "form",
236 requestedSchema: { type: "object", properties: {} },
237 _meta: { codex_approval_kind: "mcp_tool_call" },
238 } satisfies EffectAcpSchema.CreateElicitationRequest;
239​
240 assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow");
241 assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask");
242 assert.equal(
243 acpMcpToolApprovalElicitationDisposition(
244 { ...fullAccess, approvalPolicy: "on-request" },
245 tagged,
246 ),
247 "ask",
248 );
249 assert.equal(
250 acpMcpToolApprovalElicitationDisposition(
251 {
252 runtimeMode: "auto-accept-edits",
253 cwd: process.cwd(),
254 approvalPolicy: "never",
255 sandboxPolicy: {
256 type: "workspaceWrite",
257 writableRoots: [],
258 networkAccess: false,
259 },
260 },
261 tagged,
262 ),
263 "allow",
264 );
265 const { _meta: _tag, ...untagged } = tagged;
266 assert.equal(
267 acpMcpToolApprovalElicitationDisposition(
268 fullAccess,
269 untagged,
270 "mcp_tool_call_approval_exec-123",
271 ),
272 "allow",
273 );
274 assert.isUndefined(
275 acpMcpToolApprovalElicitationDisposition(fullAccess, {
276 ...tagged,
277 _meta: { codex_approval_kind: "ordinary_form" },
278 }),
279 );
280 assert.isUndefined(
281 acpMcpToolApprovalElicitationDisposition(fullAccess, {
282 sessionId: "session-1",
283 message: "Authenticate",
284 mode: "url",
285 elicitationId: "elicitation-1",
286 url: "https://example.com/login",
287 _meta: { codex_approval_kind: "mcp_tool_call" },
288 }),
289 );
290 });
291});
292​
293describe("client terminal disposition", () => {
294 const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace");
295​
296 it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => {
297 assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask");
298 assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask");
299 assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow");
300 assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow");
301 });
302​
303 it("denies terminals under explicit read-only and workspace-write sandboxes", () => {
304 for (const sandboxPolicy of [
305 { type: "readOnly" },
306 { type: "workspaceWrite", writableRoots: [], networkAccess: false },
307 ]) {
308 assert.equal(
309 acpClientExecuteDisposition({
310 runtimeMode: "full-access",
311 cwd,
312 approvalPolicy: "never",
313 sandboxPolicy,
314 }),
315 "deny",
316 );
317 }
318 });
319});
320​
321describe("makeAcpClientPolicyGrants", () => {
322 it("grants terminals only from execute approvals, never from any other approved kind", () => {
323 const grants = makeAcpClientPolicyGrants();
324 for (const toolKind of ["read", "edit", "switch_mode", "other", "unknown", "browse"]) {
325 grants.recordApproval({ toolKind, scope: "session", turnKey: "turn-1" });
326 }
327 assert.isFalse(grants.allowsExecute("turn-1"));
328 grants.recordApproval({ toolKind: "execute", scope: "turn", turnKey: "turn-1" });
329 assert.isTrue(grants.allowsExecute("turn-1"));
330 assert.isFalse(grants.allowsExecute("turn-2"));
331 assert.isFalse(grants.allowsExecute(null));
332 });
333​
334 it("keeps accept-for-session command grants across turns", () => {
335 const grants = makeAcpClientPolicyGrants();
336 grants.recordApproval({ toolKind: "execute", scope: "session", turnKey: "turn-1" });
337 assert.isTrue(grants.allowsExecute("turn-9"));
338 assert.isTrue(grants.allowsExecute(null));
339 });
340​
341 it("drops a turn's command grant when a later turn approves one", () => {
342 const grants = makeAcpClientPolicyGrants();
343 grants.recordApproval({ toolKind: "execute", scope: "turn", turnKey: "turn-1" });
344 grants.recordApproval({ toolKind: "execute", scope: "turn", turnKey: "turn-2" });
345 assert.isFalse(grants.allowsExecute("turn-1"));
346 assert.isTrue(grants.allowsExecute("turn-2"));
347 });
348});