diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts index ee0ebcecdc2e..7412acf91996 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.test.ts @@ -52,6 +52,7 @@ import { callPluginToolsMcp, findSkill, handleQaAction, + resolveWorkspaceSkillPath, writeWorkspaceSkill, } from "./suite-runtime-agent-tools.js"; import { createTempDirHarness } from "./temp-dir.test-helper.js"; @@ -91,6 +92,25 @@ describe("qa suite runtime agent tools helpers", () => { expect(skillPath).toBe(path.join(workspaceDir, "skills", "my-skill", "SKILL.md")); }); + it("rejects workspace skill names that escape the skills directory", async () => { + const workspaceDir = await makeTempDir("qa-workspace-"); + + for (const name of ["", " spaced", "spaced ", ".", "..", "../escape", "..\\escape", "a/b"]) { + expect(() => resolveWorkspaceSkillPath(workspaceDir, name), name).toThrow( + `invalid QA workspace skill name: ${JSON.stringify(name)}`, + ); + await expect( + writeWorkspaceSkill({ + env: { gateway: { workspaceDir } } as never, + name, + body: "escape", + }), + ).rejects.toThrow(`invalid QA workspace skill name: ${JSON.stringify(name)}`); + } + + await expect(fs.readdir(path.join(workspaceDir, "skills"))).rejects.toThrow(); + }); + it("routes generic transport actions through the payload extractor", async () => { const handleAction = vi.fn(async () => ({ content: [{ type: "text", text: "done" }], diff --git a/extensions/qa-lab/src/suite-runtime-agent-tools.ts b/extensions/qa-lab/src/suite-runtime-agent-tools.ts index f92974b1a2b9..b5c8f7f1f6c6 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-tools.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-tools.ts @@ -26,14 +26,36 @@ function findSkill(skills: QaSkillStatusEntry[], name: string) { return skills.find((skill) => skill.name === name); } +function resolveWorkspaceSkillPath(workspaceDir: string, name: string) { + const trimmed = name.trim(); + if ( + !trimmed || + trimmed !== name || + trimmed === "." || + trimmed === ".." || + trimmed.includes("\0") || + /[\\/]/u.test(trimmed) + ) { + throw new Error(`invalid QA workspace skill name: ${JSON.stringify(name)}`); + } + + const skillsDir = path.resolve(workspaceDir, "skills"); + const skillDir = path.resolve(skillsDir, trimmed); + const relative = path.relative(skillsDir, skillDir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`invalid QA workspace skill name: ${JSON.stringify(name)}`); + } + return path.join(skillDir, "SKILL.md"); +} + async function writeWorkspaceSkill(params: { env: Pick; name: string; body: string; }) { - const skillDir = path.join(params.env.gateway.workspaceDir, "skills", params.name); + const skillPath = resolveWorkspaceSkillPath(params.env.gateway.workspaceDir, params.name); + const skillDir = path.dirname(skillPath); await fs.mkdir(skillDir, { recursive: true }); - const skillPath = path.join(skillDir, "SKILL.md"); await fs.writeFile(skillPath, `${params.body.trim()}\n`, "utf8"); return skillPath; } @@ -113,4 +135,10 @@ async function handleQaAction(params: { return extractQaToolPayload(result as Parameters[0]); } -export { callPluginToolsMcp, findSkill, handleQaAction, writeWorkspaceSkill }; +export { + callPluginToolsMcp, + findSkill, + handleQaAction, + resolveWorkspaceSkillPath, + writeWorkspaceSkill, +};