Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off
2import type { 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
7export interface AcpRuntimePolicy {
8 readonly runtimeMode: RuntimeMode;
9 readonly cwd: string | null;
10 readonly approvalPolicy?: unknown;
11 readonly sandboxPolicy?: unknown;
12}
13
14export type AcpPermissionDisposition = "allow" | "ask" | "deny";
15
16export function unknownRecord(value: unknown): Record<string, unknown> | undefined {
17 return typeof value === "object" && value !== null && !Array.isArray(value)
18 ? (value as Record<string, unknown>)
19 : undefined;
20}
21
22function resolveAcpPermissionPath(path: string, cwd: string | null): string | undefined {
23 const trimmed = path.trim();
24 if (trimmed.length === 0) return undefined;
25 if (NodePath.isAbsolute(trimmed)) return trimmed;
26 if (cwd === null || cwd.trim().length === 0) return undefined;
27 return `${cwd}${cwd.endsWith(NodePath.sep) ? "" : NodePath.sep}${trimmed}`;
28}
29
30function acpPathIsWithinRoot(path: string, root: string): boolean {
31 const relative = NodePath.relative(root, path);
32 return (
33 relative === "" ||
34 (relative !== ".." &&
35 !relative.startsWith(`..${NodePath.sep}`) &&
36 !NodePath.isAbsolute(relative))
37 );
38}
39
40// Canonicalize existing ancestors for symlink-aware checks of new paths; fail closed on errors.
41function acpCanonicalPathForContainment(path: string): string | undefined {
42 // realpath must resolve symlinks before ..; lexical normalization can hide traversal.
43 let candidate = path;
44 const missingSuffix: Array<string> = [];
45
46 while (true) {
47 try {
48 return NodePath.resolve(NodeFS.realpathSync.native(candidate), ...missingSuffix);
49 } catch (cause) {
50 if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
51 return undefined;
52 }
53 }
54
55 try {
56 NodeFS.lstatSync(candidate);
57 return undefined;
58 } catch (cause) {
59 if ((cause as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") {
60 return undefined;
61 }
62 }
63
64 const parent = NodePath.dirname(candidate);
65 if (parent === candidate) return undefined;
66 missingSuffix.unshift(NodePath.basename(candidate));
67 candidate = parent;
68 }
69}
70
71interface AcpPolicyOperation {
72 readonly kind: string | null | undefined;
73 readonly locations: ReadonlyArray<{ readonly path: string }> | null | undefined;
74}
75
76function acpPolicyRequiresApproval(runtimePolicy: AcpRuntimePolicy): boolean {
77 return runtimePolicy.approvalPolicy === undefined
78 ? runtimePolicy.runtimeMode === "approval-required"
79 : runtimePolicy.approvalPolicy !== "never";
80}
81
82function isAcpReadKind(toolKind: string): boolean {
83 return toolKind === "read" || toolKind === "search" || toolKind === "think";
84}
85
86/** Reads never ask; an absent sandbox implies read-only in approval-required mode. */
87function acpReadDisposition(runtimePolicy: AcpRuntimePolicy): "allow" | "deny" {
88 switch (unknownRecord(runtimePolicy.sandboxPolicy)?.type) {
89 case undefined:
90 case "readOnly":
91 case "workspaceWrite":
92 case "dangerFullAccess":
93 case "externalSandbox":
94 return "allow";
95 default:
96 return "deny";
97 }
98}
99
100function acpWorkspaceWriteAllowsMutation(
101 runtimePolicy: AcpRuntimePolicy,
102 sandboxPolicy: Record<string, unknown>,
103 locations: AcpPolicyOperation["locations"],
104): boolean {
105 const cwd =
106 typeof runtimePolicy.cwd === "string" && runtimePolicy.cwd.trim().length > 0
107 ? (resolveAcpPermissionPath(runtimePolicy.cwd, process.cwd()) ?? null)
108 : null;
109 const roots: Array<string> = [];
110 if (cwd !== null) {
111 const canonicalCwd = acpCanonicalPathForContainment(cwd);
112 if (canonicalCwd !== undefined) roots.push(canonicalCwd);
113 }
114 const writableRoots = sandboxPolicy.writableRoots;
115 if (Array.isArray(writableRoots)) {
116 for (const writableRoot of writableRoots) {
117 if (typeof writableRoot !== "string") continue;
118 const resolved = resolveAcpPermissionPath(writableRoot, cwd);
119 if (resolved === undefined) continue;
120 const canonicalRoot = acpCanonicalPathForContainment(resolved);
121 if (canonicalRoot !== undefined) roots.push(canonicalRoot);
122 }
123 }
124 if (roots.length === 0) return false;
125
126 if (locations === undefined || locations === null || locations.length === 0) {
127 return false;
128 }
129 for (const location of locations) {
130 const resolved = resolveAcpPermissionPath(location.path, cwd);
131 const canonicalPath =
132 resolved === undefined ? undefined : acpCanonicalPathForContainment(resolved);
133 if (
134 canonicalPath === undefined ||
135 !roots.some((root) => acpPathIsWithinRoot(canonicalPath, root))
136 ) {
137 return false;
138 }
139 }
140 return true;
141}
142
143function acpOperationDisposition(
144 runtimePolicy: AcpRuntimePolicy,
145 operation: AcpPolicyOperation,
146): AcpPermissionDisposition {
147 const toolKind = operation.kind ?? "other";
148 if (isAcpReadKind(toolKind)) {
149 return acpReadDisposition(runtimePolicy);
150 }
151 if (acpPolicyRequiresApproval(runtimePolicy)) {
152 return "ask";
153 }
154
155 const sandboxPolicy = unknownRecord(runtimePolicy.sandboxPolicy);
156 switch (sandboxPolicy?.type) {
157 case "readOnly":
158 return "deny";
159 case "workspaceWrite":
160 if (isAcpMutationKind(toolKind)) {
161 return acpWorkspaceWriteAllowsMutation(
162 runtimePolicy,
163 sandboxPolicy ?? {},
164 operation.locations,
165 )
166 ? "allow"
167 : "deny";
168 }
169 return "deny";
170 case "dangerFullAccess":
171 case "externalSandbox":
172 return "allow";
173 case undefined:
174 if (runtimePolicy.runtimeMode === "approval-required") return "deny";
175 if (
176 runtimePolicy.runtimeMode === "auto-accept-edits" &&
177 runtimePolicy.approvalPolicy === undefined &&
178 !isAcpMutationKind(toolKind)
179 ) {
180 return "ask";
181 }
182 return "allow";
183 default:
184 return "deny";
185 }
186}
187
188function isAcpMutationKind(toolKind: string): boolean {
189 return toolKind === "edit" || toolKind === "delete" || toolKind === "move";
190}
191
192export function acpPermissionDisposition(
193 runtimePolicy: AcpRuntimePolicy,
194 request: EffectAcpSchema.RequestPermissionRequest,
195): AcpPermissionDisposition {
196 return acpOperationDisposition(runtimePolicy, {
197 kind: request.toolCall.kind,
198 locations: request.toolCall.locations,
199 });
200}
201
202export function acpMcpToolApprovalElicitationDisposition(
203 runtimePolicy: AcpRuntimePolicy,
204 request: EffectAcpSchema.CreateElicitationRequest,
205 nativeRequestId?: string,
206): "ask" | "allow" | undefined {
207 if (
208 request.mode !== "form" ||
209 (unknownRecord(request._meta)?.codex_approval_kind !== "mcp_tool_call" &&
210 nativeRequestId?.startsWith("mcp_tool_call_approval_") !== true)
211 ) {
212 return undefined;
213 }
214 // T3's authenticated MCP endpoint enforces scope; only explicit approval mode needs a user prompt.
215 return acpPolicyRequiresApproval(runtimePolicy) ? "ask" : "allow";
216}
217
218export function acpClientExecuteDisposition(
219 runtimePolicy: AcpRuntimePolicy,
220): AcpPermissionDisposition {
221 return acpOperationDisposition(runtimePolicy, { kind: "execute", locations: undefined });
222}
223
224export interface AcpApprovalGrantInput {
225 /** The approved request's ACP tool kind, after ID-only requests take their tracked call's kind. */
226 readonly toolKind: string;
227 readonly scope: "session" | "turn";
228 readonly turnKey: string;
229}
230
231export interface AcpClientPolicyGrants {
232 readonly recordApproval: (input: AcpApprovalGrantInput) => void;
233 readonly allowsExecute: (turnKey: string | null) => boolean;
234}
235
236/** ACP cannot link commands to terminals; approved execute requests grant execution for the turn or accepted session. */
237export function makeAcpClientPolicyGrants(): AcpClientPolicyGrants {
238 let sessionExecute = false;
239 let turnExecute: string | null = null;
240 return {
241 recordApproval: (input) => {
242 // Other kinds also show as command cards, but approving them must not open terminals.
243 if (input.toolKind !== "execute") return;
244 if (input.scope === "session") {
245 sessionExecute = true;
246 } else {
247 turnExecute = input.turnKey;
248 }
249 },
250 allowsExecute: (turnKey) => sessionExecute || (turnKey !== null && turnExecute === turnKey),
251 };
252}