1// @effect-diagnostics nodeBuiltinImport:off
2import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts";
3import type * as EffectAcpSchema from "effect-acp/compat";
4import * as NodeFS from "node:fs";
5import * as NodePath from "node:path";
6
7/**
8 * Runtime-policy decisions for ACP work that T3 mediates.
9 *
10 * ACP agents run their own tools under their own permission model and ask
11 * through `session/request_permission`, which T3 answers by policy. The one
12 * client-mediated path left is Devin's `terminal/*`, which runs with the T3
13 * server's own privileges; it resolves through {@link acpOperationDisposition}
14 * so a client terminal can never do more than an execute permission request.
15 */
16
17/** Structural subset of the adapter runtime policy that decisions read. */
18export interface AcpRuntimePolicy {
19 readonly runtimeMode: RuntimeMode;
20 readonly cwd: string | null;
21 readonly approvalPolicy?: unknown;
22 readonly sandboxPolicy?: unknown;
23}
24
25export type AcpPermissionDisposition = "allow" | "ask" | "deny";
26
27export function unknownRecord(value: unknown): Record<string, unknown> | undefined {
28 return typeof value === "object" && value !== null && !Array.isArray(value)
29 ? (value as Record<string, unknown>)
30 : undefined;
31}
32
33function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined {
34 const trimmed = path.trim();
35 if (trimmed.length === 0) return undefined;
36 if (NodePath.isAbsolute(trimmed)) return trimmed;
37 if (cwd === null || cwd.trim().length === 0) return undefined;
38 return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`;
39}
40
41function acpPathIsWithinRoot(path: string, root: string): boolean {
42 const relative = NodePath.relative(root, path);
43 return (
44 relative === "" ||
45 (relative !== ".." &&
46 !relative.startsWith(`..${NodePath.sep}`) &&
47 !NodePath.isAbsolute(relative))
48 );
49}
50
51/**
52 * Canonicalize a path for an authorization containment check.
53 *
54 * `realpath` cannot resolve a file that has not been created yet, so walk up
55 * to the deepest existing ancestor and append the missing suffix to that
56 * ancestor's canonical path. This follows symlinked directories while still
57 * allowing normal writes to new files. If an existing entry cannot be
58 * canonicalized (for example, a broken symlink), fail closed.
59 */
60function acpCanonicalPathForContainment(path: string): string | undefined {
61 // Do not lexically normalize before realpath. For a path such as
62 // `workspace/link/../file`, the kernel resolves `link` before `..`; an
63 // eager NodePath.resolve would erase that symlink traversal and could turn
64 // an outside target into an apparently in-workspace path.
65 let candidate = path;
66 const missingSuffix: Array<string> = [];
67
68 while (true) {
69 try {
70 return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix);
71 } catch (cause) {
72 if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
73 return undefined;
74 }
75 }
76
77 try {
78 NodeFS.lstatSync(candidate);
79 // The entry exists but realpath could not resolve it, as with a broken
80 // symlink. Treat it as untrusted rather than authorizing its lexical path.
81 return undefined;
82 } catch (cause) {
83 if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
84 return undefined;
85 }
86 }
87
88 const parent = NodePath.dirname(candidate);
89 if (parent === candidate) return undefined;
90 missingSuffix.unshift(NodePath.basename(candidate));
91 candidate = parent;
92 }
93}
94
95/** The parts of a native operation the disposition logic reads. */
96interface AcpPolicyOperation {
97 readonly kind: string | null | undefined;
98 readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined;
99}
100
101function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean {
102 return runtimePolicy.approvalPolicy === undefined
103 ? runtimePolicy.runtimeMode === "approval-required"
104 : runtimePolicy.approvalPolicy !== "never";
105}
106
107function isAcpReadKind(toolKind: string): boolean {
108 return toolKind === "read" || toolKind === "search" || toolKind === "think";
109}
110
111/**
112 * Reads follow the sandbox alone and never ask. Approval policy governs writes
113 * and commands, as it does for Codex and Claude, and every sandbox T3 knows
114 * lets the agent read. That includes no explicit sandbox, since the strictest
115 * runtime mode (approval-required) implies a read-only one. Unknown sandbox
116 * types still fail closed.
117 */
118function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" {
119 switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) {
120 case undefined:
121 case "readOnly":
122 case "workspaceWrite":
123 case "dangerFullAccess":
124 case "externalSandbox":
125 return "allow";
126 default:
127 return "deny";
128 }
129}
130
131function acpWorkspaceWriteAllowsMutation(
132 runtimePolicy: AcpRuntimePolicy,
133 sandboxPolicy: Record<string, unknown>,
134 locations: AcpPolicyOperation["locations"],
135): boolean {
136 const cwd =
137 typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0
138 ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null)
139 : null;
140 const roots: Array<string> = [];
141 if (cwd !== null) {
142 const canonicalCwd = acpCanonicalPathForContainment(cwd);
143 if (canonicalCwd !== undefined) roots.push(canonicalCwd);
144 }
145 const writableRoots = sandboxPolicy.writableRoots;
146 if (Array.isArray(writableRoots)) {
147 for (const writableRoot of writableRoots) {
148 if (typeof writableRoot !== "string") continue;
149 const resolved = resolveAcpPermissionPath(writableRoot, cwd);
150 if (resolved === undefined) continue;
151 const canonicalRoot = acpCanonicalPathForContainment(resolved);
152 if (canonicalRoot !== undefined) roots.push(canonicalRoot);
153 }
154 }
155 if (roots.length === 0) return false;
156
157 if (locations === undefined || locations === null || locations.length === 0) {
158 return false;
159 }
160 for (const location of locations) {
161 const resolved = resolveAcpPermissionPath(location.path, cwd);
162 const canonicalPath =
163 resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved);
164 if (
165 canonicalPath === undefined ||
166 !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root))
167 ) {
168 return false;
169 }
170 }
171 return true;
172}
173
174/**
175 * Disposition of one native operation under the active runtime policy. Shared
176 * by provider permission requests and the client fs/terminal handlers.
177 */
178function acpOperationDisposition(
179 runtimePolicy: AcpRuntimePolicy,
180 operation: AcpPolicyOperation,
181): AcpPermissionDisposition {
182 const toolKind = operation.kind ?? "other";
183 if (isAcpReadKind(toolKind)) {
184 return acpReadDisposition(runtimePolicy);
185 }
186 if (acpPolicyRequiresApproval(runtimePolicy)) {
187 return "ask";
188 }
189
190 const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy);
191 switch (sandboxPolicy?.type) {
192 case "readOnly":
193 return "deny";
194 case "workspaceWrite":
195 if (isAcpMutationKind(toolKind)) {
196 return acpWorkspaceWriteAllowsMutation(
197 runtimePolicy,
198 sandboxPolicy ?? {},
199 operation.locations,
200 )
201 ? "allow"
202 : "deny";
203 }
204 return "deny";
205 case "dangerFullAccess":
206 case "externalSandbox":
207 return "allow";
208 case undefined:
209 if (runtimePolicy.runtimeMode === "approval-required") return "deny";
210 // Auto-accept edits approves file changes wherever the agent makes them
211 // (ACP prompts need not carry locations to confine); other actions still ask.
212 if (
213 runtimePolicy.runtimeMode === "auto-accept-edits" &&
214 runtimePolicy.approvalPolicy === undefined &&
215 !isAcpMutationKind(toolKind)
216 ) {
217 return "ask";
218 }
219 return "allow";
220 default:
221 return "deny";
222 }
223}
224
225function isAcpMutationKind(toolKind: string): boolean {
226 return toolKind === "edit" || toolKind === "delete" || toolKind === "move";
227}
228
229export function acpPermissionDisposition(
230 runtimePolicy: AcpRuntimePolicy,
231 request: EffectAcpSchema.RequestPermissionRequest,
232): AcpPermissionDisposition {
233 return acpOperationDisposition(runtimePolicy, {
234 kind: request.toolCall.kind,
235 locations: request.toolCall.locations,
236 });
237}
238
239/** Resolve explicitly tagged MCP approvals through the thread's normal policy. */
240export function acpMcpToolApprovalElicitationDisposition(
241 runtimePolicy: AcpRuntimePolicy,
242 request: EffectAcpSchema.CreateElicitationRequest,
243 nativeRequestId?: string,
244): AcpPermissionDisposition | undefined {
245 if (
246 request.mode !== "form" ||
247 (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" &&
248 nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true)
249 ) {
250 return undefined;
251 }
252 // This request comes from T3's authenticated, scope-checked MCP endpoint,
253 // not an arbitrary provider command. Let explicit approval mode surface it
254 // to the user and otherwise allow the endpoint to enforce its own policy.
255 return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow";
256}
257
258/** Disposition of a client-mediated `terminal/create` (Devin's client terminals). */
259export function acpClientExecuteDisposition(
260 runtimePolicy: AcpRuntimePolicy,
261): AcpPermissionDisposition {
262 return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined });
263}
264
265export interface AcpApprovalGrantInput {
266 readonly kind: ProviderRequestKind;
267 readonly scope: "session" | "turn";
268 readonly turnKey: string;
269}
270
271export interface AcpClientPolicyGrants {
272 readonly recordApproval: (input: AcpApprovalGrantInput) => void;
273 readonly allowsExecute: (turnKey: string | null) => boolean;
274}
275
276/**
277 * Command approvals recorded when the user accepts a `session/request_permission`,
278 * so an "ask" disposition at the client terminal boundary honors a command the
279 * user already approved. ACP does not link an approved tool call to the
280 * terminals the agent then creates, so an approved command authorizes client
281 * terminals for the approving turn, or for the session on accept-for-session.
282 */
283export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants {
284 let sessionExecute = false;
285 let turnExecute: string | null = null;
286 return {
287 recordApproval: (input) => {
288 if (input.kind !== "command") return;
289 if (input.scope === "session") {
290 sessionExecute = true;
291 } else {
292 turnExecute = input.turnKey;
293 }
294 },
295 allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey),
296 };
297}