1import * as Effect from "effect/Effect";
2import type * as FileSystem from "effect/FileSystem";
3import type * as Path from "effect/Path";
4import * as EffectAcpErrors from "effect-acp/errors";
5import type * as EffectAcpSchema from "effect-acp/compat";
6
7/**
8 * Workspace file access requested through the ACP client `fs` capability.
9 * The agent gates each write behind `session/request_permission`, so only
10 * path containment is checked here.
11 */
12const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
13
14function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean {
15 const relative = path.relative(root, candidate);
16 return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
17}
18
19/**
20 * Resolves an agent-supplied path and rejects anything outside the session
21 * roots. Symlinks resolve before the check, including the final component, so
22 * a link inside the workspace cannot read or write through to a file outside
23 * it. An entry that exists but cannot be resolved, like a dangling link, is
24 * rejected rather than written through.
25 */
26const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFilePath")(
27 function* (input: {
28 readonly fileSystem: FileSystem.FileSystem;
29 readonly path: Path.Path;
30 readonly allowedRoots: ReadonlyArray<string>;
31 readonly requestPath: string;
32 }) {
33 const { path } = input;
34 const resolved = path.resolve(input.requestPath);
35 const outside = EffectAcpErrors.AcpRequestError.invalidParams(
36 `Path '${input.requestPath}' is outside the session workspace.`,
37 );
38 const real = yield* input.fileSystem.realPath(resolved).pipe(
39 Effect.catch(() =>
40 Effect.gen(function* () {
41 // Only a missing file (a new write) falls back to its parent; a
42 // dangling or unreadable link must not be followed on write.
43 const entryExists = yield* input.fileSystem.readLink(resolved).pipe(
44 Effect.as(true),
45 Effect.catch(() => input.fileSystem.exists(resolved)),
46 Effect.orElseSucceed(() => true),
47 );
48 if (entryExists) return yield* outside;
49 const parent = yield* input.fileSystem
50 .realPath(path.dirname(resolved))
51 .pipe(Effect.orElseSucceed(() => path.dirname(resolved)));
52 return path.join(parent, path.basename(resolved));
53 }),
54 ),
55 );
56 const roots = yield* Effect.forEach(input.allowedRoots, (root) =>
57 input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)),
58 );
59 if (!roots.some((root) => isInsideRoot(path, root, real))) {
60 return yield* outside;
61 }
62 return real;
63 },
64);
65
66export const readAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.readTextFile")(
67 function* (input: {
68 readonly fileSystem: FileSystem.FileSystem;
69 readonly path: Path.Path;
70 readonly allowedRoots: ReadonlyArray<string>;
71 readonly request: EffectAcpSchema.ReadTextFileRequest;
72 }): Effect.fn.Return<EffectAcpSchema.ReadTextFileResponse, EffectAcpErrors.AcpError> {
73 const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path });
74 const info = yield* input.fileSystem
75 .stat(filePath)
76 .pipe(
77 Effect.mapError(() =>
78 EffectAcpErrors.AcpRequestError.resourceNotFound(
79 `File '${input.request.path}' not found.`,
80 ),
81 ),
82 );
83 if (info.type !== "File" || Number(info.size) > CLIENT_FILE_MAX_BYTES) {
84 return yield* EffectAcpErrors.AcpRequestError.invalidParams(
85 `File '${input.request.path}' is not a readable text file under ${CLIENT_FILE_MAX_BYTES} bytes.`,
86 );
87 }
88 const text = yield* input.fileSystem
89 .readFileString(filePath)
90 .pipe(
91 Effect.mapError(() =>
92 EffectAcpErrors.AcpRequestError.internalError(`Could not read '${input.request.path}'.`),
93 ),
94 );
95 const line = input.request.line ?? undefined;
96 const limit = input.request.limit ?? undefined;
97 if (line === undefined && limit === undefined) {
98 return { content: text };
99 }
100 // ACP lines are 1-indexed. `limit` is a line count.
101 const lines = text.split("\n");
102 const start = Math.max(0, (line ?? 1) - 1);
103 const end = limit === undefined ? lines.length : Math.min(lines.length, start + limit);
104 return { content: lines.slice(start, end).join("\n") };
105 },
106);
107
108export const writeAntigravityClientTextFile = Effect.fn("AntigravityClientFiles.writeTextFile")(
109 function* (input: {
110 readonly fileSystem: FileSystem.FileSystem;
111 readonly path: Path.Path;
112 readonly allowedRoots: ReadonlyArray<string>;
113 readonly request: EffectAcpSchema.WriteTextFileRequest;
114 }): Effect.fn.Return<EffectAcpSchema.WriteTextFileResponse, EffectAcpErrors.AcpError> {
115 const filePath = yield* resolveClientFilePath({ ...input, requestPath: input.request.path });
116 yield* input.fileSystem.makeDirectory(input.path.dirname(filePath), { recursive: true }).pipe(
117 Effect.andThen(input.fileSystem.writeFileString(filePath, input.request.content)),
118 Effect.mapError(() =>
119 EffectAcpErrors.AcpRequestError.internalError(`Could not write '${input.request.path}'.`),
120 ),
121 );
122 return {};
123 },
124);