From f08167af3447c19ea8e22a4f79c84f78838acc25 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 28 Jul 2026 23:33:44 +0200 Subject: [PATCH] fix(v2): enforce absolute protocol paths --- scripts/generate.js | 64 ++++++++++++++++++++++++++---- src/examples/dual-version-agent.ts | 2 +- src/v2/absolute-path.test.ts | 47 ++++++++++++++++++++++ src/v2/absolute-path.ts | 35 ++++++++++++++++ src/v2/acp.test.ts | 31 ++++++++++----- src/v2/acp.ts | 1 + src/v2/schema/types.gen.ts | 4 +- src/v2/schema/zod.gen.ts | 9 ++++- 8 files changed, 172 insertions(+), 21 deletions(-) create mode 100644 src/v2/absolute-path.test.ts create mode 100644 src/v2/absolute-path.ts diff --git a/scripts/generate.js b/scripts/generate.js index ae7c3de..a13995e 100644 --- a/scripts/generate.js +++ b/scripts/generate.js @@ -88,6 +88,7 @@ const SCHEMA_CONFIGS = [ stagingDir: "./src/.schema-v2-staging", previousDir: "./src/.schema-v2-previous", schemaDeserializeImport: "../../schema-deserialize.js", + absolutePathImport: "../absolute-path.js", expectedExtensibleUnions: V2_EXTENSIBLE_UNIONS, openApiVersion: "2.0.0", releaseTag: CURRENT_V2_SCHEMA_RELEASE, @@ -183,6 +184,13 @@ async function generateSchema(config, checkGenerated) { // behavior that isn't in the schema descriptions: custom variants bypass // the lenient-field salvage known variants get. let zodDocs = updateDocs(zodSrc, schemaDefs); + if (config.absolutePathImport) { + zodDocs = addAbsolutePathValidation( + zodDocs, + config.absolutePathImport, + config.name, + ); + } for (const [name, exclusion] of defExclusions) { zodDocs = appendDocNote( zodDocs, @@ -212,15 +220,17 @@ async function generateSchema(config, checkGenerated) { const tsPath = `${stagingDir}/types.gen.ts`; const tsSrc = await fs.readFile(tsPath, "utf8"); - const ts = await formatStable( - updateDocs( - tsSrc.replace( - `export type ClientOptions`, - `// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`, - ), - schemaDefs, + let tsDocs = updateDocs( + tsSrc.replace( + `export type ClientOptions`, + `// eslint-disable-next-line @typescript-eslint/no-unused-vars\ntype ClientOptions`, ), + schemaDefs, ); + if (config.absolutePathImport) { + tsDocs = addAbsolutePathBrand(tsDocs, config.name); + } + const ts = await formatStable(tsDocs); await fs.writeFile(tsPath, ts); // Always write the file: the staging swap replaces the whole directory, so @@ -419,6 +429,46 @@ async function formatStable(source) { ); } +function addAbsolutePathValidation(source, importPath, lane) { + const zodImport = source.match(/import \* as z from ["']zod\/v4["'];/)?.[0]; + const absolutePathSchema = "export const zAbsolutePath = z.string();"; + if (!zodImport || !source.includes(absolutePathSchema)) { + throw new Error( + `[${lane}] Could not attach absolute-path validation to generated Zod schemas`, + ); + } + + return source + .replace( + zodImport, + `import { isAbsolutePath } from ${JSON.stringify(importPath)};\n` + + `import type { AbsolutePath } from "./types.gen.js";\n` + + `${zodImport}`, + ) + .replace( + absolutePathSchema, + `export const zAbsolutePath = z.string().refine(isAbsolutePath, {\n` + + ` message: "Expected an absolute filesystem path",\n` + + `}).transform((value) => value as AbsolutePath);`, + ); +} + +function addAbsolutePathBrand(source, lane) { + const absolutePathType = "export type AbsolutePath = string;"; + if (!source.includes(absolutePathType)) { + throw new Error( + `[${lane}] Could not brand the generated AbsolutePath type`, + ); + } + + return source.replace( + absolutePathType, + `export type AbsolutePath = string & {\n` + + ` readonly __brand: "AbsolutePath";\n` + + `};`, + ); +} + /** * Downloads a file from a URL to a local path * @param {string} url - The URL to download from diff --git a/src/examples/dual-version-agent.ts b/src/examples/dual-version-agent.ts index d67c865..9a1b79a 100644 --- a/src/examples/dual-version-agent.ts +++ b/src/examples/dual-version-agent.ts @@ -44,7 +44,7 @@ type V2Turn = { }; type V2Session = { - cwd: string; + cwd: v2.AbsolutePath; active: boolean; history: v2.SessionUpdate[]; turn?: V2Turn; diff --git a/src/v2/absolute-path.test.ts b/src/v2/absolute-path.test.ts new file mode 100644 index 0000000..7fcc8af --- /dev/null +++ b/src/v2/absolute-path.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { absolutePath, isAbsolutePath } from "./absolute-path.js"; +import { zAbsolutePath } from "./schema/zod.gen.js"; +import type { AbsolutePath } from "./schema/types.gen.js"; + +const typedAbsolutePath: AbsolutePath = absolutePath("/workspace"); +const parsedAbsolutePath: AbsolutePath = zAbsolutePath.parse("/workspace"); +// @ts-expect-error Raw strings must be validated before use as protocol paths. +const unvalidatedAbsolutePath: AbsolutePath = "/workspace"; +void typedAbsolutePath; +void parsedAbsolutePath; +void unvalidatedAbsolutePath; + +describe("absolute protocol paths", () => { + it.each([ + "/", + "/tmp/project", + "C:\\", + "C:\\Users\\agent\\project", + "D:/projects/acp", + "\\\\server\\share", + "\\\\server/share/directory", + "//server/share/directory", + "\\\\?\\C:\\long\\path", + ])("accepts %s", (value) => { + expect(isAbsolutePath(value)).toBe(true); + expect(absolutePath(value)).toBe(value); + expect(zAbsolutePath.parse(value)).toBe(value); + }); + + it.each([ + "", + ".", + "./project", + "../project", + "tmp/project", + "C:relative", + "\\rooted-without-drive", + "~/project", + "/tmp/\0project", + ])("rejects %s", (value) => { + expect(isAbsolutePath(value)).toBe(false); + expect(() => absolutePath(value)).toThrow(TypeError); + expect(zAbsolutePath.safeParse(value).success).toBe(false); + }); +}); diff --git a/src/v2/absolute-path.ts b/src/v2/absolute-path.ts new file mode 100644 index 0000000..75ac5d5 --- /dev/null +++ b/src/v2/absolute-path.ts @@ -0,0 +1,35 @@ +import type { AbsolutePath } from "./schema/types.gen.js"; + +const WINDOWS_DRIVE_ABSOLUTE_PATH = /^[A-Za-z]:[\\/]/; +const WINDOWS_UNC_ABSOLUTE_PATH = /^\\\\[^\\/]+[\\/][^\\/]+(?:[\\/].*)?$/; + +/** + * Returns whether a string is an absolute POSIX, Windows drive, or Windows UNC + * filesystem path. The check is host-independent so ACP messages can be + * validated while crossing operating-system boundaries. + * + * @experimental + */ +export function isAbsolutePath(value: string): value is AbsolutePath { + if (value.includes("\0")) return false; + return ( + value.startsWith("/") || + WINDOWS_DRIVE_ABSOLUTE_PATH.test(value) || + WINDOWS_UNC_ABSOLUTE_PATH.test(value) + ); +} + +/** + * Validates and returns an absolute protocol path. + * + * @throws {TypeError} If `value` is not an absolute filesystem path. + * @experimental + */ +export function absolutePath(value: string): AbsolutePath { + if (!isAbsolutePath(value)) { + throw new TypeError( + `Expected an absolute filesystem path, received ${JSON.stringify(value)}`, + ); + } + return value; +} diff --git a/src/v2/acp.test.ts b/src/v2/acp.test.ts index 4ec569d..b2e2340 100644 --- a/src/v2/acp.test.ts +++ b/src/v2/acp.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { PROTOCOL_VERSION, agent, + absolutePath, batchNotification, batchRequest, client, @@ -129,7 +130,7 @@ describe("experimental v2 app API", () => { const stdioServer = { type: "stdio", name: "stdio-server", - command: "/usr/bin/example-mcp", + command: absolutePath("/usr/bin/example-mcp"), args, env, _meta: stdioMeta, @@ -150,9 +151,9 @@ describe("experimental v2 app API", () => { settings: customSettings, } satisfies McpServer; - const additionalDirectories = ["/workspace/other"]; + const additionalDirectories = [absolutePath("/workspace/other")]; const request = { - cwd: "/workspace", + cwd: absolutePath("/workspace"), additionalDirectories, mcpServers: [httpServer, stdioServer, acpServer, customServer], _meta: requestMeta, @@ -162,7 +163,7 @@ describe("experimental v2 app API", () => { await client().connectWith(agent(), (agentClient) => { const builder = agentClient.buildSession(request); - additionalDirectories[0] = "/mutated-input"; + additionalDirectories[0] = absolutePath("/mutated-input"); requestMeta.nested.value = "mutated-input"; httpServer.name = "mutated-input"; headers[0].value = "mutated-input"; @@ -179,7 +180,7 @@ describe("experimental v2 app API", () => { expect(builder.toRequest()).toEqual(expected); const returned = builder.toRequest(); - returned.additionalDirectories![0] = "/mutated-output"; + returned.additionalDirectories![0] = absolutePath("/mutated-output"); (returned._meta as NestedMeta).nested.value = "mutated-output"; const returnedHttp = returned.mcpServers![0] as typeof httpServer; @@ -217,7 +218,7 @@ describe("experimental v2 app API", () => { const mcpServer = { type: "stdio", name: "stdio-server", - command: "/usr/bin/example-mcp", + command: absolutePath("/usr/bin/example-mcp"), args, env, _meta: serverMeta, @@ -226,7 +227,7 @@ describe("experimental v2 app API", () => { await client().connectWith(agent(), (agentClient) => { const builder = agentClient - .buildSession("/workspace") + .buildSession(absolutePath("/workspace")) .withMcpServer(mcpServer); args[0] = "mutated-input"; @@ -285,7 +286,9 @@ describe("experimental v2 app API", () => { }), ).resolves.toMatchObject({ protocolVersion: PROTOCOL_VERSION }); - const session = await agentClient.buildSession("/workspace").start(); + const session = await agentClient + .buildSession(absolutePath("/workspace")) + .start(); try { await updateClient!.notify(methods.client.session.update, { sessionId: session.sessionId, @@ -363,7 +366,9 @@ describe("experimental v2 app API", () => { protocolVersion: PROTOCOL_VERSION, info: clientInfo, }); - const session = await agentClient.buildSession("/workspace").start(); + const session = await agentClient + .buildSession(absolutePath("/workspace")) + .start(); try { const first = session.prompt("First"); const firstText = session.readText(); @@ -420,7 +425,9 @@ describe("experimental v2 app API", () => { protocolVersion: PROTOCOL_VERSION, info: clientInfo, }); - const session = await agentClient.buildSession("/workspace").start(); + const session = await agentClient + .buildSession(absolutePath("/workspace")) + .start(); try { await session.prompt("Hello"); const text = session.readText(); @@ -489,7 +496,9 @@ describe("experimental v2 app API", () => { protocolVersion: PROTOCOL_VERSION, info: clientInfo, }); - const session = await agentClient.buildSession("/workspace").start(); + const session = await agentClient + .buildSession(absolutePath("/workspace")) + .start(); try { await session.prompt("First"); for (const update of [ diff --git a/src/v2/acp.ts b/src/v2/acp.ts index 1e65336..f7d17e9 100644 --- a/src/v2/acp.ts +++ b/src/v2/acp.ts @@ -16,6 +16,7 @@ import * as validate from "./schema/zod.gen.js"; import * as guards from "./schema/guards.gen.js"; import { ndJsonStream as createJsonStream } from "../stream.js"; export type * from "./schema/types.gen.js"; +export { absolutePath, isAbsolutePath } from "./absolute-path.js"; // Runtime narrowing helpers for extensible unions, exposed as companion values // that merge (declaration merging) with the like-named types — e.g. // `CreateElicitationResponse.isAccept(response)`. See schema/guards.gen.ts. diff --git a/src/v2/schema/types.gen.ts b/src/v2/schema/types.gen.ts index 8183a1e..2919011 100644 --- a/src/v2/schema/types.gen.ts +++ b/src/v2/schema/types.gen.ts @@ -688,7 +688,9 @@ export type DiffFileType = "text" | "binary" | "directory" | "symlink" | string; /** * An absolute filesystem path used by the protocol. */ -export type AbsolutePath = string; +export type AbsolutePath = string & { + readonly __brand: "AbsolutePath"; +}; /** * Operation metadata for add, delete, and modify changes. diff --git a/src/v2/schema/zod.gen.ts b/src/v2/schema/zod.gen.ts index 00ae9db..66a5705 100644 --- a/src/v2/schema/zod.gen.ts +++ b/src/v2/schema/zod.gen.ts @@ -7,6 +7,8 @@ import { requiredDefaultOnError, vecSkipError, } from "../../schema-deserialize.js"; +import { isAbsolutePath } from "../absolute-path.js"; +import type { AbsolutePath } from "./types.gen.js"; import * as z from "zod/v4"; /** @@ -312,7 +314,12 @@ export const zDiffFileType = z.union([ /** * An absolute filesystem path used by the protocol. */ -export const zAbsolutePath = z.string(); +export const zAbsolutePath = z + .string() + .refine(isAbsolutePath, { + message: "Expected an absolute filesystem path", + }) + .transform((value) => value as AbsolutePath); /** * Operation metadata for add, delete, and modify changes.