fix(qa-lab): keep workspace skills in sandbox

This commit is contained in:
Vincent Koc
2026-06-19 04:33:14 +02:00
parent 9ceb970a06
commit 1492f9906a
2 changed files with 51 additions and 3 deletions
@@ -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" }],
@@ -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<QaSuiteRuntimeEnv, "gateway">;
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<typeof extractQaToolPayload>[0]);
}
export { callPluginToolsMcp, findSkill, handleQaAction, writeWorkspaceSkill };
export {
callPluginToolsMcp,
findSkill,
handleQaAction,
resolveWorkspaceSkillPath,
writeWorkspaceSkill,
};