diff --git a/server/src/__tests__/company-skill-import-boundary.test.ts b/server/src/__tests__/company-skill-import-boundary.test.ts index 9e096f1771..bc8ce57ca8 100644 --- a/server/src/__tests__/company-skill-import-boundary.test.ts +++ b/server/src/__tests__/company-skill-import-boundary.test.ts @@ -6,6 +6,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { companies, companySkills, createDb, projects, projectWorkspaces } from "@paperclipai/db"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { companySkillService } from "../services/company-skills.js"; +import { resolveManagedProjectWorkspaceDir } from "../home-paths.js"; const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; @@ -83,4 +84,63 @@ describeEmbeddedPostgres("company skill local import boundary", () => { details: { code: "skill_source_validation_failed" }, }); }); + + // Regression for LOOA-850: a `managed_checkout` project (no registered workspace row) exposes + // only a server-derived `codebase.managedFolder`; before the fix that folder was never an + // approved root, so every managed_checkout project's in-place skill re-import was denied. + it("allows managed_checkout project managedFolder imports and rejects prefix-adjacent siblings", async () => { + const companyId = randomUUID(); + const projectId = randomUUID(); + // Redirect the instance root at temp so the server-derived managedFolder is under our control. + const home = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-managed-home-")); + cleanupDirs.add(home); + const previousHome = process.env.PAPERCLIP_HOME; + process.env.PAPERCLIP_HOME = home; + try { + await db.insert(companies).values({ + id: companyId, + name: "Managed Co", + issuePrefix: `M${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + // No projectWorkspaces row => origin "managed_checkout", primaryWorkspace null, + // managedFolder = resolveManagedProjectWorkspaceDir({ companyId, projectId }) (server-derived). + await db.insert(projects).values({ id: projectId, companyId, name: "Managed project" }); + + const managedFolder = resolveManagedProjectWorkspaceDir({ companyId, projectId }); + const allowedSkill = path.join(managedFolder, ".agents", "skills", "managed-allowed"); + await fs.mkdir(allowedSkill, { recursive: true }); + await fs.writeFile( + path.join(allowedSkill, "SKILL.md"), + "---\nname: managed-allowed\ndescription: managed-allowed\n---\n# Managed Allowed\n", + "utf8", + ); + + // Prefix-adjacent sibling: its path is a string-prefix match of managedFolder but it is NOT + // inside the subtree. Confirms the boundary uses `${root}${sep}`-anchored matching, not a bare + // startsWith, so the added root cannot be widened by an adjacent directory name. + const prefixAdjacent = `${managedFolder}-evil`; + await fs.mkdir(prefixAdjacent, { recursive: true }); + await fs.writeFile( + path.join(prefixAdjacent, "SKILL.md"), + "---\nname: managed-evil\ndescription: managed-evil\n---\n# Managed Evil\n", + "utf8", + ); + + const service = companySkillService(db); + await expect(service.importFromSource(companyId, allowedSkill)).resolves.toMatchObject({ + imported: [expect.objectContaining({ slug: "managed-allowed" })], + }); + await expect(service.importFromSource(companyId, prefixAdjacent)).rejects.toMatchObject({ + status: 403, + details: { code: "skill_workspace_boundary_denied" }, + }); + } finally { + if (previousHome === undefined) { + delete process.env.PAPERCLIP_HOME; + } else { + process.env.PAPERCLIP_HOME = previousHome; + } + } + }); }); diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index a88ae25e32..1392c69fec 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -2960,6 +2960,10 @@ export function companySkillService(db: Db) { const configuredRoots = [ resolveManagedSkillsRoot(companyId), ...projectRows.flatMap((project) => project.workspaces.map((workspace) => workspace.cwd)), + // Include server-derived managed-checkout dirs for projects with no registered workspace cwd. + // Uses managedFolder (not effectiveLocalFolder) to stay server-controlled; user-registered + // localFolder paths are already covered by workspaces[].cwd above. + ...projectRows.map((project) => project.codebase.managedFolder), ].filter((root): root is string => typeof root === "string" && root.trim().length > 0); const approvedRoots = (await Promise.all( configuredRoots.map((root) => fs.realpath(path.resolve(root)).catch(() => null)),