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 };
31}
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 // ACP permission requests need not carry locations.
94 const autoAcceptEdits: AcpRuntimePolicy = { runtimeMode: "auto-accept-edits", cwd };
95 for (const kind of ["edit", "delete", "move"] as const) {
96 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "allow");
97 }
98 for (const kind of ["execute", "fetch", "other"] as const) {
99 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest(kind)), "ask");
100 }
101 assert.equal(acpPermissionDisposition(autoAcceptEdits, permissionRequest("read")), "allow");
102 assert.equal(
103 acpPermissionDisposition(
104 { ...autoAcceptEdits, approvalPolicy: "on-request" },
105 permissionRequest("edit"),
106 ),
107 "ask",
108 );
109 });
110
111 it("auto-allows read-kind permission requests under on-request approval", () => {
112 for (const runtimePolicy of [
113 { ...policy, approvalPolicy: "on-request" },
114 { ...policy, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly" } },
115 { runtimeMode: "approval-required", cwd },
116 ] satisfies ReadonlyArray<AcpRuntimePolicy>) {
117 for (const kind of ["read", "search", "think"] as const) {
118 assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "allow");
119 }
120 for (const kind of ["edit", "delete", "move", "execute", "fetch", "other"] as const) {
121 assert.equal(acpPermissionDisposition(runtimePolicy, permissionRequest(kind)), "ask");
122 }
123 }
124 });
125
126 it.effect("denies mutations through workspace symlinks that escape the writable roots", () =>
127 Effect.gen(function* () {
128 const fileSystem = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;
130 const workspace = yield* fileSystem.makeTempDirectoryScoped({
131 prefix: "t3-acp-permission-workspace-",
132 });
133 const outside = yield* fileSystem.makeTempDirectoryScoped({
134 prefix: "t3-acp-permission-outside-",
135 });
136 const outsideFile = path.join(outside, "existing.ts");
137 yield* fileSystem.writeFileString(outsideFile, "outside");
138 yield* fileSystem.symlink(outsideFile, path.join(workspace, "linked-file.ts"));
139 yield* fileSystem.symlink(outside, path.join(workspace, "linked-directory"));
140
141 const realPolicy: AcpRuntimePolicy = {
142 runtimeMode: "full-access",
143 cwd: workspace,
144 approvalPolicy: "never",
145 sandboxPolicy: {
146 type: "workspaceWrite",
147 writableRoots: [],
148 networkAccess: false,
149 },
150 };
151
152 assert.equal(
153 acpPermissionDisposition(
154 realPolicy,
155 permissionRequest("edit", [{ path: "linked-file.ts" }]),
156 ),
157 "deny",
158 );
159 assert.equal(
160 acpPermissionDisposition(
161 realPolicy,
162 permissionRequest("edit", [{ path: "linked-directory/new-file.ts" }]),
163 ),
164 "deny",
165 "a missing leaf below an escaping directory symlink must not be auto-approved",
166 );
167 assert.equal(
168 acpPermissionDisposition(
169 realPolicy,
170 permissionRequest("edit", [{ path: "linked-directory/../escaped-file.ts" }]),
171 ),
172 "deny",
173 "physical symlink traversal must be resolved before parent segments",
174 );
175 }).pipe(Effect.provide(NodeServices.layer)),
176 );
177
178 it.effect("allows existing and new files beneath canonical writable roots", () =>
179 Effect.gen(function* () {
180 const fileSystem = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const workspace = yield* fileSystem.makeTempDirectoryScoped({
183 prefix: "t3-acp-permission-workspace-",
184 });
185 const workspaceLinkParent = yield* fileSystem.makeTempDirectoryScoped({
186 prefix: "t3-acp-permission-link-parent-",
187 });
188 const workspaceLink = path.join(workspaceLinkParent, "workspace-link");
189 yield* fileSystem.makeDirectory(path.join(workspace, "src"), { recursive: true });
190 yield* fileSystem.writeFileString(path.join(workspace, "src", "existing.ts"), "existing");
191 yield* fileSystem.symlink(workspace, workspaceLink);
192
193 const realPolicy: AcpRuntimePolicy = {
194 runtimeMode: "full-access",
195 cwd: workspaceLink,
196 approvalPolicy: "never",
197 sandboxPolicy: {
198 type: "workspaceWrite",
199 writableRoots: [],
200 networkAccess: false,
201 },
202 };
203
204 assert.equal(
205 acpPermissionDisposition(
206 realPolicy,
207 permissionRequest("edit", [{ path: "src/existing.ts" }]),
208 ),
209 "allow",
210 );
211 assert.equal(
212 acpPermissionDisposition(
213 realPolicy,
214 permissionRequest("edit", [{ path: "src/generated/new-file.ts" }]),
215 ),
216 "allow",
217 "non-existent descendants of a real in-root ancestor remain writable",
218 );
219 }).pipe(Effect.provide(NodeServices.layer)),
220 );
221});
222
223describe("acpMcpToolApprovalElicitationDisposition", () => {
224 it("applies runtime policy only to explicitly tagged MCP approval elicitations", () => {
225 const fullAccess: AcpRuntimePolicy = {
226 runtimeMode: "full-access",
227 cwd: process.cwd(),
228 };
229 const approvalRequired: AcpRuntimePolicy = {
230 runtimeMode: "approval-required",
231 cwd: process.cwd(),
232 };
233 const tagged = {
234 sessionId: "session-1",
235 message: "Approve this request?",
236 mode: "form",
237 requestedSchema: { type: "object", properties: {} },
238 _meta: { codex_approval_kind: "mcp_tool_call" },
239 } satisfies EffectAcpSchema.CreateElicitationRequest;
240
241 assert.equal(acpMcpToolApprovalElicitationDisposition(fullAccess, tagged), "allow");
242 assert.equal(acpMcpToolApprovalElicitationDisposition(approvalRequired, tagged), "ask");
243 assert.equal(
244 acpMcpToolApprovalElicitationDisposition(
245 { ...fullAccess, approvalPolicy: "on-request" },
246 tagged,
247 ),
248 "ask",
249 );
250 assert.equal(
251 acpMcpToolApprovalElicitationDisposition(
252 {
253 runtimeMode: "auto-accept-edits",
254 cwd: process.cwd(),
255 approvalPolicy: "never",
256 sandboxPolicy: {
257 type: "workspaceWrite",
258 writableRoots: [],
259 networkAccess: false,
260 },
261 },
262 tagged,
263 ),
264 "allow",
265 );
266 const { _meta: _tag, ...untagged } = tagged;
267 assert.equal(
268 acpMcpToolApprovalElicitationDisposition(
269 fullAccess,
270 untagged,
271 "mcp_tool_call_approval_exec-123",
272 ),
273 "allow",
274 );
275 assert.isUndefined(
276 acpMcpToolApprovalElicitationDisposition(fullAccess, {
277 ...tagged,
278 _meta: { codex_approval_kind: "ordinary_form" },
279 }),
280 );
281 assert.isUndefined(
282 acpMcpToolApprovalElicitationDisposition(fullAccess, {
283 sessionId: "session-1",
284 message: "Authenticate",
285 mode: "url",
286 elicitationId: "elicitation-1",
287 url: "https://example.com/login",
288 _meta: { codex_approval_kind: "mcp_tool_call" },
289 }),
290 );
291 });
292});
293
294describe("client terminal disposition", () => {
295 const cwd = NodePath.resolve(process.cwd(), "acp-client-policy-workspace");
296
297 it("asks in approval-required and auto-accept-edits, allows in auto and full access", () => {
298 assert.equal(acpClientExecuteDisposition({ runtimeMode: "approval-required", cwd }), "ask");
299 assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto-accept-edits", cwd }), "ask");
300 assert.equal(acpClientExecuteDisposition({ runtimeMode: "auto", cwd }), "allow");
301 assert.equal(acpClientExecuteDisposition({ runtimeMode: "full-access", cwd }), "allow");
302 });
303
304 it("denies terminals under explicit read-only and workspace-write sandboxes", () => {
305 for (const sandboxPolicy of [
306 { type: "readOnly" },
307 { type: "workspaceWrite", writableRoots: [], networkAccess: false },
308 ]) {
309 assert.equal(
310 acpClientExecuteDisposition({
311 runtimeMode: "full-access",
312 cwd,
313 approvalPolicy: "never",
314 sandboxPolicy,
315 }),
316 "deny",
317 );
318 }
319 });
320});
321
322describe("makeAcpClientPolicyGrants", () => {
323 it("grants terminals only from commands, never from an approved read or edit", () => {
324 const grants = makeAcpClientPolicyGrants();
325 grants.recordApproval({ kind: "file-read", scope: "turn", turnKey: "turn-1" });
326 grants.recordApproval({ kind: "file-change", scope: "session", turnKey: "turn-1" });
327 assert.isFalse(grants.allowsExecute("turn-1"));
328 grants.recordApproval({ kind: "command", 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({ kind: "command", 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({ kind: "command", scope: "turn", turnKey: "turn-1" });
344 grants.recordApproval({ kind: "command", scope: "turn", turnKey: "turn-2" });
345 assert.isFalse(grants.allowsExecute("turn-1"));
346 assert.isTrue(grants.allowsExecute("turn-2"));
347 });
348});