fix(security): materialize complete skill packages
This commit is contained in:
1 parent
f5b3de438e
commit
7b54fe54a8
10 files changed
+615
-78
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { statSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "@main/database";
|
||||
import { createGitHubImporter } from "@main/skills/import-github";
|
||||
|
||||
const commitSha = "a".repeat(40);
|
||||
|
||||
describe("GitHub skill import materialization", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let companyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-skill-github-import-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
companyId = db.core.company.saveCompany({
|
||||
name: "GitHub Skill Import Test",
|
||||
description: "Materializes complete GitHub skill packages",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("downloads and materializes GitHub inventory files", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = String(input);
|
||||
if (url === "https://api.github.com/repos/acme/skills/commits/HEAD") {
|
||||
return new Response(commitSha);
|
||||
}
|
||||
if (url === `https://api.github.com/repos/acme/skills/git/trees/${commitSha}?recursive=1`) {
|
||||
return new Response(JSON.stringify({
|
||||
sha: commitSha,
|
||||
tree: [
|
||||
{ path: "review/SKILL.md", type: "blob", mode: "100644" },
|
||||
{ path: "review/references/api.md", type: "blob", mode: "100644" },
|
||||
{ path: "review/scripts/run.sh", type: "blob", mode: "100755" },
|
||||
],
|
||||
}));
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/acme/skills/${commitSha}/review/SKILL.md`) {
|
||||
return new Response("---\nname: Review\nslug: review\n---\n# Review\n");
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/acme/skills/${commitSha}/review/references/api.md`) {
|
||||
return new Response("API notes");
|
||||
}
|
||||
if (url === `https://raw.githubusercontent.com/acme/skills/${commitSha}/review/scripts/run.sh`) {
|
||||
return new Response("echo review\n");
|
||||
}
|
||||
return new Response("missing", { status: 404 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const importer = createGitHubImporter({
|
||||
db,
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
skillFilesDir: (ownerCompanyId, slug) => join(tempDir, "skills", ownerCompanyId, slug),
|
||||
});
|
||||
|
||||
const results = await importer.importFromGitHub(companyId, "https://github.com/acme/skills", null, null);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
const materializedDir = join(tempDir, "skills", companyId, "review");
|
||||
await expect(readFile(join(materializedDir, "SKILL.md"), "utf-8")).resolves.toContain("# Review");
|
||||
await expect(readFile(join(materializedDir, "references", "api.md"), "utf-8")).resolves.toBe("API notes");
|
||||
await expect(readFile(join(materializedDir, "scripts", "run.sh"), "utf-8")).resolves.toBe("echo review\n");
|
||||
expect(statSync(join(materializedDir, "scripts", "run.sh")).mode & 0o100).toBe(0o100);
|
||||
expect(statSync(join(materializedDir, "scripts", "run.sh")).mode & 0o011).toBe(0);
|
||||
|
||||
const [skill] = db.core.skills.listCompanySkills(companyId);
|
||||
expect(skill?.trustLevel).toBe("scripts_executables");
|
||||
expect(skill?.fileInventory).toEqual([
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "references/api.md", kind: "reference" },
|
||||
{ path: "scripts/run.sh", kind: "script" },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledWith(`https://raw.githubusercontent.com/acme/skills/${commitSha}/review/references/api.md`);
|
||||
expect(fetchMock).toHaveBeenCalledWith(`https://raw.githubusercontent.com/acme/skills/${commitSha}/review/scripts/run.sh`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, truncate, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppDatabase } from "@main/database";
|
||||
import { MAX_SKILL_FILE_BYTES, MAX_TOTAL_BYTES } from "@main/skill-github";
|
||||
import { importFromLocal } from "@main/skills/import-local";
|
||||
|
||||
describe("local skill import materialization", () => {
|
||||
let tempDir: string;
|
||||
let db: AppDatabase;
|
||||
let companyId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-skill-local-import-"));
|
||||
db = new AppDatabase(tempDir);
|
||||
db.init();
|
||||
companyId = db.core.company.saveCompany({
|
||||
name: "Local Skill Import Test",
|
||||
description: "Materializes complete local skill packages",
|
||||
status: "active",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies local inventory files and records executable sources as scripts", async () => {
|
||||
const sourceDir = join(tempDir, "source", "review");
|
||||
await mkdir(join(sourceDir, "references"), { recursive: true });
|
||||
await mkdir(join(sourceDir, "scripts"), { recursive: true });
|
||||
await mkdir(join(sourceDir, "assets"), { recursive: true });
|
||||
await writeFile(join(sourceDir, "SKILL.md"), "---\nname: Review\nslug: review\n---\n# Review\n", "utf-8");
|
||||
await writeFile(join(sourceDir, "references", "api.md"), "API notes", "utf-8");
|
||||
await writeFile(join(sourceDir, "scripts", "run.sh"), "echo review\n", "utf-8");
|
||||
await chmod(join(sourceDir, "scripts", "run.sh"), 0o755);
|
||||
await writeFile(join(sourceDir, "assets", "logo.bin"), new Uint8Array([7, 8, 9]));
|
||||
|
||||
const results = await importFromLocal({
|
||||
db,
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
skillFilesDir: (ownerCompanyId, slug) => join(tempDir, "skills", ownerCompanyId, slug),
|
||||
}, companyId, sourceDir);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
const materializedDir = join(tempDir, "skills", companyId, "review");
|
||||
await expect(readFile(join(materializedDir, "SKILL.md"), "utf-8")).resolves.toContain("# Review");
|
||||
await expect(readFile(join(materializedDir, "references", "api.md"), "utf-8")).resolves.toBe("API notes");
|
||||
await expect(readFile(join(materializedDir, "scripts", "run.sh"), "utf-8")).resolves.toBe("echo review\n");
|
||||
await expect(readFile(join(materializedDir, "assets", "logo.bin"))).resolves.toEqual(Buffer.from([7, 8, 9]));
|
||||
expect(statSync(join(materializedDir, "scripts", "run.sh")).mode & 0o100).toBe(0o100);
|
||||
expect(statSync(join(materializedDir, "scripts", "run.sh")).mode & 0o011).toBe(0);
|
||||
|
||||
const [skill] = db.core.skills.listCompanySkills(companyId);
|
||||
expect(skill?.trustLevel).toBe("scripts_executables");
|
||||
expect(skill?.fileInventory).toEqual([
|
||||
{ path: "SKILL.md", kind: "skill" },
|
||||
{ path: "assets/logo.bin", kind: "asset" },
|
||||
{ path: "references/api.md", kind: "reference" },
|
||||
{ path: "scripts/run.sh", kind: "script" },
|
||||
]);
|
||||
expect(existsSync(materializedDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects local packages whose aggregate size exceeds the limit before reading oversized inventory", async () => {
|
||||
const sourceDir = join(tempDir, "source", "large");
|
||||
await mkdir(join(sourceDir, "assets"), { recursive: true });
|
||||
await writeFile(join(sourceDir, "SKILL.md"), "---\nname: Large\nslug: large\n---\n", "utf-8");
|
||||
|
||||
const fileCount = Math.ceil(MAX_TOTAL_BYTES / MAX_SKILL_FILE_BYTES) + 1;
|
||||
for (let index = 0; index < fileCount; index += 1) {
|
||||
await writeFile(join(sourceDir, "assets", `chunk-${index}.bin`), "");
|
||||
await truncate(join(sourceDir, "assets", `chunk-${index}.bin`), MAX_SKILL_FILE_BYTES);
|
||||
}
|
||||
|
||||
await expect(importFromLocal({
|
||||
db,
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
skillFilesDir: (ownerCompanyId, slug) => join(tempDir, "skills", ownerCompanyId, slug),
|
||||
}, companyId, sourceDir)).rejects.toThrow(
|
||||
`Skill package exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
|
||||
);
|
||||
|
||||
expect(existsSync(join(tempDir, "skills", companyId, "large"))).toBe(false);
|
||||
expect(db.core.skills.listCompanySkills(companyId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { MAX_SKILL_FILE_BYTES, MAX_TOTAL_BYTES } from "@main/skill-github";
|
||||
import { materializeSkillFiles } from "@main/skills/materialize";
|
||||
|
||||
describe("skill materialization", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-skill-materialize-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("materializes every inventory file and preserves script executability", async () => {
|
||||
const dir = join(tempDir, "skills", "company", "review");
|
||||
|
||||
await materializeSkillFiles(dir, [
|
||||
{ path: "SKILL.md", kind: "skill", content: "---\nname: Review\n---\n" },
|
||||
{ path: "references/api.md", kind: "reference", content: "API notes" },
|
||||
{ path: "assets/logo.bin", kind: "asset", content: new Uint8Array([1, 2, 3]) },
|
||||
{ path: "scripts/run.sh", kind: "script", content: "echo review\n" },
|
||||
]);
|
||||
|
||||
await expect(readFile(join(dir, "SKILL.md"), "utf-8")).resolves.toContain("Review");
|
||||
await expect(readFile(join(dir, "references", "api.md"), "utf-8")).resolves.toBe("API notes");
|
||||
await expect(readFile(join(dir, "assets", "logo.bin"))).resolves.toEqual(Buffer.from([1, 2, 3]));
|
||||
await expect(readFile(join(dir, "scripts", "run.sh"), "utf-8")).resolves.toBe("echo review\n");
|
||||
expect(statSync(join(dir, "scripts", "run.sh")).mode & 0o100).toBe(0o100);
|
||||
expect(statSync(join(dir, "scripts", "run.sh")).mode & 0o011).toBe(0);
|
||||
expect(statSync(join(dir, "references", "api.md")).mode & 0o111).toBe(0);
|
||||
});
|
||||
|
||||
it("replaces stale files when a skill is re-materialized", async () => {
|
||||
const dir = join(tempDir, "skills", "company", "review");
|
||||
await materializeSkillFiles(dir, [
|
||||
{ path: "SKILL.md", kind: "skill", content: "old" },
|
||||
{ path: "references/stale.md", kind: "reference", content: "stale" },
|
||||
]);
|
||||
|
||||
await materializeSkillFiles(dir, [
|
||||
{ path: "SKILL.md", kind: "skill", content: "new" },
|
||||
]);
|
||||
|
||||
await expect(readFile(join(dir, "SKILL.md"), "utf-8")).resolves.toBe("new");
|
||||
expect(existsSync(join(dir, "references", "stale.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects traversal paths before writing the package", async () => {
|
||||
const dir = join(tempDir, "skills", "company", "bad");
|
||||
|
||||
await expect(materializeSkillFiles(dir, [
|
||||
{ path: "SKILL.md", kind: "skill", content: "ok" },
|
||||
{ path: "../escape.md", kind: "markdown", content: "bad" },
|
||||
])).rejects.toThrow("Skill file path contains traversal segments: ../escape.md");
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces single-file and aggregate package byte limits", async () => {
|
||||
const dir = join(tempDir, "skills", "company", "large");
|
||||
|
||||
await expect(materializeSkillFiles(dir, [
|
||||
{ path: "SKILL.md", kind: "skill", content: "ok" },
|
||||
{ path: "assets/too-large.bin", kind: "asset", content: new Uint8Array(MAX_SKILL_FILE_BYTES + 1) },
|
||||
])).rejects.toThrow(`assets/too-large.bin exceeds ${MAX_SKILL_FILE_BYTES}-byte limit`);
|
||||
|
||||
const packageFiles = [
|
||||
{ path: "SKILL.md", kind: "skill" as const, content: "ok" },
|
||||
...Array.from({ length: Math.ceil(MAX_TOTAL_BYTES / MAX_SKILL_FILE_BYTES) }, (_, index) => ({
|
||||
path: `assets/chunk-${index}.bin`,
|
||||
kind: "asset" as const,
|
||||
content: new Uint8Array(MAX_SKILL_FILE_BYTES),
|
||||
})),
|
||||
];
|
||||
await expect(materializeSkillFiles(dir, packageFiles)).rejects.toThrow(
|
||||
`Skill package exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user