/** * @file server-mode-leaks.test.js * @description Server mode (S3 plan, P0): a member cannot read the host's AI * tool config files through `/api/ai-tools/:id/config[/:index]`, and the * member-readable `/api/cc-config/{skills,commands}` list project items only * from the member's own workspaces — never another user's folder, nor the * dashboard's own working directory when no `cwd` is given. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const path = require("node:path"); const fs = require("node:fs"); const os = require("node:os"); const TMP = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-leaks-"))); process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; process.env.LANES_ROOT = path.join(TMP, "lanes"); // The user-scope skills come from CLAUDE_HOME — keep it out of the real one. process.env.CLAUDE_HOME = path.join(TMP, "claude-home"); const { createApp, startServer } = require("../index"); const { db } = require("../db"); const auth = require("../lib/auth"); let server; let BASE; const MIA_DIR = path.join(TMP, "w", "mia"); const MAX_DIR = path.join(TMP, "w", "max"); function writeSkill(dir, name) { const d = path.join(dir, ".claude", "skills", name); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, "SKILL.md"), `---\nname: ${name}\n---\nbody\n`); } function writeCommand(dir, name) { const d = path.join(dir, ".claude", "commands"); fs.mkdirSync(d, { recursive: true }); fs.writeFileSync(path.join(d, `${name}.md`), "do it\n"); } async function call(p, cookie) { const res = await fetch(BASE + p, { headers: cookie ? { cookie } : {} }); return { status: res.status, body: await res.json().catch(() => null) }; } async function login(username, password) { const res = await fetch(BASE + "/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }), }); assert.equal(res.status, 200); return res.headers.get("set-cookie").split(";")[0]; } const names = (r) => (r.body?.items || []).map((i) => i.name); before(async () => { writeSkill(MIA_DIR, "mia-skill"); writeSkill(MAX_DIR, "max-skill"); writeCommand(MAX_DIR, "max-cmd"); server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; const mia = await auth.createUser("mia", "member-pass-1", "developer"); const max = await auth.createUser("max", "member-pass-2", "developer"); await auth.createUser("boss", "admin-pass-1", "admin"); const lane = db.prepare("INSERT INTO lanes (cwd, tool, owner_id) VALUES (?, 'claude', ?)"); lane.run(MIA_DIR, mia.id); lane.run(MAX_DIR, max.id); process.env.AGENTHUB_MODE = "server"; }); after(() => { delete process.env.AGENTHUB_MODE; server?.close(); fs.rmSync(TMP, { recursive: true, force: true }); }); describe("server mode: AI tool config files are staff-only", () => { for (const p of [ "/api/ai-tools/claude/config", "/api/ai-tools/claude/config/0", "/api/AI-Tools/claude/Config/0", "/api/ai-tools/claude/%63onfig/0", "/api/ai-tools//claude/config/0", ]) { it(`refuses a member ${p}`, async () => { const r = await call(p, await login("mia", "member-pass-1")); assert.equal(r.status, 403); assert.equal(r.body.error.code, "EADMIN"); }); } it("still lets an admin reach it", async () => { const r = await call("/api/ai-tools/claude/config", await login("boss", "admin-pass-1")); assert.notEqual(r.status, 403); }); it("keeps the rest of /api/ai-tools open to a member", async () => { const r = await call("/api/ai-tools", await login("mia", "member-pass-1")); assert.equal(r.status, 200); }); }); describe("server mode: a member's skills/commands come from their own workspaces", () => { it("lists the project skills of the member's own workspace", async () => { const mia = await login("mia", "member-pass-1"); const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MIA_DIR)}`, mia); assert.equal(r.status, 200); assert.ok(names(r).includes("mia-skill")); }); it("refuses another user's folder (skills and commands)", async () => { const mia = await login("mia", "member-pass-1"); for (const kind of ["skills", "commands"]) { const r = await call(`/api/cc-config/${kind}?cwd=${encodeURIComponent(MAX_DIR)}`, mia); assert.equal(r.status, 403, kind); assert.equal(r.body.error.code, "EFOLDER"); assert.ok(!JSON.stringify(r.body).includes("max-")); } }); it("refuses a path that walks out of the member's folder", async () => { const mia = await login("mia", "member-pass-1"); const sneaky = path.join(MIA_DIR, "..", "max"); const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(sneaky)}`, mia); assert.equal(r.status, 403); }); it("lists no project items without a cwd (never the dashboard's own folder)", async () => { const mia = await login("mia", "member-pass-1"); const r = await call("/api/cc-config/skills?scope=project", mia); assert.equal(r.status, 200); assert.deepEqual(r.body.items, []); const all = await call("/api/cc-config/commands", mia); assert.equal(all.status, 200); assert.ok(all.body.items.every((i) => i.scope === "user")); }); it("leaves an admin free to name any folder", async () => { const boss = await login("boss", "admin-pass-1"); const r = await call(`/api/cc-config/skills?cwd=${encodeURIComponent(MAX_DIR)}`, boss); assert.equal(r.status, 200); assert.ok(names(r).includes("max-skill")); }); }); describe("the System page's facts for a developer", () => { // Every string that looks like an absolute path, anywhere in a body. const paths = (o, out = []) => { if (typeof o === "string" && o.startsWith("/")) out.push(o); else if (o && typeof o === "object") for (const v of Object.values(o)) paths(v, out); return out; }; it("carries no host or other-user path (DB, hooks file, transcript cache keys)", async () => { const mia = await login("mia", "member-pass-1"); const info = await call("/api/settings/info", mia); assert.equal(info.status, 200); assert.deepEqual(paths(info.body), []); assert.equal(typeof info.body.db.size, "number", "the numbers stay"); const boss = await login("boss", "admin-pass-1"); const full = await call("/api/settings/info", boss); assert.ok(full.body.db.path, "an admin still sees the host's paths"); }); });