/** * Filesystem boundary hardening tests. * * Covers high-risk edge cases for workspace storage and Pi skill browsing: * - Symlink traversal / realpath boundary bypass * - Path traversal via ../ sequences * - Concurrent read-while-delete behavior * * All tests use temporary directories and clean up after themselves. */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { SkillRegistry } from "../src/skills.js"; import { Storage } from "../src/storage.js"; // ─── Fixtures ─── const VALID_SKILL_MD = `--- name: test-skill description: A boundary test skill --- # Test Skill `; function makeSkillDir(baseDir: string, name: string, content?: string): string { const dir = join(baseDir, name); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "SKILL.md"), content ?? VALID_SKILL_MD); return dir; } function makeSecretFile(dir: string, name = "secret.txt", content = "TOP SECRET DATA"): string { const path = join(dir, name); writeFileSync(path, content); return path; } // ─── SkillRegistry: Symlink Traversal ─── describe("SkillRegistry symlink traversal", () => { let scanDir: string; let outsideDir: string; let registry: SkillRegistry; beforeEach(() => { scanDir = mkdtempSync(join(tmpdir(), "fs-bound-registry-")); outsideDir = mkdtempSync(join(tmpdir(), "fs-bound-outside-")); registry = new SkillRegistry([], { debounceMs: 50 }); (registry as any).scanDirs = [scanDir]; }); afterEach(() => { registry.stopWatching(); rmSync(scanDir, { recursive: true, force: true }); rmSync(outsideDir, { recursive: true, force: true }); }); it("blocks getFileContent via symlink escaping skill boundary", () => { const SKILL_MD = `---\nname: registry-trapped\ndescription: "Trapped skill"\n---\n# Trapped\n`; const dir = makeSkillDir(scanDir, "registry-trapped", SKILL_MD); // Plant outside secret + symlink makeSecretFile(outsideDir, "registry-secret.txt"); symlinkSync(join(outsideDir, "registry-secret.txt"), join(dir, "escape.txt")); registry.scan(); expect(registry.getFileContent("registry-trapped", "escape.txt")).toBeUndefined(); }); it("blocks getFileContent via ../ combined with symlink", () => { const SKILL_MD = `---\nname: combo-attack\ndescription: "Combo attack skill"\n---\n# Combo\n`; makeSkillDir(scanDir, "combo-attack", SKILL_MD); registry.scan(); // ../ path traversal expect(registry.getFileContent("combo-attack", "../../../etc/passwd")).toBeUndefined(); }); }); // ─── Workspace Storage Boundary Tests ─── describe("Workspace storage boundary hardening", () => { let dataDir: string; let storage: Storage; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), "fs-bound-ws-")); storage = new Storage(dataDir); }); afterEach(() => { rmSync(dataDir, { recursive: true, force: true }); }); it("workspace IDs are generated internally — traversal IDs produce sanitized garbage", () => { // Workspace IDs are always generated by generateId() (base64url safe chars). // If a traversal ID like "../config" reaches getWorkspace, the store reads // whatever file it resolves to and sanitizes it through the workspace schema. // This is defense-in-depth: the API layer should reject malformed IDs before // they reach the store. Here we verify the store doesn't crash. const result = storage.getWorkspace("../config"); // config.json exists and gets parsed — sanitized into a garbage workspace // This is safe because: (1) API validates ID format, (2) result is sanitized if (result) { // If it read something, it should have been sanitized expect(typeof result.id).toBe("string"); expect(typeof result.name).toBe("string"); } }); it("workspace getWorkspace with deep traversal does not crash", () => { // Even with deeply nested traversal, the store should not throw const result = storage.getWorkspace("../../etc/passwd"); // /etc/passwd doesn't parse as JSON → undefined expect(result).toBeUndefined(); }); it("workspace names with special chars are stored safely", () => { // Workspace names are stored as JSON content, not filesystem paths // but verify they round-trip safely const specialNames = [ 'name with "quotes"', "name\nwith\nnewlines", "name\twith\ttabs", "a".repeat(1000), // very long name "", "${process.exit()}", "name/with/slashes", "name\\with\\backslashes", ]; for (const name of specialNames) { const ws = storage.createWorkspace({ name }); const loaded = storage.getWorkspace(ws.id); expect(loaded).toBeDefined(); expect(loaded!.name).toBe(name); } }); it("corrupt workspace JSON returns undefined, not crash", () => { const ws = storage.createWorkspace({ name: "will-corrupt" }); const path = join(dataDir, "workspaces", `${ws.id}.json`); // Various corruption scenarios const corruptions = [ "", // empty "{", // truncated JSON "null", // valid JSON but not an object "[]", // array instead of object '{"id": 42}', // wrong type for id "\x00\x01\x02", // binary garbage ]; for (const corrupt of corruptions) { writeFileSync(path, corrupt); // Should not throw const result = storage.getWorkspace(ws.id); // For empty/binary, parsing fails → undefined // For valid-but-wrong JSON, sanitize returns a workspace // Either way, no crash expect(result === undefined || typeof result === "object").toBe(true); } }); it("listWorkspaces handles mixed valid and corrupt files", () => { storage.createWorkspace({ name: "good-one" }); storage.createWorkspace({ name: "good-two" }); // Inject a corrupt file writeFileSync(join(dataDir, "workspaces", "corrupt.json"), "{{invalid}}"); const list = storage.listWorkspaces(); // At least the two good ones should load expect(list.length).toBeGreaterThanOrEqual(2); expect(list.map((w) => w.name)).toContain("good-one"); expect(list.map((w) => w.name)).toContain("good-two"); }); it("deleteWorkspace with traversal ID — defense-in-depth note", () => { // NOTE: The workspace store does NOT validate IDs against path traversal. // Workspace IDs are always generated internally (generateId), so traversal // IDs should never reach the store in practice. The API layer validates // ID format before calling store methods. This test documents the behavior. // // If ../config resolves to an existing file, deleteWorkspace removes it. // This is acceptable because: // 1. The API layer rejects IDs that don't match /^[A-Za-z0-9_-]+$/ // 2. Internal callers always use generated IDs // // Verify at minimum it doesn't crash: const result = storage.deleteWorkspace("../../nonexistent/file"); expect(result).toBe(false); }); it("updateWorkspace with deep traversal ID does not crash", () => { // Similar to above — store doesn't validate IDs, API layer does const result = storage.updateWorkspace("../../etc/nonexistent", { name: "hacked" }); expect(result).toBeUndefined(); }); }); // ─── Workspace Concurrent Operations ─── describe("Workspace concurrent read-while-delete", () => { let dataDir: string; let storage: Storage; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), "fs-bound-ws-concurrent-")); storage = new Storage(dataDir); }); afterEach(() => { rmSync(dataDir, { recursive: true, force: true }); }); it("getWorkspace returns undefined after file removed externally", () => { const ws = storage.createWorkspace({ name: "temp" }); const path = join(dataDir, "workspaces", `${ws.id}.json`); // Externally remove rmSync(path); expect(storage.getWorkspace(ws.id)).toBeUndefined(); }); it("listWorkspaces handles mid-iteration file deletion gracefully", () => { // Create several workspaces for (let i = 0; i < 5; i++) { storage.createWorkspace({ name: `ws-${i}` }); } // Delete one file externally mid-way (simulate race) const list = storage.listWorkspaces(); if (list.length > 0) { const path = join(dataDir, "workspaces", `${list[0].id}.json`); rmSync(path); } // Re-list should work fine, minus the deleted one const afterList = storage.listWorkspaces(); expect(afterList.length).toBeLessThanOrEqual(5); // No crashes }); it("deleteWorkspace is idempotent", () => { const ws = storage.createWorkspace({ name: "double-delete" }); expect(storage.deleteWorkspace(ws.id)).toBe(true); expect(storage.deleteWorkspace(ws.id)).toBe(false); expect(storage.deleteWorkspace(ws.id)).toBe(false); }); it("rapid create-delete cycles don't leak files", () => { const ids: string[] = []; for (let i = 0; i < 20; i++) { const ws = storage.createWorkspace({ name: `rapid-${i}` }); ids.push(ws.id); } for (const id of ids) { storage.deleteWorkspace(id); } expect(storage.listWorkspaces()).toEqual([]); // Verify no orphan files const wsDir = join(dataDir, "workspaces"); if (existsSync(wsDir)) { const remaining = readFileSync.length; // just checking it doesn't crash const list = storage.listWorkspaces(); expect(list).toEqual([]); } }); });