| 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import type { ProviderRequestKind, RuntimeMode } from "@t3tools/contracts"; |
| 3 | import type * as EffectAcpSchema from "effect-acp/compat"; |
| 4 | import * as NodeFS from "node:fs"; |
| 5 | import * 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. */ |
| 18 | export interface AcpRuntimePolicy { |
| 19 | readonly runtimeMode: RuntimeMode; |
| 20 | readonly cwd: string | null; |
| 21 | readonly approvalPolicy?: unknown; |
| 22 | readonly sandboxPolicy?: unknown; |
| 23 | } |
| 24 | |
| 25 | export type AcpPermissionDisposition = "allow" | "ask" | "deny"; |
| 26 | |
| 27 | export 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 | |
| 33 | function 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 | |
| 41 | function 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 | */ |
| 60 | function 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. */ |
| 96 | interface AcpPolicyOperation { |
| 97 | readonly kind: string | null | undefined; |
| 98 | readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined; |
| 99 | } |
| 100 | |
| 101 | function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean { |
| 102 | return runtimePolicy.approvalPolicy === undefined |
| 103 | ? runtimePolicy.runtimeMode === "approval-required" |
| 104 | : runtimePolicy.approvalPolicy !== "never"; |
| 105 | } |
| 106 | |
| 107 | function 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 | */ |
| 118 | function 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 | |
| 131 | function 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 | */ |
| 178 | function 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 | |
| 225 | function isAcpMutationKind(toolKind: string): boolean { |
| 226 | return toolKind === "edit" || toolKind === "delete" || toolKind === "move"; |
| 227 | } |
| 228 | |
| 229 | export 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. */ |
| 240 | export 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). */ |
| 259 | export function acpClientExecuteDisposition( |
| 260 | runtimePolicy: AcpRuntimePolicy, |
| 261 | ): AcpPermissionDisposition { |
| 262 | return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined }); |
| 263 | } |
| 264 | |
| 265 | export interface AcpApprovalGrantInput { |
| 266 | readonly kind: ProviderRequestKind; |
| 267 | readonly scope: "session" | "turn"; |
| 268 | readonly turnKey: string; |
| 269 | } |
| 270 | |
| 271 | export 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 | */ |
| 283 | export 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 | } |