424 lines
15 KiB
TypeScript
424 lines
15 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
import fs from "fs/promises"
|
|
import os from "os"
|
|
import path from "path"
|
|
import { isSafeManagedPath } from "../src/utils/files"
|
|
import {
|
|
readManagedInstallManifest,
|
|
writeManagedInstallManifest,
|
|
cleanupRemovedManagedDirectories,
|
|
cleanupRemovedManagedFiles,
|
|
} from "../src/targets/managed-artifacts"
|
|
import { readCodexInstallManifest } from "../src/targets/codex"
|
|
import {
|
|
cleanupRemovedPiExtensions,
|
|
cleanupRemovedPiPrompts,
|
|
cleanupRemovedPiSkills,
|
|
readPiInstallManifest,
|
|
} from "../src/targets/pi"
|
|
|
|
describe("isSafeManagedPath", () => {
|
|
const root = "/tmp/managed-root"
|
|
|
|
test("accepts simple relative names", () => {
|
|
expect(isSafeManagedPath(root, "skill-name")).toBe(true)
|
|
expect(isSafeManagedPath(root, "foo.md")).toBe(true)
|
|
expect(isSafeManagedPath(root, "foo/bar")).toBe(true)
|
|
expect(isSafeManagedPath(root, "foo/bar/baz.toml")).toBe(true)
|
|
})
|
|
|
|
test("rejects non-string values", () => {
|
|
expect(isSafeManagedPath(root, undefined as unknown)).toBe(false)
|
|
expect(isSafeManagedPath(root, null as unknown)).toBe(false)
|
|
expect(isSafeManagedPath(root, 42 as unknown)).toBe(false)
|
|
expect(isSafeManagedPath(root, {} as unknown)).toBe(false)
|
|
})
|
|
|
|
test("rejects empty strings", () => {
|
|
expect(isSafeManagedPath(root, "")).toBe(false)
|
|
})
|
|
|
|
test("rejects absolute POSIX paths", () => {
|
|
expect(isSafeManagedPath(root, "/etc/passwd")).toBe(false)
|
|
expect(isSafeManagedPath(root, "/tmp/anything")).toBe(false)
|
|
})
|
|
|
|
test("rejects path traversal segments", () => {
|
|
expect(isSafeManagedPath(root, "..")).toBe(false)
|
|
expect(isSafeManagedPath(root, "../escape")).toBe(false)
|
|
expect(isSafeManagedPath(root, "../../../etc/passwd")).toBe(false)
|
|
expect(isSafeManagedPath(root, "foo/../bar")).toBe(false)
|
|
expect(isSafeManagedPath(root, "foo/../../escape")).toBe(false)
|
|
})
|
|
|
|
test("rejects windows-style absolute paths", () => {
|
|
// path.isAbsolute recognizes drive letters on win32 only; on posix
|
|
// the backslash form is treated as a literal filename, but the
|
|
// traversal split catches mixed separators.
|
|
expect(isSafeManagedPath(root, "..\\escape")).toBe(false)
|
|
expect(isSafeManagedPath(root, "foo\\..\\..\\escape")).toBe(false)
|
|
})
|
|
|
|
test("rejects entries that resolve outside root", () => {
|
|
// Even without `..` segments, the final containment check catches
|
|
// anything that would resolve outside the root.
|
|
expect(isSafeManagedPath(root, "..")).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("readManagedInstallManifest filters unsafe entries", () => {
|
|
let tempRoot: string
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "managed-manifest-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test("drops traversal and absolute entries, keeps safe ones", async () => {
|
|
const managedDir = path.join(tempRoot, "managed")
|
|
await fs.mkdir(managedDir, { recursive: true })
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
groups: {
|
|
skills: [
|
|
"safe-skill",
|
|
"../../../etc/passwd",
|
|
"/etc/passwd",
|
|
"foo/../bar",
|
|
"foo/../../escape",
|
|
"another-safe",
|
|
],
|
|
commands: ["ok.md"],
|
|
},
|
|
}
|
|
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readManagedInstallManifest(managedDir, "compound-engineering")
|
|
expect(result).not.toBeNull()
|
|
expect(result!.groups.skills).toEqual(["safe-skill", "another-safe"])
|
|
expect(result!.groups.commands).toEqual(["ok.md"])
|
|
})
|
|
|
|
test("returns null for wrong pluginName", async () => {
|
|
const managedDir = path.join(tempRoot, "managed")
|
|
await fs.mkdir(managedDir, { recursive: true })
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "other-plugin",
|
|
groups: { skills: ["safe"] },
|
|
}
|
|
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readManagedInstallManifest(managedDir, "compound-engineering")
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("cleanupRemovedManagedFiles does not escape root (defense in depth)", () => {
|
|
let tempRoot: string
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "managed-cleanup-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test("skips unsafe entries even when fed directly (bypass read-time filter)", async () => {
|
|
const rootDir = path.join(tempRoot, "root")
|
|
await fs.mkdir(rootDir, { recursive: true })
|
|
const outsideFile = path.join(tempRoot, "outside.txt")
|
|
await fs.writeFile(outsideFile, "keep me")
|
|
|
|
// Simulate a manifest object assembled without going through
|
|
// readManagedInstallManifest's filter.
|
|
const hostileManifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
groups: {
|
|
prompts: ["../outside.txt", "/etc/passwd"],
|
|
},
|
|
}
|
|
|
|
await cleanupRemovedManagedFiles(rootDir, hostileManifest, "prompts", [])
|
|
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
|
|
})
|
|
|
|
test("skips unsafe directory entries", async () => {
|
|
const rootDir = path.join(tempRoot, "root")
|
|
await fs.mkdir(rootDir, { recursive: true })
|
|
const outsideDir = path.join(tempRoot, "outside")
|
|
await fs.mkdir(outsideDir)
|
|
await fs.writeFile(path.join(outsideDir, "file.txt"), "keep me")
|
|
|
|
const hostileManifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
groups: {
|
|
skills: ["../outside"],
|
|
},
|
|
}
|
|
|
|
await cleanupRemovedManagedDirectories(rootDir, hostileManifest, "skills", [])
|
|
expect(await fs.readFile(path.join(outsideDir, "file.txt"), "utf8")).toBe("keep me")
|
|
})
|
|
|
|
test("still cleans up safe entries correctly", async () => {
|
|
const rootDir = path.join(tempRoot, "root")
|
|
await fs.mkdir(rootDir, { recursive: true })
|
|
const safeFile = path.join(rootDir, "safe-prompt.md")
|
|
await fs.writeFile(safeFile, "remove me")
|
|
|
|
await writeManagedInstallManifest(rootDir, {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
groups: { prompts: ["safe-prompt.md"] },
|
|
})
|
|
|
|
const manifest = await readManagedInstallManifest(rootDir, "compound-engineering")
|
|
expect(manifest).not.toBeNull()
|
|
|
|
// Simulate a follow-up install where "safe-prompt.md" is no longer
|
|
// in the current bundle — cleanup should remove it.
|
|
await cleanupRemovedManagedFiles(rootDir, manifest, "prompts", [])
|
|
let exists = true
|
|
try {
|
|
await fs.stat(safeFile)
|
|
} catch {
|
|
exists = false
|
|
}
|
|
expect(exists).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe("readCodexInstallManifest filters unsafe entries", () => {
|
|
let tempRoot: string
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-manifest-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test("drops traversal/absolute entries from skills, prompts, agents", async () => {
|
|
const codexRoot = path.join(tempRoot, ".codex")
|
|
const pluginDir = path.join(codexRoot, "compound-engineering")
|
|
await fs.mkdir(pluginDir, { recursive: true })
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
skills: ["safe-skill", "../../../etc/passwd", "/etc/passwd"],
|
|
prompts: ["ok.md", "../../evil.md", "foo/../../escape.md"],
|
|
agents: ["safe-agent.toml", "/tmp/abs.toml", "../escape.toml"],
|
|
}
|
|
await fs.writeFile(path.join(pluginDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readCodexInstallManifest(codexRoot, "compound-engineering")
|
|
expect(result).not.toBeNull()
|
|
expect(result!.skills).toEqual(["safe-skill"])
|
|
expect(result!.prompts).toEqual(["ok.md"])
|
|
expect(result!.agents).toEqual(["safe-agent.toml"])
|
|
})
|
|
|
|
test("keeps all entries when all are safe", async () => {
|
|
const codexRoot = path.join(tempRoot, ".codex")
|
|
const pluginDir = path.join(codexRoot, "compound-engineering")
|
|
await fs.mkdir(pluginDir, { recursive: true })
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
skills: ["a", "b", "c"],
|
|
prompts: ["p.md"],
|
|
agents: ["agent.toml"],
|
|
}
|
|
await fs.writeFile(path.join(pluginDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readCodexInstallManifest(codexRoot, "compound-engineering")
|
|
expect(result).not.toBeNull()
|
|
expect(result!.skills).toEqual(["a", "b", "c"])
|
|
expect(result!.prompts).toEqual(["p.md"])
|
|
expect(result!.agents).toEqual(["agent.toml"])
|
|
})
|
|
})
|
|
|
|
describe("readPiInstallManifest filters unsafe entries", () => {
|
|
let tempRoot: string
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-manifest-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test("drops traversal/absolute entries from skills, prompts, extensions", async () => {
|
|
const piRoot = path.join(tempRoot, ".pi")
|
|
const managedDir = path.join(piRoot, "compound-engineering")
|
|
await fs.mkdir(managedDir, { recursive: true })
|
|
const paths = {
|
|
managedDir,
|
|
skillsDir: path.join(piRoot, "skills"),
|
|
promptsDir: path.join(piRoot, "prompts"),
|
|
extensionsDir: path.join(piRoot, "extensions"),
|
|
mcporterConfigPath: path.join(managedDir, "mcporter.json"),
|
|
agentsPath: path.join(piRoot, "AGENTS.md"),
|
|
}
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
skills: ["safe-skill", "../../../etc/passwd", "/etc/passwd", "foo/../../escape"],
|
|
prompts: ["ok.md", "../../evil.md", "foo/../bar.md"],
|
|
extensions: ["safe.ext", "/tmp/abs.ext", "..\\escape.ext"],
|
|
}
|
|
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readPiInstallManifest(managedDir, "compound-engineering", paths)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.skills).toEqual(["safe-skill"])
|
|
expect(result!.prompts).toEqual(["ok.md"])
|
|
expect(result!.extensions).toEqual(["safe.ext"])
|
|
})
|
|
|
|
test("keeps all entries when all are safe", async () => {
|
|
const piRoot = path.join(tempRoot, ".pi")
|
|
const managedDir = path.join(piRoot, "compound-engineering")
|
|
await fs.mkdir(managedDir, { recursive: true })
|
|
const paths = {
|
|
managedDir,
|
|
skillsDir: path.join(piRoot, "skills"),
|
|
promptsDir: path.join(piRoot, "prompts"),
|
|
extensionsDir: path.join(piRoot, "extensions"),
|
|
mcporterConfigPath: path.join(managedDir, "mcporter.json"),
|
|
agentsPath: path.join(piRoot, "AGENTS.md"),
|
|
}
|
|
const manifest = {
|
|
version: 1,
|
|
pluginName: "compound-engineering",
|
|
skills: ["a", "b", "c"],
|
|
prompts: ["p.md"],
|
|
extensions: ["ext.js"],
|
|
}
|
|
await fs.writeFile(path.join(managedDir, "install-manifest.json"), JSON.stringify(manifest))
|
|
|
|
const result = await readPiInstallManifest(managedDir, "compound-engineering", paths)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.skills).toEqual(["a", "b", "c"])
|
|
expect(result!.prompts).toEqual(["p.md"])
|
|
expect(result!.extensions).toEqual(["ext.js"])
|
|
})
|
|
})
|
|
|
|
describe("Pi cleanup helpers do not escape root (defense in depth)", () => {
|
|
let tempRoot: string
|
|
|
|
beforeEach(async () => {
|
|
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-cleanup-"))
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tempRoot, { recursive: true, force: true })
|
|
})
|
|
|
|
test("cleanupRemovedPiSkills skips unsafe entries fed directly", async () => {
|
|
const skillsDir = path.join(tempRoot, "skills")
|
|
await fs.mkdir(skillsDir, { recursive: true })
|
|
const outsideDir = path.join(tempRoot, "outside-skill")
|
|
await fs.mkdir(outsideDir)
|
|
await fs.writeFile(path.join(outsideDir, "file.txt"), "keep me")
|
|
|
|
const hostileManifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
skills: ["../outside-skill", "/etc/passwd"],
|
|
prompts: [],
|
|
extensions: [],
|
|
}
|
|
|
|
await cleanupRemovedPiSkills(skillsDir, hostileManifest, [])
|
|
expect(await fs.readFile(path.join(outsideDir, "file.txt"), "utf8")).toBe("keep me")
|
|
})
|
|
|
|
test("cleanupRemovedPiPrompts skips unsafe entries fed directly", async () => {
|
|
const promptsDir = path.join(tempRoot, "prompts")
|
|
await fs.mkdir(promptsDir, { recursive: true })
|
|
const outsideFile = path.join(tempRoot, "outside.txt")
|
|
await fs.writeFile(outsideFile, "keep me")
|
|
|
|
const hostileManifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
skills: [],
|
|
prompts: ["../outside.txt", "/etc/passwd"],
|
|
extensions: [],
|
|
}
|
|
|
|
await cleanupRemovedPiPrompts(promptsDir, hostileManifest, [])
|
|
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
|
|
})
|
|
|
|
test("cleanupRemovedPiExtensions skips unsafe entries fed directly", async () => {
|
|
const extensionsDir = path.join(tempRoot, "extensions")
|
|
await fs.mkdir(extensionsDir, { recursive: true })
|
|
const outsideFile = path.join(tempRoot, "outside-ext")
|
|
await fs.writeFile(outsideFile, "keep me")
|
|
|
|
const hostileManifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
skills: [],
|
|
prompts: [],
|
|
extensions: ["../outside-ext", "/etc/passwd", "foo/../../escape"],
|
|
}
|
|
|
|
await cleanupRemovedPiExtensions(extensionsDir, hostileManifest, [])
|
|
expect(await fs.readFile(outsideFile, "utf8")).toBe("keep me")
|
|
})
|
|
|
|
test("still cleans up safe entries correctly", async () => {
|
|
const skillsDir = path.join(tempRoot, "skills")
|
|
const promptsDir = path.join(tempRoot, "prompts")
|
|
const extensionsDir = path.join(tempRoot, "extensions")
|
|
await fs.mkdir(skillsDir, { recursive: true })
|
|
await fs.mkdir(promptsDir, { recursive: true })
|
|
await fs.mkdir(extensionsDir, { recursive: true })
|
|
|
|
const staleSkillDir = path.join(skillsDir, "stale-skill")
|
|
await fs.mkdir(staleSkillDir)
|
|
await fs.writeFile(path.join(staleSkillDir, "SKILL.md"), "old")
|
|
const stalePrompt = path.join(promptsDir, "stale.md")
|
|
await fs.writeFile(stalePrompt, "old")
|
|
const staleExt = path.join(extensionsDir, "stale.ext")
|
|
await fs.writeFile(staleExt, "old")
|
|
|
|
const manifest = {
|
|
version: 1 as const,
|
|
pluginName: "compound-engineering",
|
|
skills: ["stale-skill"],
|
|
prompts: ["stale.md"],
|
|
extensions: ["stale.ext"],
|
|
}
|
|
|
|
await cleanupRemovedPiSkills(skillsDir, manifest, [])
|
|
await cleanupRemovedPiPrompts(promptsDir, manifest, [])
|
|
await cleanupRemovedPiExtensions(extensionsDir, manifest, [])
|
|
|
|
for (const p of [staleSkillDir, stalePrompt, staleExt]) {
|
|
let exists = true
|
|
try {
|
|
await fs.stat(p)
|
|
} catch {
|
|
exists = false
|
|
}
|
|
expect(exists).toBe(false)
|
|
}
|
|
})
|
|
})
|