Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

44 changes: 38 additions & 6 deletions plugins/agents-squad/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
}
Comment on lines +306 to 314
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", {
Expand Down Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions plugins/agents-squad/schema-portability.test.mjs
Original file line number Diff line number Diff line change
@@ -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)) {
Comment on lines +7 to +8
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 === "<!") {
return true;
}
}
}
return false;
}

test("model-facing handoff schemas avoid regex lookarounds", () => {
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);
});