fix(security): materialize complete skill packages

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 01:36:16 -04:00
1 parent f5b3de438e
commit 7b54fe54a8
10 files changed
+615 -78

No files matched your search

+76 -16
View File
@@ -1,4 +1,10 @@
import { readResponseWithLimit } from "./skills/read-response-with-limit.js";
import { readResponseBytesWithLimit, readResponseWithLimit } from "./skills/read-response-with-limit.js";
export interface GitHubSkillFileData {
path: string;
content: Uint8Array;
mode: string | null;
}
/**
* Data returned for each skill found in a GitHub repository.
@@ -6,7 +12,7 @@ import { readResponseWithLimit } from "./skills/read-response-with-limit.js";
export interface GitHubSkillData {
skillMdPath: string;
markdown: string;
inventory: string[];
files: GitHubSkillFileData[];
owner: string;
repo: string;
}
@@ -20,12 +26,44 @@ interface Logger {
/** 2 MB — generous upper bound for a single SKILL.md (markdown) file. */
export const MAX_SKILL_BYTES = 2 * 1024 * 1024;
/** 32 MB — aggregate upper bound for all SKILL.md content from a single skill pack. */
/** 8 MB — upper bound for a single non-SKILL.md package file. */
export const MAX_SKILL_FILE_BYTES = 8 * 1024 * 1024;
/** 32 MB — aggregate upper bound for all materialized files from a skill pack. */
export const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
/** 10 MB — upper bound for the recursive git tree JSON response. */
const MAX_TREE_BYTES = 10 * 1024 * 1024;
interface GitHubTreeEntry {
path: string;
type: string;
mode?: string;
}
function encodeGitHubBlobPath(filePath: string): string {
if (filePath.includes("\0") || filePath.includes("\\") || filePath.startsWith("/") || /^[A-Za-z]:/.test(filePath)) {
throw new Error(`Invalid GitHub skill file path: ${filePath}`);
}
const parts = filePath.split("/");
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) {
throw new Error(`Invalid GitHub skill file path: ${filePath}`);
}
return parts.map((part) => encodeURIComponent(part)).join("/");
}
function rawGitHubUrl(owner: string, repo: string, ref: string, filePath: string): string {
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${encodeGitHubBlobPath(filePath)}`;
}
function assertTotalSkillBytes(totalBytes: number): void {
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(
`Skill pack exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
);
}
}
/**
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
* Returns the 40-character hex SHA on success, null on error.
@@ -87,7 +125,7 @@ export async function fetchGitHubSkills(
return { skills: [], commitSha: ref };
}
const treeData = JSON.parse(await readResponseWithLimit(treeResponse, MAX_TREE_BYTES)) as {
tree?: Array<{ path: string; type: string }>;
tree?: GitHubTreeEntry[];
sha?: string;
};
const treeSha = typeof treeData.sha === "string" ? treeData.sha : ref;
@@ -120,14 +158,15 @@ export async function fetchGitHubSkills(
for (const skillMdPath of skillMdPaths) {
try {
let skillBytes = 0;
// Download content via raw.githubusercontent.com (no API rate limit)
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${skillMdPath}`;
const rawUrl = rawGitHubUrl(owner, repo, ref, skillMdPath);
const contentResponse = await fetch(rawUrl);
if (!contentResponse.ok) {
logger?.warn(`Failed to download ${rawUrl}: ${contentResponse.status}`);
continue;
}
const remainingBudget = MAX_TOTAL_BYTES - totalBytes;
const remainingBudget = MAX_TOTAL_BYTES - totalBytes - skillBytes;
const perFileCap = Math.min(MAX_SKILL_BYTES, Math.max(0, remainingBudget));
if (perFileCap === 0) {
throw new Error(
@@ -135,12 +174,8 @@ export async function fetchGitHubSkills(
);
}
const markdown = await readResponseWithLimit(contentResponse, perFileCap);
totalBytes += Buffer.byteLength(markdown, "utf-8");
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(
`Skill pack exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
);
}
skillBytes += Buffer.byteLength(markdown, "utf-8");
assertTotalSkillBytes(totalBytes + skillBytes);
// Collect sibling files in the same directory (or subdirectories)
const skillDir =
@@ -148,17 +183,42 @@ export async function fetchGitHubSkills(
? ""
: skillMdPath.slice(0, skillMdPath.lastIndexOf("/"));
const prefix = skillDir ? `${skillDir}/` : "";
const inventory = allPaths.filter(
(p) => p !== skillMdPath && (prefix ? p.startsWith(prefix) : !p.includes("/")),
const inventoryEntries = allEntries.filter(
(entry) => entry.path !== skillMdPath && (prefix ? entry.path.startsWith(prefix) : !entry.path.includes("/")),
);
const files: GitHubSkillFileData[] = [];
for (const entry of inventoryEntries) {
const fileRemainingBudget = MAX_TOTAL_BYTES - totalBytes - skillBytes;
const fileCap = Math.min(MAX_SKILL_FILE_BYTES, Math.max(0, fileRemainingBudget));
if (fileCap === 0) {
throw new Error(
`Skill pack exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
);
}
const fileUrl = rawGitHubUrl(owner, repo, ref, entry.path);
const fileResponse = await fetch(fileUrl);
if (!fileResponse.ok) {
throw new Error(`Failed to download ${fileUrl}: ${fileResponse.status}`);
}
const content = await readResponseBytesWithLimit(fileResponse, fileCap);
skillBytes += content.byteLength;
assertTotalSkillBytes(totalBytes + skillBytes);
files.push({
path: entry.path,
content,
mode: entry.mode ?? null,
});
}
skills.push({
skillMdPath,
markdown,
inventory,
files,
owner,
repo,
repo,
});
totalBytes += skillBytes;
} catch (err) {
logger?.error(`Error downloading skill ${skillMdPath}:`, err);
continue;
+10 -2
View File
@@ -1,4 +1,4 @@
import { readdirSync, realpathSync, type Dirent } from "node:fs";
import { readdirSync, realpathSync, statSync, type Dirent } from "node:fs";
import { join, resolve, relative } from "node:path";
import type { FileInventoryEntry } from "../skill-types.js";
import { classifyInventoryKind } from "../skill-format.js";
@@ -133,10 +133,18 @@ export function buildLocalInventory(
if (!entry.isFile()) continue;
const resolvedPath = resolve(fullPath);
if (resolvedPath === skillMdPath) continue;
let fileStat;
try {
fileStat = statSync(resolvedPath);
} catch (err) {
logger.warn(`[skill-import] statSync failed for ${resolvedPath}:`, err instanceof Error ? err.message : String(err));
continue;
}
if (!fileStat.isFile()) continue;
const relativePath = relative(skillDir, resolvedPath).replace(/\\/g, "/");
inventory.push({
path: relativePath,
kind: classifyInventoryKind(relativePath),
kind: (fileStat.mode & 0o111) !== 0 ? "script" : classifyInventoryKind(relativePath),
});
}
}
+10 -35
View File
@@ -1,6 +1,5 @@
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join, basename, dirname } from "node:path";
import { basename, dirname } from "node:path";
import type { AppDatabase } from "../database";
import type { FileInventoryEntry, SkillImportResult, SkillSourceType } from "../skill-types.js";
import {
@@ -15,9 +14,8 @@ import {
import {
resolveGitHubCommitSha,
fetchGitHubSkills,
MAX_SKILL_BYTES,
MAX_TOTAL_BYTES,
} from "../skill-github.js";
import { materializeSkillFiles, type MaterializedSkillFile } from "./materialize";
interface GitHubImportDeps {
db: AppDatabase;
@@ -45,23 +43,6 @@ export function createGitHubImporter({ db, logger, skillFilesDir }: GitHubImport
}
}
async function materializeSkillFiles(
companyId: string,
slug: string,
markdown: string,
_inventory: FileInventoryEntry[],
): Promise<string> {
if (markdown.length * 2 > MAX_SKILL_BYTES) {
throw new Error(
`SKILL.md exceeds ${MAX_SKILL_BYTES}-byte limit`,
);
}
const dir = skillFilesDir(companyId, slug);
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "SKILL.md"), markdown, "utf-8");
return dir;
}
async function importFromGitHub(
companyId: string,
url: string,
@@ -90,16 +71,8 @@ export function createGitHubImporter({ db, logger, skillFilesDir }: GitHubImport
const results: SkillImportResult[] = [];
const sourceType: SkillSourceType = originalSkillsShUrl ? "skills_sh" : "github";
const nowTs = new Date().toISOString();
let totalMaterializedBytes = 0;
for (const ghSkill of githubSkills) {
const skillBytes = Buffer.byteLength(ghSkill.markdown, "utf-8");
totalMaterializedBytes += skillBytes;
if (totalMaterializedBytes > MAX_TOTAL_BYTES) {
throw new Error(
`Skill pack exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`,
);
}
const parsed = parseFrontmatterMarkdown(ghSkill.markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
@@ -115,16 +88,18 @@ export function createGitHubImporter({ db, logger, skillFilesDir }: GitHubImport
? ""
: ghSkill.skillMdPath.slice(0, ghSkill.skillMdPath.lastIndexOf("/"));
const prefix = skillDir ? `${skillDir}/` : "";
const inventory: FileInventoryEntry[] = [
{ path: "SKILL.md", kind: "skill" as const },
...ghSkill.inventory.map((filePath) => {
const relativePath = prefix ? filePath.slice(prefix.length) : filePath;
const materializedFiles: MaterializedSkillFile[] = [
{ path: "SKILL.md", kind: "skill", content: ghSkill.markdown },
...ghSkill.files.map((file) => {
const relativePath = prefix ? file.path.slice(prefix.length) : file.path;
return {
path: relativePath,
kind: classifyInventoryKind(relativePath),
kind: file.mode === "100755" ? "script" as const : classifyInventoryKind(relativePath),
content: file.content,
};
}),
];
const inventory: FileInventoryEntry[] = materializedFiles.map(({ path, kind }) => ({ path, kind }));
const metadata: Record<string, unknown> = {
sourceKind: "github",
@@ -153,7 +128,7 @@ export function createGitHubImporter({ db, logger, skillFilesDir }: GitHubImport
const skillId = randomUUID();
await materializeSkillFiles(companyId, rawSlug, ghSkill.markdown, inventory);
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), materializedFiles);
const { isUpdate } = db.core.skills.upsertCompanySkill({
id: skillId,
+78 -6
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
import { resolve, basename, dirname } from "node:path";
import { resolve, basename, dirname, isAbsolute, relative } from "node:path";
import type { AppDatabase } from "../database";
import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js";
import { parseFrontmatterMarkdown, deriveTrustLevel } from "../skill-format.js";
@@ -14,7 +14,8 @@ import {
findSkillMdFiles,
buildLocalInventory,
} from "./discovery";
import { materializeSkillFiles } from "./materialize";
import { materializeSkillFiles, normalizeMaterializedRelativePath, type MaterializedSkillFile } from "./materialize";
import { MAX_SKILL_BYTES, MAX_SKILL_FILE_BYTES, MAX_TOTAL_BYTES } from "../skill-github.js";
export interface LocalImportContext {
db: AppDatabase;
@@ -26,6 +27,70 @@ export interface LocalImportContext {
skillFilesDir: (companyId: string, slug: string) => string;
}
function assertLocalFileSize(filePath: string, size: number, maxBytes: number): void {
if (size > maxBytes) {
throw new Error(`${filePath} exceeds ${maxBytes}-byte limit`);
}
}
function assertLocalPackageSize(totalBytes: number): void {
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(`Skill package exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`);
}
}
function readLocalSkillMarkdown(skillMdPath: string): string {
const stat = statSync(skillMdPath);
if (!stat.isFile()) {
throw new Error(`Local skill path is not a file: ${skillMdPath}`);
}
assertLocalFileSize(skillMdPath, stat.size, MAX_SKILL_BYTES);
return readFileSync(skillMdPath, "utf-8");
}
function assertPathInsideRoot(rootDir: string, targetPath: string, relativePath: string): void {
const relativePathFromRoot = relative(rootDir, targetPath);
if (relativePathFromRoot.length === 0 || relativePathFromRoot.startsWith("..") || isAbsolute(relativePathFromRoot)) {
throw new Error(`Skill inventory path escapes source directory: ${relativePath}`);
}
}
function buildLocalMaterializedFiles(
skillDir: string,
markdown: string,
markdownBytes: number,
inventory: FileInventoryEntry[],
): MaterializedSkillFile[] {
const realSkillDir = realpathSync(skillDir);
const files: MaterializedSkillFile[] = [
{ path: "SKILL.md", kind: "skill", content: markdown },
];
let totalBytes = markdownBytes;
assertLocalPackageSize(totalBytes);
for (const entry of inventory) {
if (entry.path === "SKILL.md") continue;
const relativePath = normalizeMaterializedRelativePath(entry.path);
const sourcePath = resolve(realSkillDir, ...relativePath.split("/"));
const realSourcePath = realpathSync(sourcePath);
assertPathInsideRoot(realSkillDir, realSourcePath, relativePath);
const stat = statSync(realSourcePath);
if (!stat.isFile()) {
throw new Error(`Skill inventory path is not a file: ${relativePath}`);
}
assertLocalFileSize(relativePath, stat.size, MAX_SKILL_FILE_BYTES);
totalBytes += stat.size;
assertLocalPackageSize(totalBytes);
files.push({
path: relativePath,
kind: entry.kind,
content: readFileSync(realSourcePath),
});
}
return files;
}
export async function importFromLocal(
ctx: LocalImportContext,
companyId: string,
@@ -47,7 +112,8 @@ export async function importFromLocal(
const nowTs = new Date().toISOString();
if (stat.isFile()) {
const markdown = readFileSync(resolvedPath, "utf-8");
const markdown = readLocalSkillMarkdown(resolvedPath);
assertLocalPackageSize(Buffer.byteLength(markdown, "utf-8"));
const parsed = parseFrontmatterMarkdown(markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
@@ -67,7 +133,9 @@ export async function importFromLocal(
});
const skillId = randomUUID();
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory);
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), [
{ path: "SKILL.md", kind: "skill", content: markdown },
]);
const { isUpdate } = db.core.skills.upsertCompanySkill({
id: skillId,
@@ -100,7 +168,8 @@ export async function importFromLocal(
for (const skillMdPath of skillMdFiles) {
const skillDir = dirname(skillMdPath);
const markdown = readFileSync(skillMdPath, "utf-8");
const markdown = readLocalSkillMarkdown(skillMdPath);
const markdownBytes = Buffer.byteLength(markdown, "utf-8");
const parsed = parseFrontmatterMarkdown(markdown);
const rawSlug =
normalizeSkillSlug(parsed.frontmatter.slug as string | undefined) ??
@@ -119,7 +188,10 @@ export async function importFromLocal(
});
const skillId = randomUUID();
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory);
await materializeSkillFiles(
skillFilesDir(companyId, rawSlug),
buildLocalMaterializedFiles(skillDir, markdown, markdownBytes, inventory),
);
const { isUpdate } = db.core.skills.upsertCompanySkill({
id: skillId,
+3 -1
View File
@@ -59,7 +59,9 @@ export async function importFromUrl(
});
const skillId = randomUUID();
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), markdown, inventory);
await materializeSkillFiles(skillFilesDir(companyId, rawSlug), [
{ path: "SKILL.md", kind: "skill", content: markdown },
]);
const { isUpdate } = db.core.skills.upsertCompanySkill({
id: skillId,
+133 -10
View File
@@ -1,19 +1,142 @@
import { existsSync } from "node:fs";
import { mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdir, rename, writeFile, rm } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import type { FileInventoryEntry } from "../skill-types.js";
import { MAX_SKILL_BYTES } from "../skill-github.js";
import { MAX_SKILL_BYTES, MAX_SKILL_FILE_BYTES, MAX_TOTAL_BYTES } from "../skill-github.js";
export interface MaterializedSkillFile extends FileInventoryEntry {
content: string | Uint8Array;
}
interface PreparedSkillFile extends FileInventoryEntry {
content: Uint8Array;
}
const MATERIALIZED_FILE_MODE = 0o600;
const MATERIALIZED_SCRIPT_MODE = 0o700;
const MATERIALIZED_DIR_MODE = 0o700;
export function normalizeMaterializedRelativePath(filePath: string): string {
if (filePath.includes("\0")) {
throw new Error("Skill file path contains a null byte");
}
const normalized = filePath.replace(/\\/g, "/");
if (normalized !== filePath) {
throw new Error(`Skill file path must use POSIX separators: ${filePath}`);
}
if (normalized.length === 0 || normalized.startsWith("/") || /^[A-Za-z]:/.test(normalized)) {
throw new Error(`Skill file path must be relative: ${filePath}`);
}
const parts = normalized.split("/");
if (parts.some((part) => part.length === 0 || part === "." || part === "..")) {
throw new Error(`Skill file path contains traversal segments: ${filePath}`);
}
return parts.join("/");
}
function encodeContent(content: string | Uint8Array): Uint8Array {
if (typeof content === "string") {
return Buffer.from(content, "utf-8");
}
return content;
}
function ensureTargetInsideRoot(rootDir: string, targetPath: string, relativePath: string): void {
const root = resolve(rootDir);
const targetRelative = relative(root, targetPath);
if (targetRelative.length === 0 || targetRelative.startsWith("..") || isAbsolute(targetRelative)) {
throw new Error(`Skill file path escapes materialization root: ${relativePath}`);
}
}
function prepareMaterializedFiles(files: MaterializedSkillFile[]): PreparedSkillFile[] {
const seen = new Set<string>();
const prepared: PreparedSkillFile[] = [];
let totalBytes = 0;
let hasRootSkill = false;
for (const file of files) {
const path = normalizeMaterializedRelativePath(file.path);
const duplicateKey = process.platform === "win32" ? path.toLowerCase() : path;
if (seen.has(duplicateKey)) {
throw new Error(`Duplicate skill file path: ${path}`);
}
seen.add(duplicateKey);
const content = encodeContent(file.content);
const maxBytes = path === "SKILL.md" ? MAX_SKILL_BYTES : MAX_SKILL_FILE_BYTES;
if (content.byteLength > maxBytes) {
throw new Error(`${path} exceeds ${maxBytes}-byte limit`);
}
totalBytes += content.byteLength;
if (totalBytes > MAX_TOTAL_BYTES) {
throw new Error(`Skill package exceeded aggregate limit of ${MAX_TOTAL_BYTES} bytes`);
}
if (path === "SKILL.md") {
hasRootSkill = true;
}
prepared.push({ path, kind: file.kind, content });
}
if (!hasRootSkill) {
throw new Error("Skill package is missing SKILL.md");
}
return prepared;
}
async function replaceDirectoryWithFiles(dir: string, files: PreparedSkillFile[]): Promise<void> {
const parentDir = dirname(dir);
const dirName = basename(dir);
const stagingDir = join(parentDir, `.${dirName}.${randomUUID()}.tmp`);
let backupDir: string | null = null;
await mkdir(parentDir, { recursive: true, mode: MATERIALIZED_DIR_MODE });
await mkdir(stagingDir, { recursive: true, mode: MATERIALIZED_DIR_MODE });
try {
for (const file of files) {
const target = resolve(stagingDir, ...file.path.split("/"));
ensureTargetInsideRoot(stagingDir, target, file.path);
await mkdir(dirname(target), { recursive: true, mode: MATERIALIZED_DIR_MODE });
await writeFile(target, file.content, {
mode: file.kind === "script" ? MATERIALIZED_SCRIPT_MODE : MATERIALIZED_FILE_MODE,
});
}
if (existsSync(dir)) {
backupDir = join(parentDir, `.${dirName}.${randomUUID()}.old`);
await rename(dir, backupDir);
}
try {
await rename(stagingDir, dir);
} catch (error) {
if (backupDir && existsSync(backupDir)) {
await rename(backupDir, dir);
backupDir = null;
}
throw error;
}
if (backupDir) {
await rm(backupDir, { recursive: true, force: true });
backupDir = null;
}
} finally {
await rm(stagingDir, { recursive: true, force: true });
if (backupDir) {
await rm(backupDir, { recursive: true, force: true });
}
}
}
export async function materializeSkillFiles(
dir: string,
markdown: string,
_inventory: FileInventoryEntry[],
files: MaterializedSkillFile[],
): Promise<string> {
if (markdown.length * 2 > MAX_SKILL_BYTES) {
throw new Error(`SKILL.md exceeds ${MAX_SKILL_BYTES}-byte limit`);
}
await mkdir(dir, { recursive: true });
await writeFile(join(dir, "SKILL.md"), markdown, "utf-8");
const prepared = prepareMaterializedFiles(files);
await replaceDirectoryWithFiles(dir, prepared);
return dir;
}
+31 -8
View File
@@ -1,8 +1,13 @@
export async function readResponseWithLimit(response: Response, maxBytes: number): Promise<string> {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function assertValidByteLimit(maxBytes: number): void {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new Error(`Invalid response byte limit: ${maxBytes}`);
}
}
function validateContentLength(response: Response, maxBytes: number): void {
const contentLengthHeader = response.headers.get("Content-Length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
@@ -13,15 +18,29 @@ export async function readResponseWithLimit(response: Response, maxBytes: number
throw new Error(`Response too large: ${contentLength} bytes (max ${maxBytes})`);
}
}
}
function assertWithinLimit(byteLength: number, maxBytes: number): void {
if (byteLength > maxBytes) {
throw new Error(`Response body exceeded ${maxBytes} bytes`);
}
}
export async function readResponseBytesWithLimit(response: Response, maxBytes: number): Promise<Uint8Array> {
assertValidByteLimit(maxBytes);
validateContentLength(response, maxBytes);
const reader = response.body?.getReader();
if (!reader) {
const text = await response.text();
const byteLength = new TextEncoder().encode(text).byteLength;
if (byteLength > maxBytes) {
throw new Error(`Response body exceeded ${maxBytes} bytes`);
if (typeof response.arrayBuffer === "function") {
const bytes = new Uint8Array(await response.arrayBuffer());
assertWithinLimit(bytes.byteLength, maxBytes);
return bytes;
}
return text;
const text = await response.text();
const bytes = encoder.encode(text);
assertWithinLimit(bytes.byteLength, maxBytes);
return bytes;
}
const chunks: Uint8Array[] = [];
@@ -33,7 +52,7 @@ export async function readResponseWithLimit(response: Response, maxBytes: number
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Response body exceeded ${maxBytes} bytes`);
assertWithinLimit(totalBytes, maxBytes);
}
chunks.push(value);
}
@@ -45,5 +64,9 @@ export async function readResponseWithLimit(response: Response, maxBytes: number
offset += chunk.byteLength;
}
return new TextDecoder().decode(body);
return body;
}
export async function readResponseWithLimit(response: Response, maxBytes: number): Promise<string> {
return decoder.decode(await readResponseBytesWithLimit(response, maxBytes));
}
@@ -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([]);
});
});
+84
View File
@@ -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`,
);
});
});