diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts index 8d6b62731cfd..b49ca4550bb1 100644 --- a/apps/server/src/provider/AntigravityInstallation.test.ts +++ b/apps/server/src/provider/AntigravityInstallation.test.ts @@ -702,8 +702,9 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { source: "managed", version: previousVersion, }); + const canonicalExternalExecutable = yield* fs.realPath(externalExecutable); expect(yield* installation.resolve(externalExecutable)).toMatchObject({ - executablePath: externalExecutable, + executablePath: canonicalExternalExecutable, source: "override", managedVersionDirectory: null, }); @@ -724,7 +725,7 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { yield* installation.remove(); expect(yield* installation.resolve()).toMatchObject({ source: "path", - executablePath: externalExecutable, + executablePath: canonicalExternalExecutable, }); const isolated = yield* makeHarness({ baseDir }); expect(yield* isolated.installation.resolve().pipe(Effect.flip)).toMatchObject({ @@ -734,11 +735,11 @@ it.layer(NodeServices.layer)("Antigravity installation", (it) => { yield* isolated.installation.resolve(undefined, { PATH: externalDirectory }), ).toMatchObject({ source: "path", - executablePath: externalExecutable, + executablePath: canonicalExternalExecutable, }); expect( yield* isolated.installation.resolve("agy_acp_server.par", { PATH: externalDirectory }), - ).toMatchObject({ source: "override", executablePath: externalExecutable }); + ).toMatchObject({ source: "override", executablePath: canonicalExternalExecutable }); }), ); diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts index ae727c3996a1..246b740b7578 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.test.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.test.ts @@ -882,6 +882,14 @@ it.layer(layer)("AntigravityAdapter", (it) => { }).pipe(Effect.flip); expect(escape._tag).toBe("AcpRequestError"); expect(yield* fs.exists(path.join(outside, "escape.txt"))).toBe(false); + yield* fs.symlink(outside, path.join(cwd, "outside-link")); + const symlinkEscape = yield* write({ + sessionId: nativeSessionId, + path: path.join(cwd, "outside-link", "nested", "escape.txt"), + content: "nope", + }).pipe(Effect.flip); + expect(symlinkEscape._tag).toBe("AcpRequestError"); + expect(yield* fs.exists(path.join(outside, "nested", "escape.txt"))).toBe(false); const missing = yield* read({ sessionId: nativeSessionId, path: path.join(cwd, "missing.txt"), diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index e91c04e59025..a2cc2bf01fad 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -196,6 +196,27 @@ function isInsideRoot(path: Path.Path, root: string, candidate: string): boolean return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } +const realPathWithMissingTail = Effect.fn("AntigravityAdapter.realPathWithMissingTail")( + function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly candidate: string; + }) { + const missingSegments: Array = []; + let current = input.candidate; + while (true) { + const real = yield* input.fileSystem.realPath(current).pipe(Effect.option); + if (Option.isSome(real)) { + return input.path.join(real.value, ...missingSegments.reverse()); + } + const parent = input.path.dirname(current); + if (parent === current) return input.candidate; + missingSegments.push(input.path.basename(current)); + current = parent; + } + }, +); + /** Resolves an agent-supplied path and rejects anything outside the session roots. */ const resolveClientFilePath = Effect.fn("AntigravityAdapter.resolveClientFilePath")( function* (input: { @@ -207,9 +228,11 @@ const resolveClientFilePath = Effect.fn("AntigravityAdapter.resolveClientFilePat 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 parent = yield* realPathWithMissingTail({ + fileSystem: input.fileSystem, + path, + candidate: path.dirname(resolved), + }); const real = path.join(parent, path.basename(resolved)); const roots = yield* Effect.forEach(input.allowedRoots, (root) => input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)),