diff --git a/package.json b/package.json index c1578fc5..0c43b448 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-plugins.mjs" + "validate": "node scripts/validate-plugins.mjs && node --test plugins/agents-squad/schema-portability.test.mjs" } } - diff --git a/plugins/agents-squad/index.ts b/plugins/agents-squad/index.ts index 4894905e..7117423e 100644 --- a/plugins/agents-squad/index.ts +++ b/plugins/agents-squad/index.ts @@ -5,7 +5,7 @@ import { readFileSync, writeFileSync, } from "node:fs"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { type AgentPlugin, @@ -90,6 +90,8 @@ const GLOBAL_SKILLS_DIR = join(resolveClineDataDirPath(), "settings", "skills"); /** Safe identifier pattern for conversation IDs used in filesystem paths. */ const SAFE_ID_RE = /^[A-Za-z0-9_-]+$/; +const HANDOFF_PATH_ALLOWED_RE = /^[A-Za-z0-9._/-]+$/; +const HANDOFF_PATH_MAX_LENGTH = 240; const envOr = (key: string, fallback: string): string => process.env[key]?.trim() || fallback; @@ -298,14 +300,45 @@ function resolveHandoffPath( ctx: AgentToolContext, relativePath: string, ): string { + const handoffPath = validateHandoffRelativePath(relativePath); const dir = handoffsDir(ctx); - const resolved = resolve(dir, relativePath); - if (!resolved.startsWith(`${dir}/`)) { + const resolved = resolve(dir, handoffPath); + const pathFromHandoffsDir = relative(dir, resolved); + if ( + !pathFromHandoffsDir || + pathFromHandoffsDir === ".." || + pathFromHandoffsDir.startsWith(`..${sep}`) || + isAbsolute(pathFromHandoffsDir) + ) { throw new Error(`Handoff path escapes directory: ${relativePath}`); } return resolved; } +function validateHandoffRelativePath(relativePath: string): string { + const trimmed = relativePath.trim(); + if (!trimmed) { + throw new Error("Handoff path must not be empty"); + } + if (trimmed.length > HANDOFF_PATH_MAX_LENGTH) { + throw new Error( + `Handoff path must be ${HANDOFF_PATH_MAX_LENGTH} characters or fewer`, + ); + } + if (trimmed.startsWith("/")) { + throw new Error(`Handoff path must be relative: ${relativePath}`); + } + if (!HANDOFF_PATH_ALLOWED_RE.test(trimmed)) { + throw new Error( + "Use a relative file path with letters, numbers, '.', '_', '-', or '/'.", + ); + } + if (trimmed.split("/").includes("..")) { + throw new Error(`Handoff path must not contain '..': ${relativePath}`); + } + return trimmed; +} + function emitSteer(sessionId: string | undefined, prompt: string): void { if (sessionId && prompt.trim()) { globalThis.__clinePluginHost?.emitEvent?.("steer_message", { @@ -407,9 +440,8 @@ const HandoffPathInput = z .trim() .min(1) .max(240) - .regex( - /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._/-]+$/, - "Use a relative file path with letters, numbers, '.', '_', '-', or '/'.", + .describe( + "Relative file path using letters, numbers, '.', '_', '-', or '/'. Must not be absolute or contain '..' segments.", ); const StartSubagentInput = z diff --git a/plugins/agents-squad/schema-portability.test.mjs b/plugins/agents-squad/schema-portability.test.mjs new file mode 100644 index 00000000..c5e2faef --- /dev/null +++ b/plugins/agents-squad/schema-portability.test.mjs @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const pluginSource = await readFile(new URL("./index.ts", import.meta.url), "utf8"); + +function containsZodRegexLookaround(source) { + for (const match of source.matchAll(/\.regex\(\s*\//g)) { + let escaped = false; + let inCharacterClass = false; + for (let index = match.index + match[0].length; index < source.length; index++) { + const character = source[index]; + if (escaped) { + escaped = false; + continue; + } + if (character === "\\") { + escaped = true; + continue; + } + if (character === "[") { + inCharacterClass = true; + continue; + } + if (character === "]" && inCharacterClass) { + inCharacterClass = false; + continue; + } + if (character === "/" && !inCharacterClass) break; + if (character !== "(" || inCharacterClass || source[index + 1] !== "?") continue; + const marker = source.slice(index + 2, index + 4); + if (marker[0] === "=" || marker[0] === "!" || marker === "<=" || marker === " { + assert.equal( + containsZodRegexLookaround(pluginSource), + false, + "OpenAI-compatible providers reject regex lookarounds in tool JSON Schemas", + ); +}); + +test("schema detector distinguishes lookarounds from literal text", () => { + assert.equal(containsZodRegexLookaround("z.string().regex(/^(?!\\/).+$/)"), true); + assert.equal(containsZodRegexLookaround(String.raw`z.string().regex(/\(?!literal/)`), false); + assert.equal(containsZodRegexLookaround("z.string().regex(/[(?!)]/ )"), false); +});