diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 0d379c42094a..e37fe223529e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -269,6 +269,22 @@ 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 + * 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: ( + request: EffectAcpSchema.ReadTextFileRequest, + cwd: string | null, + ) => Effect.Effect; + readonly writeTextFile: ( + request: EffectAcpSchema.WriteTextFileRequest, + cwd: string | null, + ) => Effect.Effect; + }; /** * Permission requests that are really questions (Antigravity `interaction_*` * tool calls). Returns the question and a response builder; undefined routes @@ -5379,14 +5395,29 @@ 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. 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(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(acpWriteTextFile(options.fileSystem, request)), + Effect.andThen(clientPolicyContext), + Effect.flatMap(({ policy }) => + clientFileSystem === undefined + ? acpWriteTextFile(options.fileSystem, request) + : clientFileSystem.writeTextFile(request, policy.cwd), + ), ), ); 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..4379a3bcba19 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -1,10 +1,36 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ProviderInstanceId } 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"; +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 +125,300 @@ 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"))); + + // 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/orchestration-v2/Adapters/AntigravityAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts index e740cb7cdfce..9c3289cff7fe 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.ts @@ -122,14 +122,18 @@ 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 // 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 +142,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 +184,22 @@ export function makeAntigravityAcpAdapterFlavor( }); }), sessionModeForPolicy: (policy) => antigravityPermissionMode(policy.runtimeMode), + clientFileSystem: { + readTextFile: (request, cwd) => + readAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots: antigravityClientFileRoots(cwd), + request, + }), + writeTextFile: (request, cwd) => + writeAntigravityClientTextFile({ + fileSystem: options.fileSystem, + path: options.path, + allowedRoots: antigravityClientFileRoots(cwd), + request, + }), + }, approvalOptions: antigravityApprovalOptions, extractPermissionQuestion: (request) => { const question = extractAntigravityUserInputQuestion(request); 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; },