From c4917e6b3463558369507fca0bca2f9a7bb60d5a Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:58:20 -0700 Subject: [PATCH 1/3] fix(server): Antigravity keeps its workspace containment The generic ACP fs handlers registered after Antigravity's own and replaced them (effect-acp keeps the last handler per method), so Antigravity could read and write any path the T3 server can. The flavor now supplies its handlers through `clientFileSystem`, and the adapter serves them behind the runtime policy guard instead of the unconfined generic ones. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 28 +++- .../Adapters/AntigravityAdapterV2.test.ts | 135 +++++++++++++++++- .../Adapters/AntigravityAdapterV2.ts | 37 +++-- 3 files changed, 177 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 0d379c42094a..e9a8964002d9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -269,6 +269,21 @@ export interface AcpAdapterV2Flavor { }) => Effect.Effect; /** Native session mode to select for a runtime policy (e.g. Antigravity `yolo`). */ readonly sessionModeForPolicy?: (policy: ProviderAdapterV2RuntimePolicy) => string | undefined; + /** + * Serves the agent's `fs/read_text_file` and `fs/write_text_file` requests in + * place of the generic handlers, after the runtime policy guard. Receives the + * session cwd. Antigravity confines them to its workspace. + */ + readonly clientFileSystem?: { + readonly readTextFile: ( + request: EffectAcpSchema.ReadTextFileRequest, + cwd: string, + ) => Effect.Effect; + readonly writeTextFile: ( + request: EffectAcpSchema.WriteTextFileRequest, + cwd: string, + ) => Effect.Effect; + }; /** * Permission requests that are really questions (Antigravity `interaction_*` * tool calls). Returns the question and a response builder; undefined routes @@ -5379,14 +5394,23 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV Effect.succeed(request), requestContext.requestId, ); + // A flavor's own handlers replace the generic ones: effect-acp keeps + // only the last handler registered per method. + const sessionCwd = input.runtimePolicy.cwd ?? process.cwd(); yield* targetRuntime.handleReadTextFile((request) => guardClientFsRead(request.path).pipe( - Effect.andThen(acpReadTextFile(options.fileSystem, request)), + Effect.andThen( + flavor.clientFileSystem?.readTextFile(request, sessionCwd) ?? + acpReadTextFile(options.fileSystem, request), + ), ), ); yield* targetRuntime.handleWriteTextFile((request) => guardClientFsWrite(request.path).pipe( - Effect.andThen(acpWriteTextFile(options.fileSystem, request)), + Effect.andThen( + flavor.clientFileSystem?.writeTextFile(request, sessionCwd) ?? + acpWriteTextFile(options.fileSystem, request), + ), ), ); if (handlerOptions.mcp !== false) { diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index cf5055905afd..41cccd32d357 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -1,10 +1,25 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/compat"; +import { ServerConfig } from "../../config.ts"; +import type * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts"; +import { makeAntigravityAcpRuntime } from "../../provider/acp/AntigravityAcpSupport.ts"; +import { layer as idAllocatorLayer, IdAllocatorV2 } from "../IdAllocator.ts"; import { ProviderAdapterV2RuntimePolicy } from "../ProviderAdapter.ts"; -import { makeAntigravityAcpAdapterFlavor } from "./AntigravityAdapterV2.ts"; +import { + makeAntigravityAcpAdapterFlavor, + makeAntigravityAdapterV2, +} from "./AntigravityAdapterV2.ts"; const flavor = makeAntigravityAcpAdapterFlavor({ instanceId: ProviderInstanceId.make("antigravity-test"), @@ -99,3 +114,119 @@ describe("AntigravityAdapterV2 flavor", () => { ); }); }); + +const sessionLayer = Layer.mergeAll( + NodeServices.layer, + idAllocatorLayer, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-v2-adapter-" }).pipe( + Layer.provide(NodeServices.layer), + ), +); + +describe("AntigravityAdapterV2 client file system", () => { + it.effect("confines agent file requests to the workspace under full access", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + // effect-acp keeps the last handler registered per method; so does this. + let readTextFile: Parameters[0] | undefined; + let writeTextFile: Parameters[0] | undefined; + const crypto = yield* Crypto.Crypto; + const instanceId = ProviderInstanceId.make("antigravity-containment-test"); + const adapter = makeAntigravityAdapterV2({ + instanceId, + crypto, + selfInvocation: yield* resolveSelfInvocation(), + fileSystem, + path, + idAllocator: yield* IdAllocatorV2, + serverConfig, + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { T3_ACP_ANTIGRAVITY: "1" }, + }, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((runtime): RuntimeService => ({ + ...runtime, + handleReadTextFile: (handler) => + Effect.sync(() => { + readTextFile = handler; + }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))), + handleWriteTextFile: (handler) => + Effect.sync(() => { + writeTextFile = handler; + }).pipe(Effect.andThen(runtime.handleWriteTextFile(handler))), + })), + ), + withProcess: (_stop, task) => task, + defaultModel: Effect.succeed(undefined), + }); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-workspace-", + }); + const outside = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-antigravity-outside-", + }); + const outsideFile = path.join(outside, "secret.txt"); + yield* fileSystem.writeFileString(outsideFile, "secret"); + const attachment = path.join(serverConfig.attachmentsDir, "pasted.txt"); + yield* fileSystem.writeFileString(attachment, "pasted"); + + const threadId = ThreadId.make("thread-antigravity-containment"); + const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const modelSelection = { instanceId, model: "gemini-test-low" } as const; + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-containment"), + modelSelection, + runtimePolicy, + }); + yield* session.ensureThread({ threadId, modelSelection, runtimePolicy }); + if (readTextFile === undefined || writeTextFile === undefined) { + return yield* Effect.die("Antigravity sessions must serve client file requests"); + } + const context = (method: string) => ({ requestId: `test-${method}`, method }); + + const insidePath = path.join(workspace, "src", "inside.ts"); + yield* writeTextFile( + { sessionId: "mock-session-1", path: insidePath, content: "inside" }, + context("fs/write_text_file"), + ); + assert.equal(yield* fileSystem.readFileString(insidePath), "inside"); + const pasted = yield* readTextFile( + { sessionId: "mock-session-1", path: attachment }, + context("fs/read_text_file"), + ); + assert.equal(pasted.content, "pasted"); + + const outsideRead = yield* readTextFile( + { sessionId: "mock-session-1", path: outsideFile }, + context("fs/read_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(outsideRead)); + const outsideWrite = yield* writeTextFile( + { sessionId: "mock-session-1", path: path.join(outside, "planted.txt"), content: "x" }, + context("fs/write_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(outsideWrite)); + assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt"))); + }).pipe(Effect.provide(sessionLayer), Effect.scoped), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index e740cb7cdfce..b40497632eb2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -127,9 +127,6 @@ export function makeAntigravityAcpAdapterFlavor( // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the // process by closing it, and the adapter respawns on the next turn. const scope = yield* Effect.scope; - // The attachments dir grant lets the agent read pasted files at the - // paths the turn text references. It is a leaf directory of uploads. - const allowedRoots = [input.cwd, options.serverConfig.attachmentsDir]; const runtime = yield* options.withProcess( Scope.close(scope, Exit.void), options.makeRuntime({ @@ -138,22 +135,6 @@ export function makeAntigravityAcpAdapterFlavor( additionalDirectories: [options.serverConfig.attachmentsDir], }), ); - yield* runtime.handleReadTextFile((request) => - readAntigravityClientTextFile({ - fileSystem: options.fileSystem, - path: options.path, - allowedRoots, - request, - }), - ); - yield* runtime.handleWriteTextFile((request) => - writeAntigravityClientTextFile({ - fileSystem: options.fileSystem, - path: options.path, - allowedRoots, - request, - }), - ); return { ...runtime, start: () => @@ -196,6 +177,24 @@ export function makeAntigravityAcpAdapterFlavor( }); }), sessionModeForPolicy: (policy) => antigravityPermissionMode(policy.runtimeMode), + // The attachments dir grant lets the agent read pasted files at the paths + // the turn text references. It is a leaf directory of uploads. + clientFileSystem: { + readTextFile: (request, cwd) => + readAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots: [cwd, options.serverConfig.attachmentsDir], + request, + }), + writeTextFile: (request, cwd) => + writeAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots: [cwd, options.serverConfig.attachmentsDir], + request, + }), + }, approvalOptions: antigravityApprovalOptions, extractPermissionQuestion: (request) => { const question = extractAntigravityUserInputQuestion(request); From 5bda848dd8b21e94277360aec752079ead4c0d9d Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:08:34 -0700 Subject: [PATCH 2/3] fix(server): Antigravity file requests without a workspace stay out of the server cwd A session policy without a cwd made the server's own cwd Antigravity's containment root. Pass the nullable policy cwd through and allow only the attachments dir in that case. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/orchestration-v2/Adapters/AcpAdapterV2.ts | 9 +++++---- .../Adapters/AntigravityAdapterV2.ts | 13 +++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index e9a8964002d9..618d39bfca98 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -272,16 +272,17 @@ export interface AcpAdapterV2Flavor { /** * Serves the agent's `fs/read_text_file` and `fs/write_text_file` requests in * place of the generic handlers, after the runtime policy guard. Receives the - * session cwd. Antigravity confines them to its workspace. + * session's policy cwd, which is null when the session has no workspace. + * Antigravity confines them to its workspace. */ readonly clientFileSystem?: { readonly readTextFile: ( request: EffectAcpSchema.ReadTextFileRequest, - cwd: string, + cwd: string | null, ) => Effect.Effect; readonly writeTextFile: ( request: EffectAcpSchema.WriteTextFileRequest, - cwd: string, + cwd: string | null, ) => Effect.Effect; }; /** @@ -5396,7 +5397,7 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV ); // A flavor's own handlers replace the generic ones: effect-acp keeps // only the last handler registered per method. - const sessionCwd = input.runtimePolicy.cwd ?? process.cwd(); + const sessionCwd = input.runtimePolicy.cwd; yield* targetRuntime.handleReadTextFile((request) => guardClientFsRead(request.path).pipe( Effect.andThen( diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index b40497632eb2..9c3289cff7fe 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -122,6 +122,13 @@ const extractAntigravitySubagentUpdate: NonNullable + cwd === null + ? [options.serverConfig.attachmentsDir] + : [cwd, options.serverConfig.attachmentsDir]; const makeRuntime = (input: AcpAdapterV2RuntimeInput) => Effect.gen(function* () { // AcpAdapterV2 owns the runtime scope; sign-in and sign-out stop the @@ -177,21 +184,19 @@ export function makeAntigravityAcpAdapterFlavor( }); }), sessionModeForPolicy: (policy) => antigravityPermissionMode(policy.runtimeMode), - // The attachments dir grant lets the agent read pasted files at the paths - // the turn text references. It is a leaf directory of uploads. clientFileSystem: { readTextFile: (request, cwd) => readAntigravityClientTextFile({ fileSystem: options.fileSystem, path: options.path, - allowedRoots: [cwd, options.serverConfig.attachmentsDir], + allowedRoots: antigravityClientFileRoots(cwd), request, }), writeTextFile: (request, cwd) => writeAntigravityClientTextFile({ fileSystem: options.fileSystem, path: options.path, - allowedRoots: [cwd, options.serverConfig.attachmentsDir], + allowedRoots: antigravityClientFileRoots(cwd), request, }), }, From bae8e224d62216725ea6594925b58f7a5b0e854f Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:01:14 -0700 Subject: [PATCH 3/3] fix(server): Antigravity file containment follows links and the current workspace Two escapes from Antigravity's workspace containment: - The check resolved only the parent directory, so a symlink inside the workspace pointing outside it could be read or overwritten through. The final target is now canonicalized before the check, and a link that exists but cannot be resolved (dangling) is refused rather than written through. - The allowed roots came from the policy the session opened with. They now come from the policy active when the request arrives, the one the policy guard already checks, so a session carried into a turn for another workspace cannot reach the previous one. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../orchestration-v2/Adapters/AcpAdapterV2.ts | 26 ++- .../Adapters/AntigravityAdapterV2.test.ts | 194 +++++++++++++++++- .../provider/acp/AntigravityClientFiles.ts | 38 +++- 3 files changed, 238 insertions(+), 20 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 618d39bfca98..e37fe223529e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -272,8 +272,8 @@ export interface AcpAdapterV2Flavor { /** * Serves the agent's `fs/read_text_file` and `fs/write_text_file` requests in * place of the generic handlers, after the runtime policy guard. Receives the - * session's policy cwd, which is null when the session has no workspace. - * Antigravity confines them to its workspace. + * cwd of the policy active when the request arrives, which is null when the + * session has no workspace. Antigravity confines them to its workspace. */ readonly clientFileSystem?: { readonly readTextFile: ( @@ -5396,21 +5396,27 @@ export function makeAcpAdapterV2(options: AcpAdapterV2Options): ProviderAdapterV requestContext.requestId, ); // A flavor's own handlers replace the generic ones: effect-acp keeps - // only the last handler registered per method. - const sessionCwd = input.runtimePolicy.cwd; + // only the last handler registered per method. They confine requests + // to the workspace of the policy the guard checks at request time, + // not the one the session opened with. + const clientFileSystem = flavor.clientFileSystem; yield* targetRuntime.handleReadTextFile((request) => guardClientFsRead(request.path).pipe( - Effect.andThen( - flavor.clientFileSystem?.readTextFile(request, sessionCwd) ?? - acpReadTextFile(options.fileSystem, request), + Effect.andThen(clientPolicyContext), + Effect.flatMap(({ policy }) => + clientFileSystem === undefined + ? acpReadTextFile(options.fileSystem, request) + : clientFileSystem.readTextFile(request, policy.cwd), ), ), ); yield* targetRuntime.handleWriteTextFile((request) => guardClientFsWrite(request.path).pipe( - Effect.andThen( - flavor.clientFileSystem?.writeTextFile(request, sessionCwd) ?? - acpWriteTextFile(options.fileSystem, request), + Effect.andThen(clientPolicyContext), + Effect.flatMap(({ policy }) => + clientFileSystem === undefined + ? acpWriteTextFile(options.fileSystem, request) + : clientFileSystem.writeTextFile(request, policy.cwd), ), ), ); diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 41cccd32d357..4379a3bcba19 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -1,13 +1,24 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import { + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -227,6 +238,187 @@ describe("AntigravityAdapterV2 client file system", () => { ).pipe(Effect.exit); assert.isTrue(Exit.isFailure(outsideWrite)); assert.isFalse(yield* fileSystem.exists(path.join(outside, "planted.txt"))); + + // An in-workspace symlink to an outside file must not carry a read or + // write out of the workspace. + const linkPath = path.join(workspace, "linked-secret.txt"); + yield* fileSystem.symlink(outsideFile, linkPath); + const linkedRead = yield* readTextFile( + { sessionId: "mock-session-1", path: linkPath }, + context("fs/read_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(linkedRead), "a read through an escaping symlink is denied"); + const linkedWrite = yield* writeTextFile( + { sessionId: "mock-session-1", path: linkPath, content: "overwritten" }, + context("fs/write_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(linkedWrite), "a write through an escaping symlink is denied"); + assert.equal(yield* fileSystem.readFileString(outsideFile), "secret"); + // A dangling in-workspace symlink to an outside path must not create it. + const plantedTarget = path.join(outside, "created-through-link.txt"); + const danglingLink = path.join(workspace, "dangling.txt"); + yield* fileSystem.symlink(plantedTarget, danglingLink); + const danglingWrite = yield* writeTextFile( + { sessionId: "mock-session-1", path: danglingLink, content: "planted" }, + context("fs/write_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(danglingWrite), "a write through a dangling symlink is denied"); + assert.isFalse(yield* fileSystem.exists(plantedTarget)); + // A new file under an in-workspace directory link to outside is denied. + yield* fileSystem.symlink(outside, path.join(workspace, "linked-dir")); + const linkedDirWrite = yield* writeTextFile( + { + sessionId: "mock-session-1", + path: path.join(workspace, "linked-dir", "new.txt"), + content: "planted", + }, + context("fs/write_text_file"), + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(linkedDirWrite), "a write under an escaping directory link"); + assert.isFalse(yield* fileSystem.exists(path.join(outside, "new.txt"))); + // A symlink that stays inside the workspace keeps working. + const insideLink = path.join(workspace, "inside-link.ts"); + yield* fileSystem.symlink(insidePath, insideLink); + const viaInsideLink = yield* readTextFile( + { sessionId: "mock-session-1", path: insideLink }, + context("fs/read_text_file"), + ); + assert.equal(viaInsideLink.content, "inside"); + }).pipe(Effect.provide(sessionLayer), Effect.scoped), + ); +}); + +describe("AntigravityAdapterV2 workspace changes", () => { + it.effect("confines file requests to the workspace of the turn in progress", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig; + const crypto = yield* Crypto.Crypto; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + type RuntimeService = AcpSessionRuntime.AcpSessionRuntime["Service"]; + let readTextFile: Parameters[0] | undefined; + const instanceId = ProviderInstanceId.make("antigravity-workspace-change-test"); + const adapter = makeAntigravityAdapterV2({ + instanceId, + crypto, + selfInvocation: yield* resolveSelfInvocation(), + fileSystem, + path, + idAllocator: yield* IdAllocatorV2, + serverConfig, + makeRuntime: (input) => + makeAntigravityAcpRuntime({ + ...input, + childProcessSpawner, + spawn: { + command: process.execPath, + args: [mockAgentPath], + cwd: input.cwd, + env: { T3_ACP_ANTIGRAVITY: "1", T3_ACP_HANG_PROMPT_FOREVER: "1" }, + }, + }).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.map((runtime): RuntimeService => ({ + ...runtime, + handleReadTextFile: (handler) => + Effect.sync(() => { + readTextFile = handler; + }).pipe(Effect.andThen(runtime.handleReadTextFile(handler))), + })), + ), + withProcess: (_stop, task) => task, + defaultModel: Effect.succeed(undefined), + }); + const workspaceA = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-a-" }); + const workspaceB = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ag-b-" }); + yield* fileSystem.writeFileString(path.join(workspaceA, "a.txt"), "from a"); + yield* fileSystem.writeFileString(path.join(workspaceB, "b.txt"), "from b"); + const policyFor = (cwd: string) => + ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }); + const threadId = ThreadId.make("thread-antigravity-workspace-change"); + const modelSelection = { instanceId, model: "gemini-test-low" } as const; + const session = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("provider-session-antigravity-workspace-change"), + modelSelection, + runtimePolicy: policyFor(workspaceA), + }); + const providerThread = yield* session.ensureThread({ + threadId, + modelSelection, + runtimePolicy: policyFor(workspaceA), + }); + // The session opened for A now runs a turn for B. + const now = yield* DateTime.now; + yield* session + .startTurn({ + appThread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project-antigravity-workspace-change"), + title: "Antigravity workspace change", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: workspaceB, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId: RunId.make("run-antigravity-workspace-change"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("attempt-antigravity-workspace-change"), + rootNodeId: NodeId.make("node-antigravity-workspace-change"), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-antigravity-workspace-change"), + text: "read b.txt", + attachments: [], + }, + modelSelection, + runtimePolicy: policyFor(workspaceB), + }) + .pipe(Effect.forkScoped); + yield* session.events.pipe( + Stream.filter((event) => event.type === "provider_turn.updated"), + Stream.runHead, + ); + if (readTextFile === undefined) { + return yield* Effect.die("Antigravity sessions must serve client file requests"); + } + const context = { requestId: "test-read", method: "fs/read_text_file" }; + const fromB = yield* readTextFile( + { sessionId: "mock-session-1", path: path.join(workspaceB, "b.txt") }, + context, + ); + assert.equal(fromB.content, "from b"); + const fromA = yield* readTextFile( + { sessionId: "mock-session-1", path: path.join(workspaceA, "a.txt") }, + context, + ).pipe(Effect.exit); + assert.isTrue(Exit.isFailure(fromA), "the previous workspace is no longer readable"); }).pipe(Effect.provide(sessionLayer), Effect.scoped), ); }); diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index 2b175600624d..7c5265934f03 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -16,7 +16,13 @@ function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } -/** Resolves an agent-supplied path and rejects anything outside the session roots. */ +/** + * Resolves an agent-supplied path and rejects anything outside the session + * roots. Symlinks resolve before the check, including the final component, so + * a link inside the workspace cannot read or write through to a file outside + * it. An entry that exists but cannot be resolved, like a dangling link, is + * rejected rather than written through. + */ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFilePath")( function* (input: { readonly fileSystem: FileSystem.FileSystem; @@ -26,18 +32,32 @@ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFil }) { const { path } = input; const resolved = path.resolve(input.requestPath); - // Follow symlinks on the parent so a link out of the workspace cannot escape it. - const parent = yield* input.fileSystem - .realPath(path.dirname(resolved)) - .pipe(Effect.orElseSucceed(() => path.dirname(resolved))); - const real = path.join(parent, path.basename(resolved)); + const outside = EffectAcpErrors.AcpRequestError.invalidParams( + `Path '${input.requestPath}' is outside the session workspace.`, + ); + const real = yield* input.fileSystem.realPath(resolved).pipe( + Effect.catch(() => + Effect.gen(function* () { + // Only a missing file (a new write) falls back to its parent; a + // dangling or unreadable link must not be followed on write. + const entryExists = yield* input.fileSystem.readLink(resolved).pipe( + Effect.as(true), + Effect.catch(() => input.fileSystem.exists(resolved)), + Effect.orElseSucceed(() => true), + ); + if (entryExists) return yield* outside; + const parent = yield* input.fileSystem + .realPath(path.dirname(resolved)) + .pipe(Effect.orElseSucceed(() => path.dirname(resolved))); + return path.join(parent, path.basename(resolved)); + }), + ), + ); const roots = yield* Effect.forEach(input.allowedRoots, (root) => input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)), ); if (!roots.some((root) => isInsideRoot(path, root, real))) { - return yield* EffectAcpErrors.AcpRequestError.invalidParams( - `Path '${input.requestPath}' is outside the session workspace.`, - ); + return yield* outside; } return real; },