Preserve privacy across overlapping roots and isolated workspace copies

Authorize canonical parent and child ownership, retain configured roots before detachment, and preserve source/task privacy before worktree copying and provisioning. Recheck target writers and retained readers without requiring writes to isolated source folders.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 13:15:35 -05:00
1 parent d11cf62985
commit 16fc6daaf4
10 files changed
+814 -80

No files matched your search

+104
View File
@@ -458,6 +458,110 @@ describeEmbeddedPostgres("workspace file resources", () => {
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
it.each([
["configured", "ancestor"], ["configured", "descendant"],
["execution", "ancestor"], ["execution", "descendant"],
] as const)("protects %s private roots from a public %s file browser, including retained moved ownership", async (ownerKind, relation) => {
const { root, projectRoot, targetProjectRoot } = await makeWorkspace();
try {
const ancestor = path.join(root, "work"), descendant = path.join(ancestor, "private");
await fs.mkdir(descendant, { recursive: true });
const publicRoot = relation === "ancestor" ? ancestor : descendant;
const privateRoot = relation === "ancestor" ? descendant : ancestor;
const relativeFile = relation === "ancestor" ? "private/secret.txt" : "secret.txt";
await fs.writeFile(path.join(publicRoot, relativeFile), "PRIVATE_OVERLAPPING_ROOT_BYTES");
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot: publicRoot });
await db.update(issues).set({ projectId: null, projectWorkspaceId: null }).where(eq(issues.id, graph.issueId));
await db.update(executionWorkspaces).set({ projectId: null, projectWorkspaceId: null, providerType: "local_fs" }).where(eq(executionWorkspaces.sourceIssueId, graph.issueId));
await db.update(projectWorkspaces).set({ cwd: publicRoot }).where(eq(projectWorkspaces.id, graph.projectWorkspaceId));
let ownerId: string;
let privateIssueId: string | null = null;
if (ownerKind === "configured") {
ownerId = graph.targetProjectId;
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, ownerId));
await db.update(projectWorkspaces).set({ cwd: privateRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
expect(await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.projectId, ownerId))).toEqual([]);
} else {
const [task] = await db.insert(issues).values({ companyId: graph.companyId, title: "Private overlapping task", visibility: "private", responsibleUserId: "private-owner" }).returning();
privateIssueId = task.id;
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: task.id,
name: "Private overlapping workspace", cwd: privateRoot, mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" }).returning();
ownerId = workspace.id;
}
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
const targets = [{ workspace: "execution" }, { projectId: graph.projectId, workspaceId: graph.projectWorkspaceId }];
const readState = async () => ({
access: await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId)),
policies: await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId)),
});
const deny = async () => {
const before = await readState();
for (const target of targets) {
for (const [endpoint, query] of [
["list", {}], ["resolve", { path: relativeFile }], ["content", { path: relativeFile }], ["content", { path: relativeFile, download: true }],
] as const) {
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/${endpoint}`).query({ ...target, ...query }).expect(404);
for (const hidden of ["PRIVATE_OVERLAPPING_ROOT_BYTES", ownerId, privateRoot, "Private overlapping"]) expect(JSON.stringify(response.body)).not.toContain(hidden);
}
const availability = await request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`)
.send({ queries: [{ ...target, path: relativeFile }] }).expect(200);
expect(availability.body.results).toEqual([expect.objectContaining({ openable: false, unavailableReason: "not_found", resource: null })]);
}
expect(await readState()).toEqual(before);
};
await deny();
const grant = ownerKind === "configured"
? (await db.insert(projectAccessMembers).values({ companyId: graph.companyId, projectId: ownerId, subjectType: "user", subjectId: "board-user" }).returning())[0]!
: (await db.insert(issueAccessGrants).values({ issueId: privateIssueId!, subjectType: "user", subjectId: "board-user", source: "explicit" }).returning())[0]!;
const beforeAllowedReads = await readState();
for (const target of targets) {
const response = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, path: relativeFile }).expect(200);
expect(response.body.content.data).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES");
}
expect(await readState()).toEqual(beforeAllowedReads);
// A prior admission's deny-only owner remains attached to the physical
// tree after the logical owner selects a different folder.
const canonicalCwd = await fs.realpath(privateRoot);
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: graph.companyId,
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), canonicalCwd,
kind: ownerKind === "configured" ? "project_source" : "source", scopeId: ownerId });
if (ownerKind === "configured") {
await db.update(projectWorkspaces).set({ cwd: targetProjectRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.id, grant.id));
} else {
await db.update(executionWorkspaces).set({ cwd: targetProjectRoot }).where(eq(executionWorkspaces.id, ownerId));
await db.update(issueAccessGrants).set({ revokedAt: new Date() }).where(eq(issueAccessGrants.id, grant.id));
}
await deny();
expect(await fs.readFile(path.join(publicRoot, relativeFile), "utf8")).toBe("PRIVATE_OVERLAPPING_ROOT_BYTES");
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
it("keeps sibling string-prefix roots independent without registering file readers", async () => {
const { root, projectRoot, targetProjectRoot } = await makeWorkspace();
try {
const privateRoot = path.join(root, "work"), publicRoot = path.join(root, "worker");
await fs.mkdir(privateRoot); await fs.mkdir(publicRoot);
await fs.writeFile(path.join(publicRoot, "public.txt"), "PUBLIC_SIBLING_BYTES");
const graph = await seedGraph(db, { projectRoot, targetProjectRoot, executionRoot: publicRoot });
await db.update(projects).set({ visibility: "private" }).where(eq(projects.id, graph.targetProjectId));
await db.update(projectWorkspaces).set({ cwd: privateRoot }).where(eq(projectWorkspaces.id, graph.targetProjectWorkspaceId));
const [privateIssue] = await db.insert(issues).values({ companyId: graph.companyId, title: "Private sibling", visibility: "private", responsibleUserId: "private-owner" }).returning();
await db.insert(executionWorkspaces).values({ companyId: graph.companyId, sourceIssueId: privateIssue.id, cwd: privateRoot,
name: "Private sibling", mode: "shared_workspace", strategyType: "task_directory", providerType: "local_fs" });
const app = createApp(db, { type: "board", userId: "board-user", companyIds: [graph.companyId], source: "session", isInstanceAdmin: false });
const target = { workspace: "execution", path: "public.txt" };
const content = await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query(target).expect(200);
expect(content.body.content.data).toBe("PUBLIC_SIBLING_BYTES");
await request(app).get(`/api/issues/${graph.issueId}/file-resources/list`).query({ workspace: "execution" }).expect(200);
await request(app).get(`/api/issues/${graph.issueId}/file-resources/content`).query({ ...target, download: true }).expect(200);
const availability = await request(app).post(`/api/issues/${graph.issueId}/file-resources/availability`).send({ queries: [target] }).expect(200);
expect(availability.body.results).toEqual([expect.objectContaining({ openable: true })]);
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, graph.companyId))).toEqual([]);
expect(await db.select().from(executionWorkspaceRepositoryRoots).where(eq(executionWorkspaceRepositoryRoots.companyId, graph.companyId))).toEqual([]);
} finally { await fs.rm(root, { recursive: true, force: true }); }
});
it("does not confuse identical repositories in unrelated available roots with private file provenance", async () => {
const { root, projectRoot, executionRoot, targetProjectRoot } = await makeWorkspace();
try {
@@ -1,3 +1,7 @@
import express from "express";
import request from "supertest";
import { fileResourceRoutes } from "../routes/file-resources.js";
import { errorHandler } from "../middleware/index.js";
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
@@ -7,7 +11,7 @@ import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, createDb, environments, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectAccessMembers, projectWorkspaces } from "@paperclipai/db";
import { setExpensiveWorkspaceGitExecutor } from "@paperclipai/adapter-utils/git-workspace-sync";
import { buildProjectMentionHref } from "@paperclipai/shared";
import { createWorkspaceGitOperationScheduler, WorkspaceGitScanError } from "../services/workspace-git-operation-scheduler.js";
@@ -464,6 +468,101 @@ suite("task project repository provisioning", () => {
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected source content");
}, 30_000);
it.each([false, true])("checks nested private source access before worktree copy and retains it after read-grant revocation (provision fails: %s)", async (provisionFails) => {
const previousPrivacyMode = process.env.PAPERCLIP_ISSUE_PRIVACY_MODE;
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
try {
const companyId = randomUUID(), projectId = randomUUID(), privateProjectId = randomUUID();
const sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const cwd = path.join(root, companyId, "public-source");
const privateCwd = path.join(cwd, "private");
await mkdir(privateCwd, { recursive: true });
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
git("init", "-b", "main");
await writeFile(path.join(privateCwd, "secret.txt"), "NESTED_PRIVATE_TRACKED_BYTES");
git("add", ".");
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed");
const initialWorktrees = git("worktree", "list", "--porcelain");
await db.insert(companies).values({ id: companyId, name: "Private copy", issuePrefix: `C${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" });
await db.insert(projects).values([
{ id: projectId, companyId, name: "Public Git source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } },
{ id: privateProjectId, companyId, name: "Private nested source", visibility: "private" },
]);
await db.insert(projectWorkspaces).values([
{ id: sourceId, companyId, projectId, name: "Git source", sourceType: "local_path", cwd, isPrimary: true },
{ id: randomUUID(), companyId, projectId: privateProjectId, name: "Nested private source", sourceType: "local_path", cwd: privateCwd, isPrimary: true },
]);
await db.insert(agents).values({ id: agentId, companyId, name: "Isolated reader", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
await db.insert(issues).values({ id: issueId, companyId, title: "Read isolated source", status: "todo", assigneeAgentId: agentId,
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "git_worktree", ...(provisionFails ? { provisionCommand: "exit 42" } : {}) } },
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } });
const admit = async () => {
const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } });
expect(run).not.toBeNull();
await vi.waitFor(async () => expect((await heartbeat.getRun(run!.id))?.status).toMatch(/^(succeeded|failed)$/), { timeout: 15_000 });
await drainHeartbeatRunsToQuiescence(db, heartbeat);
return (await heartbeat.getRun(run!.id))!;
};
const denied = await admit();
expect(denied.status).toBe("failed");
expect(execute.mock.calls.filter(([input]) => input.runId === denied.id)).toHaveLength(0);
expect(git("worktree", "list", "--porcelain")).toBe(initialWorktrees);
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull();
await db.insert(projectAccessMembers).values([
{ companyId, projectId: privateProjectId, subjectType: "agent", subjectId: agentId },
{ companyId, projectId: privateProjectId, subjectType: "user", subjectId: "responsible-user" },
]);
// Isolated copies need source read access, never an assignment/write grant.
expect(await accessService(db).decide({ actor: { type: "agent", agentId, companyId, source: "agent_jwt", onBehalfOfUserId: "responsible-user" },
action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId }, scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false });
const allowed = await admit();
const calls = execute.mock.calls.filter(([input]) => input.runId === allowed.id);
let copiedCwd: string;
if (provisionFails) {
expect(allowed.status).toBe("failed");
expect(allowed.error).toContain("provision");
expect(calls).toHaveLength(0);
const paths = git("worktree", "list", "--porcelain").split("\n").filter(line => line.startsWith("worktree ")).map(line => line.slice(9));
expect(paths).toHaveLength(2);
copiedCwd = paths.find(entry => entry !== paths[0])!;
expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0].executionWorkspaceId).toBeNull();
// A later public alias must not expose bytes from failed provisioning.
const aliasId = randomUUID();
await db.insert(executionWorkspaces).values({ id: aliasId, companyId, projectId: null, sourceIssueId: issueId,
name: "Public alias after failed provisioning", mode: "shared_workspace", strategyType: "project_primary", cwd: copiedCwd,
providerType: "local_fs", status: "active" });
await db.update(issues).set({ executionWorkspaceId: aliasId }).where(eq(issues.id, issueId));
} else {
expect(allowed).toMatchObject({ status: "succeeded", error: null });
expect(calls).toHaveLength(1);
expect(calls[0]![0].context.paperclipWorkspace.strategy).toBe("git_worktree");
copiedCwd = calls[0]![0].context.paperclipWorkspace.cwd;
}
const workspace = { cwd: copiedCwd };
expect(await realpath(workspace.cwd)).not.toBe(await realpath(cwd));
expect(await readFile(path.join(workspace.cwd, "private/secret.txt"), "utf8")).toBe("NESTED_PRIVATE_TRACKED_BYTES");
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = { type: "board", userId: "responsible-user", companyIds: [companyId], source: "session", isInstanceAdmin: false };
next();
});
app.use("/api", fileResourceRoutes(db));
app.use(errorHandler);
const fileUrl = `/api/issues/${issueId}/file-resources/content`;
expect((await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt" }).expect(200)).body.content.data).toBe("NESTED_PRIVATE_TRACKED_BYTES");
await db.delete(projectAccessMembers).where(and(eq(projectAccessMembers.projectId, privateProjectId), eq(projectAccessMembers.subjectId, "responsible-user")));
for (const download of [false, true]) {
const response = await request(app).get(fileUrl).query({ workspace: "execution", path: "private/secret.txt", download }).expect(404);
expect(JSON.stringify(response.body)).not.toContain("NESTED_PRIVATE_TRACKED_BYTES");
}
expect(await readFile(path.join(workspace.cwd, "private/secret.txt"), "utf8")).toBe("NESTED_PRIVATE_TRACKED_BYTES");
} finally {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", previousPrivacyMode);
}
}, 40_000);
it.each([true, false])("handles a deleted configured source with a retained workspace: %s", async (bound) => {
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const cwd = path.join(root, companyId, "retained-source");
@@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, rm, writeFile, realpath } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq } from "drizzle-orm";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryAccess,
executionWorkspaces, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants,
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { projectService } from "../services/projects.js";
import { assertExecutionWorkspaceRootReadAccess, assertExecutionWorkspaceRootWriteAccess } from "../services/execution-workspace-repositories.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("configured project root provenance", () => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let root: string;
beforeAll(async () => {
vi.stubEnv("PAPERCLIP_ISSUE_PRIVACY_MODE", "enforce");
database = await startEmbeddedPostgresTestDatabase("paperclip-configured-provenance-");
db = createDb(database.connectionString);
root = await mkdtemp(path.join(tmpdir(), "paperclip-configured-provenance-"));
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
vi.unstubAllEnvs();
await database?.cleanup();
if (root) await rm(root, { recursive: true, force: true });
});
it.each(["move", "remove", "remove_project", "replace_repositories"] as const)(
"retains nested private files before first admission when configured source changes: %s", async (mutation) => {
const companyId = randomUUID(), userId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
const cwd = path.join(root, companyId), privateCwd = path.join(cwd, "private"), unrelatedCwd = `${cwd}-unrelated`;
await mkdir(privateCwd, { recursive: true });
await mkdir(unrelatedCwd);
await writeFile(path.join(privateCwd, "secret.txt"), "Private configured source\n");
await db.insert(companies).values({ id: companyId, name: "Configured provenance", issuePrefix: `P${companyId.slice(0, 6)}` });
await db.insert(authUsers).values({ id: userId, name: "Reader", email: `${userId}@example.test`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await db.insert(agents).values({ id: agentId, companyId, name: "Worker", adapterType: "process", status: "idle" });
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: userId, membershipRole: "member", status: "active" });
const [source] = await db.insert(projects).values({ companyId, name: "Private source", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Private files", cwd: privateCwd,
repoUrl: "https://github.com/example/private", metadata: { githubRepositoryId: "fixture-private" } }).returning();
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Public parent", cwd, mode: "shared_workspace", strategyType: "task_directory" }).returning();
await db.insert(issues).values({ id: issueId, companyId, title: "Public task", assigneeAgentId: agentId, executionWorkspaceId: workspace.id });
const actor = { type: "board" as const, source: "session" as const, userId, ignoreInstanceAdmin: true };
const access = { companyId, cwd, sourceWorkspaceId: workspace.id, actor };
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
const service = projectService(db, { captureWorkspaceUpdates: false });
if (mutation === "move") await service.updateWorkspace(source.id, configured.id, { cwd: unrelatedCwd });
else if (mutation === "remove") await service.removeWorkspace(source.id, configured.id);
else if (mutation === "remove_project") await service.remove(source.id);
else await service.replaceRepositories(source.id, []);
const retained = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId));
expect(retained).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: "project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
expect.objectContaining({ kind: "shared_project_source", scopeId: source.id, canonicalCwd: await realpath(privateCwd) }),
]));
expect(retained.some(row => row.kind === "reader")).toBe(false);
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).rejects.toThrow(/source project/);
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/source project/);
// The common-prefix sibling is physically independent of the old root.
if (mutation !== "move") await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId, cwd: unrelatedCwd, actor })).resolves.toBeUndefined();
if (mutation === "remove_project") return; // Missing sticky owner must remain denied.
await db.insert(projectAccessMembers).values({ companyId, projectId: source.id, subjectType: "user", subjectId: userId });
await expect(assertExecutionWorkspaceRootReadAccess(db, access)).resolves.toBeUndefined();
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).rejects.toThrow(/protected|not authorized/);
await db.insert(principalPermissionGrants).values({ companyId, principalType: "user", principalId: userId,
permissionKey: "tasks:assign_scope", scope: { projectId: source.id, assigneeAgentId: agentId } });
await expect(assertExecutionWorkspaceRootWriteAccess(db, { ...access, issueId })).resolves.toBeUndefined();
});
it("can remove a configured folder that never existed without inventing provenance", async () => {
const companyId = randomUUID();
await db.insert(companies).values({ id: companyId, name: "Missing source", issuePrefix: `M${companyId.slice(0, 6)}` });
const [source] = await db.insert(projects).values({ companyId, name: "Missing private source", visibility: "private" }).returning();
const [configured] = await db.insert(projectWorkspaces).values({ companyId, projectId: source.id, name: "Missing", cwd: path.join(root, companyId, "never-created") }).returning();
await expect(projectService(db, { captureWorkspaceUpdates: false }).removeWorkspace(source.id, configured.id)).resolves.toMatchObject({ id: configured.id });
expect(await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, companyId))).toEqual([]);
});
});
@@ -9,7 +9,7 @@ import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest
import { eq } from "drizzle-orm";
import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositoryRoots, executionWorkspaceRepositoryAccess, executionWorkspaceRepositories, executionWorkspaces,
heartbeatRuns, issueAccessGrants, issues, projects, projectWorkspaces, projectAccessMembers, principalPermissionGrants, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { assertExecutionWorkspaceRootReadAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { assertExecutionWorkspaceRootReadAccess, assertIsolatedWorkspaceSourceReadAccess, retainIsolatedWorkspaceSourceAccess, executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
import { nativeRuntimeContextFixture } from "../services/native-runtime/runtime-context.test-fixture.js";
@@ -124,6 +124,234 @@ const support = await getEmbeddedPostgresTestSupport();
beforePublish: async cloneCwd => { await beforePublish?.(); await input.beforePublish(cloneCwd); } }));
}
it.each([
{ owner: "configured", direction: "descendant" }, { owner: "configured", direction: "ancestor" },
{ owner: "execution", direction: "descendant" }, { owner: "execution", direction: "ancestor" },
])("authorizes newly overlapping private $owner $direction owners before selection, admission and publication", async ({ owner, direction }) => {
const f = await fixture();
const child = path.join(f.admission.cwd, "private");
await mkdir(child);
const privateCwd = direction === "descendant" ? child : f.admission.cwd;
if (direction === "ancestor") {
f.admission.cwd = child;
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id));
}
await f.service.prepareForAdmission(f.admission); // A retained reader cannot hide later nested owners.
let grant: () => Promise<unknown>;
let detach: () => Promise<unknown>;
if (owner === "configured") {
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Nested private source", visibility: "private" }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private files", cwd: privateCwd }).returning();
grant = () => db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
detach = async () => {
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
};
} else {
const peer = await standardPeer(f);
await db.update(executionWorkspaces).set({ cwd: privateCwd }).where(eq(executionWorkspaces.id, peer.workspace.id));
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
grant = () => db.insert(issueAccessGrants).values([
{ issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId },
{ issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId },
]);
detach = async () => {
const moved = path.join(root, `${peer.issueId}-moved`); await mkdir(moved);
await executionWorkspaceService(db).update(peer.workspace.id, { cwd: moved });
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId));
};
}
await writeFile(path.join(privateCwd, "private.txt"), "Nested private files\n");
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd, actor: f.actor, sourceWorkspaceId: f.workspace.id });
const select = () => executionWorkspaceService(db).selectTaskWorkspace({ companyId: f.companyId, issueId: f.issueId, actor: f.actor,
selection: { kind: "existing", workspaceId: f.workspace.id }, expectedBindingRevision: 0, requestKey: randomUUID() });
await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/);
await expect(select()).rejects.toThrow(/source project|workspace access|authorization boundary/);
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
await grant();
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(read()).resolves.toBeUndefined();
await detach();
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/source project|workspace access|authorization boundary/);
await expect(read()).rejects.toThrow(/source project|workspace access|authorization boundary/);
});
it("does not confuse sibling component prefixes with overlapping roots", async () => {
const f = await fixture();
const sibling = `${f.admission.cwd}er`; await mkdir(sibling);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private source", visibility: "private" }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Sibling", cwd: sibling });
await db.insert(executionWorkspaceRepositoryAccess).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"),
canonicalCwd: await realpath(sibling), kind: "project_source", scopeId: project.id });
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, rootKey: createHash("sha256").update(await realpath(sibling)).digest("hex"),
canonicalCwd: await realpath(sibling), mode: "anonymous_only" });
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.request({ kind: "catalog", id: "private-repo" })).resolves.toMatchObject({ kind: "requires_next_admission" });
});
it.each([
{ mode: "managed", direction: "ancestor" }, { mode: "managed", direction: "descendant" },
{ mode: "anonymous_only", direction: "ancestor" }, { mode: "anonymous_only", direction: "descendant" },
] as const)("fences $mode root policy against opposite-mode $direction acquisition", async ({ mode, direction }) => {
const f = await fixture();
const child = path.join(f.admission.cwd, "nested"); await mkdir(child);
const existingRoot = direction === "ancestor" ? child : f.admission.cwd;
if (direction === "descendant") {
f.admission.cwd = child;
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, f.workspace.id));
}
const canonicalCwd = await realpath(existingRoot);
await db.insert(executionWorkspaceRepositoryRoots).values({ companyId: f.companyId, canonicalCwd,
rootKey: createHash("sha256").update(canonicalCwd).digest("hex"), mode });
if (mode === "managed") {
await db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, executionPolicy: f.policy } }).where(eq(heartbeatRuns.id, f.runId));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/anonymous public URLs only/);
} else await expect(f.request({ kind: "catalog", id: "private-repo" })).rejects.toThrow(/anonymous public URLs only/);
expect(await f.service.list(f.companyId, f.workspace.id)).toEqual([]);
expect(acquisition.materialize).not.toHaveBeenCalled();
});
it("reauthorizes a nested private publication against an already-admitted parent reader", async () => {
const f = await fixture();
await f.service.prepareForAdmission(f.admission);
const peer = await standardPeer(f), child = path.join(f.admission.cwd, "private"); await mkdir(child);
peer.admission.cwd = child;
await db.update(executionWorkspaces).set({ cwd: child }).where(eq(executionWorkspaces.id, peer.workspace.id));
await db.update(issues).set({ visibility: "private", privacyRootIssueId: peer.issueId, responsibleUserId: peer.userId }).where(eq(issues.id, peer.issueId));
const request = () => f.service.request({ companyId: f.companyId, issueId: peer.issueId, actor: peer.actor,
request: { repository: { kind: "url", url: "https://github.com/public/nested" }, requestKey: randomUUID() } });
await expect(request()).rejects.toThrow(/workspace access|authorization boundary/);
await db.insert(issueAccessGrants).values([
{ issueId: peer.issueId, subjectType: "agent", subjectId: f.agentId, source: "explicit", grantedByUserId: peer.userId },
{ issueId: peer.issueId, subjectType: "user", subjectId: f.userId, source: "explicit", grantedByUserId: peer.userId },
]);
const receipt = await request();
await localPrivateClone({ ...f, issueId: peer.issueId }, async () => {
await db.delete(issueAccessGrants).where(eq(issueAccessGrants.issueId, peer.issueId));
});
await expect(f.service.prepareForAdmission(peer.admission)).rejects.toThrow(/workspace access|authorization boundary/);
await expect(fs.lstat(path.join(child, receipt.repository.relativePath))).rejects.toMatchObject({ code: "ENOENT" });
expect(acquisition.materialize).toHaveBeenCalledOnce();
});
it.each(["subdirectory", "linked_worktree", "nested_target"] as const)("retains private Git owner provenance copied through a %s without requiring source write grants", async sourceKind => {
const f = await fixture(), exec = promisify(execFileCallback);
const privateCwd = path.join(f.admission.cwd, "private"), publicCwd = path.join(f.admission.cwd, "public");
await mkdir(privateCwd); await mkdir(publicCwd);
await writeFile(path.join(privateCwd, "secret.txt"), "Copied private Git bytes\n");
await writeFile(path.join(publicCwd, "readme.txt"), "Public nested configuration\n");
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
await exec("git", ["-C", f.admission.cwd, "add", "."]);
await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Private copied source", visibility: "private",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private nested files", cwd: privateCwd });
if (sourceKind === "nested_target") {
const [outer] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected ancestor source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: outer.id, name: "Ancestor source", cwd: f.admission.cwd });
}
let sourceCwd = publicCwd;
if (sourceKind === "linked_worktree") {
sourceCwd = path.join(root, `${f.issueId}-linked`);
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "linked", sourceCwd]);
}
await expect(assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor })).rejects.toThrow(/source project/);
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd, actor: f.actor });
expect(captured.projectIds).toContain(project.id);
const targetCwd = sourceKind === "nested_target" ? path.join(f.admission.cwd, ".paperclip", "worktrees", "copy") : path.join(root, `${f.issueId}-copy`);
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured });
await expect(fs.lstat(targetCwd)).rejects.toMatchObject({ code: "ENOENT" });
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "copy", targetCwd]);
await retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd, targetCwd, actor: f.actor, captured });
const [target] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, name: "Isolated copy", cwd: targetCwd,
mode: "isolated_workspace", strategyType: "git_worktree" }).returning();
await db.update(issues).set({ executionWorkspaceId: target.id }).where(eq(issues.id, f.issueId));
await expect(f.service.prepareForAdmission({ ...f.admission, workspaceId: target.id, cwd: targetCwd })).resolves.toEqual([]);
expect(await readFile(path.join(targetCwd, "private", "secret.txt"), "utf8")).toBe("Copied private Git bytes\n");
await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: f.actor, sourceWorkspaceId: target.id })).rejects.toThrow(/source project/);
});
it.each(["exact", "descendant"] as const)("requires write authority for a protected %s target before copying", async relation => {
const f = await fixture(), exec = promisify(execFileCallback);
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
await writeFile(path.join(f.admission.cwd, "source.txt"), "Source bytes\n");
await exec("git", ["-C", f.admission.cwd, "add", "."]);
await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const targetCwd = path.join(root, `${f.issueId}-protected-target`);
const protectedCwd = relation === "exact" ? targetCwd : path.join(targetCwd, "private");
await mkdir(protectedCwd, { recursive: true });
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected target",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Target owner", cwd: protectedCwd });
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd,
actor: f.actor, issueId: f.issueId, captured })).rejects.toThrow(/protected|assignment grant/);
await expect(fs.lstat(path.join(targetCwd, "source.txt"))).rejects.toMatchObject({ code: "ENOENT" });
const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.companyId, f.companyId));
expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_source", scopeId: f.workspace.id })]));
});
it("excludes available lexical descendants whose canonical roots are unrelated", async () => {
const f = await fixture();
const unrelated = path.join(root, `${f.issueId}-unrelated`); await mkdir(unrelated);
const alias = path.join(f.admission.cwd, "link"); await symlink(unrelated, alias);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Unrelated private owner", visibility: "private" }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias });
const [workspace] = await db.insert(executionWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Unrelated alias", cwd: alias,
mode: "shared_workspace", strategyType: "project_primary" }).returning();
await db.insert(executionWorkspaceRepositories).values({ companyId: f.companyId, requestedByIssueId: f.issueId, executionWorkspaceId: workspace.id,
repositoryIdentity: "https://github.com/private/unrelated", repoUrl: "https://github.com/private/unrelated", relativePath: "unused",
requestedRef: "HEAD", requestKey: randomUUID() });
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
});
it.each(["revoked", "deleted_source", "deleted_owner", "retained_reader"] as const)("protects already-copied bytes when post-copy authorization fails (%s)", async failure => {
const f = await fixture(), exec = promisify(execFileCallback);
await exec("git", ["init", "--initial-branch=main", f.admission.cwd]);
await writeFile(path.join(f.admission.cwd, "secret.txt"), "Failed copy still private\n");
await exec("git", ["-C", f.admission.cwd, "add", "."]);
await exec("git", ["-C", f.admission.cwd, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Failed private copy", visibility: "private" }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Private source", cwd: f.admission.cwd });
await db.insert(projectAccessMembers).values([
{ companyId: f.companyId, projectId: project.id, subjectType: "agent", subjectId: f.agentId },
{ companyId: f.companyId, projectId: project.id, subjectType: "user", subjectId: f.userId },
]);
const captured = await assertIsolatedWorkspaceSourceReadAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, actor: f.actor });
const targetCwd = path.join(root, `${f.issueId}-failed-copy`);
if (failure === "retained_reader") {
await mkdir(targetCwd);
const peer = await standardPeer(f);
await db.update(executionWorkspaces).set({ cwd: targetCwd }).where(eq(executionWorkspaces.id, peer.workspace.id));
await f.service.prepareForAdmission({ ...peer.admission, cwd: targetCwd });
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd,
actor: f.actor, captured })).rejects.toThrow(/source project/);
await expect(fs.lstat(path.join(targetCwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" });
return;
}
await exec("git", ["-C", f.admission.cwd, "worktree", "add", "-b", "failed-copy", targetCwd]);
if (failure === "revoked") await db.delete(projectAccessMembers).where(eq(projectAccessMembers.projectId, project.id));
if (failure === "deleted_owner") await db.delete(projects).where(eq(projects.id, project.id));
if (failure === "deleted_source") await rm(f.admission.cwd, { recursive: true });
await expect(retainIsolatedWorkspaceSourceAccess(db, { companyId: f.companyId, sourceCwd: f.admission.cwd, targetCwd, actor: f.actor, captured })).rejects.toThrow();
const canonicalCwd = await realpath(targetCwd);
const owners = await db.select().from(executionWorkspaceRepositoryAccess).where(eq(executionWorkspaceRepositoryAccess.rootKey, createHash("sha256").update(canonicalCwd).digest("hex")));
expect(owners).toEqual(expect.arrayContaining([expect.objectContaining({ kind: "isolated_project_source", scopeId: project.id })]));
const peer = await standardPeer(f);
await expect(assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: targetCwd, actor: peer.actor })).rejects.toThrow(/source project/);
expect(await readFile(path.join(targetCwd, "secret.txt"), "utf8")).toBe("Failed copy still private\n");
});
it.each(["empty", "different_identity", "unreadable_alias", "loop_alias", "anonymous_private_edits", "authorized_peer"] as const)(
"authorizes every physical repository source for a standard reader with %s", async scenario => {
const f = await fixture(), peer = await standardPeer(f);
@@ -771,11 +999,11 @@ const support = await getEmbeddedPostgresTestSupport();
});
}
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
// Once fenced, unrelated historical paths need no further filesystem reads.
// Rechecking current owners must still tolerate an unrelated unavailable path.
vi.restoreAllMocks();
const actual = fs.realpath.bind(fs);
vi.spyOn(fs, "realpath").mockImplementation(async pathname => {
if (String(pathname) === unavailableCwd) throw new Error("Historical path must not be revisited");
if (String(pathname) === unavailableCwd) throw Object.assign(new Error("Historical path remains unavailable"), { code: "EACCES" });
return actual(pathname);
});
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission(peer.admission)).resolves.toEqual([]);
@@ -36,22 +36,31 @@ async function canonicalRepositoryRoot(cwd: string): Promise<string> {
}
}
/** Physical folder authority is symmetric: a parent captures its descendants,
* while a child remains inside its ancestor's retained file ownership. */
function repositoryRootsOverlap(left: string, right: string): boolean {
const contains = (parent: string, child: string) => {
const relative = path.relative(parent, child);
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
};
return contains(left, right) || contains(right, left);
}
/** An unavailable legacy path must not block unrelated roots. Preserve the
* unresolved suffix and reject roots inside an inaccessible prefix, since a
* hidden symlink may still point at the candidate's admitted directory. */
async function legacyRepositoryRootMayMatch(cwd: string, candidate: string): Promise<boolean> {
const absolute = path.resolve(cwd);
if (absolute === candidate) return true;
let probe = absolute;
let suffix = "";
let inaccessiblePrefix: string | null = null;
for (;;) {
try {
const canonical = await fs.realpath(probe);
if (path.join(canonical, suffix) === candidate) return true;
if (repositoryRootsOverlap(path.join(canonical, suffix), candidate)) return true;
if (!inaccessiblePrefix) return false;
const unresolved = path.join(canonical, path.relative(probe, inaccessiblePrefix));
return candidate === unresolved || candidate.startsWith(`${unresolved}${path.sep}`);
return repositoryRootsOverlap(candidate, unresolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (!["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(code ?? "")) throw error;
@@ -102,24 +111,44 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
}) {
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
const sourceIds = new Set(entries.filter(entry => entry.kind === "source").map(entry => entry.scopeId));
if (input.sourceWorkspaceId) sourceIds.add(input.sourceWorkspaceId);
const inspectCurrentAliases = input.inspectCurrentAliases || !entries.some(entry => entry.kind === "reader");
if (inspectCurrentAliases) {
// Include current physical aliases as well as sticky historical sources.
// Ordinary task files carry privacy even without acquisition receipts.
const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
.where(eq(executionWorkspaces.companyId, input.companyId));
for (const workspace of workspaces) {
if (!workspace.cwd) continue;
const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
});
if (peerRoot === canonicalCwd) sourceIds.add(workspace.id);
const companyEntries = await tx.select().from(executionWorkspaceRepositoryAccess)
.where(eq(executionWorkspaceRepositoryAccess.companyId, input.companyId));
const entries = companyEntries.filter(entry => entry.rootKey === rootKey);
const overlappingEntries = companyEntries.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)
|| overlappingEntries.some(entry => createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
throw forbidden("Repository root identity conflicts with its retained access record");
}
// Only an exact target copy record proves that ancestral source owners were
// copied here. A copied child must never relax its parent or a new owner
// configured directly at/inside the target.
const copiedProjectIds = new Set(entries.filter(entry => entry.kind === "isolated_project_source").map(entry => entry.scopeId));
const isCopiedAncestor = (projectId: string, sourceRoot: string) => copiedProjectIds.has(projectId)
&& sourceRoot !== canonicalCwd && repositoryRootsOverlap(sourceRoot, canonicalCwd)
&& !path.relative(sourceRoot, canonicalCwd).startsWith(`..${path.sep}`)
&& path.relative(sourceRoot, canonicalCwd) !== "..";
const sharedSourceRoots = new Map<string, Set<string>>();
const addSharedSource = (id: string, cwd: string) => {
const roots = sharedSourceRoots.get(id) ?? new Set<string>();
roots.add(cwd); sharedSourceRoots.set(id, roots);
};
for (const entry of overlappingEntries) if (entry.kind === "source") addSharedSource(entry.scopeId, entry.canonicalCwd);
const sharedSourceIds = new Set(sharedSourceRoots.keys());
const isolatedSourceIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_source").map(entry => entry.scopeId));
const sourceIds = new Set([...sharedSourceIds, ...isolatedSourceIds]);
if (input.sourceWorkspaceId) { sourceIds.add(input.sourceWorkspaceId); sharedSourceIds.add(input.sourceWorkspaceId); addSharedSource(input.sourceWorkspaceId, canonicalCwd); }
// Recheck current roots on every admission/publication. A later nested owner
// can introduce private files even after this root has retained readers.
const workspaces = await tx.select({ id: executionWorkspaces.id, cwd: executionWorkspaces.cwd }).from(executionWorkspaces)
.where(eq(executionWorkspaces.companyId, input.companyId));
for (const workspace of workspaces) {
if (!workspace.cwd) continue;
const peerRoot = await canonicalRepositoryRoot(workspace.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
});
if (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd)) {
sourceIds.add(workspace.id); sharedSourceIds.add(workspace.id); addSharedSource(workspace.id, peerRoot);
}
}
const inventory = await tx.select({ id: executionWorkspaceRepositories.id, workspaceId: executionWorkspaces.id, cwd: executionWorkspaces.cwd,
@@ -149,7 +178,6 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
for (const peer of inventory) {
const name = path.basename(taskRepositoryRelativePath(peer.identity));
const published = publishedNames.has(name);
if (!published && !inspectCurrentAliases) continue;
const peerRoot = peer.cwd ? await fs.realpath(peer.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
@@ -158,11 +186,14 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
// copies in available unrelated roots do not contribute files here.
// Retained sources survive cwd changes; unavailable legacy aliases with
// matching publications remain ambiguous and therefore fail closed.
const matches = sourceIds.has(peer.workspaceId) || peerRoot === canonicalCwd
const matches = sourceIds.has(peer.workspaceId) || (peerRoot !== null && repositoryRootsOverlap(peerRoot, canonicalCwd))
|| (published && peerRoot === null && !knownOrigins.has(peer.id))
|| (!entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
|| (peerRoot === null && !entries.some(entry => entry.kind === "reader") && peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd));
if (matches) {
sourceIds.add(peer.workspaceId);
if (!isolatedSourceIds.has(peer.workspaceId) || (peerRoot && repositoryRootsOverlap(peerRoot, canonicalCwd))) {
sharedSourceIds.add(peer.workspaceId); addSharedSource(peer.workspaceId, peerRoot ?? canonicalCwd);
}
if (published) recognizedNames.add(name);
}
}
@@ -173,15 +204,16 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
.from(projectWorkspaces).where(eq(projectWorkspaces.companyId, input.companyId));
// Retain the privacy owner, not its disposable workspace configuration.
// Execution sources may later lose their project FK; retained files must not.
const sourceProjectIds = new Set(entries.filter(entry => entry.kind === "project_source").map(entry => entry.scopeId));
const sharedProjectIds = new Set(entries.filter(entry => entry.kind === "shared_project_source").map(entry => entry.scopeId));
const isolatedProjectIds = new Set<string>();
const sourceProjectIds = new Set(overlappingEntries.filter(entry => (entry.kind === "project_source" || entry.kind === "isolated_project_source")).map(entry => entry.scopeId));
const sharedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "shared_project_source" && !isCopiedAncestor(entry.scopeId, entry.canonicalCwd)).map(entry => entry.scopeId));
const isolatedProjectIds = new Set(overlappingEntries.filter(entry => entry.kind === "isolated_project_source").map(entry => entry.scopeId));
if (sourceIds.size) {
const owners = await tx.select({ projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
const owners = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId, strategyType: executionWorkspaces.strategyType }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), or(...[...sourceIds].map(id => eq(executionWorkspaces.id, id)))));
for (const owner of owners) if (owner.projectId) {
sourceProjectIds.add(owner.projectId);
if (owner.strategyType === "git_worktree") isolatedProjectIds.add(owner.projectId);
if (owner.strategyType === "git_worktree" || !sharedSourceIds.has(owner.id)
|| [...sharedSourceRoots.get(owner.id) ?? []].every(cwd => isCopiedAncestor(owner.projectId!, cwd))) isolatedProjectIds.add(owner.projectId);
else sharedProjectIds.add(owner.projectId);
}
}
@@ -191,16 +223,16 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
});
if (sourceRoot !== canonicalCwd) continue;
if (!sourceRoot || !repositoryRootsOverlap(sourceRoot, canonicalCwd)) continue;
sourceProjectIds.add(source.projectId);
sharedProjectIds.add(source.projectId);
if (!isCopiedAncestor(source.projectId, sourceRoot)) sharedProjectIds.add(source.projectId);
}
// Older retained owners have no sharing classification. Require writes
// unless an authoritative retained workspace proves an isolated copy.
for (const entry of entries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
for (const entry of overlappingEntries) if (entry.kind === "project_source" && !isolatedProjectIds.has(entry.scopeId)) {
sharedProjectIds.add(entry.scopeId);
}
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds };
return { canonicalCwd, rootKey, entries, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds };
}
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
@@ -215,12 +247,50 @@ async function assertRootProjectAccess(db: Db | DbTransaction, actor: Authorizat
}
}
async function retainedRootReader(tx: DbTransaction, companyId: string, reader: typeof executionWorkspaceRepositoryAccess.$inferSelect) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, companyId)));
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, companyId))) : [];
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, companyId))) : [];
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== reader.issueId) {
throw forbidden("Repository root retains a reader whose identity is no longer available");
}
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId,
agentId: reader.agentId!, runId: reader.scopeId, onBehalfOfUserId: reader.responsibleUserId === "local-board" ? null : reader.responsibleUserId };
return { actor, issue };
}
export async function lockExecutionWorkspaceRootProvenance(tx: DbTransaction, companyId: string) {
// Acquire before task/workspace row locks. Hold only for policy and receipt
// transactions, never Git, provider execution, or snapshot capture.
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`task-repository-roots:${companyId}`}, 0))`);
}
/** A configured folder owns project files before its first task admission.
* Preserve that ownership before its configuration can move or disappear.
* Caller holds the company root fence and the fresh configured-workspace lock. */
export async function preserveConfiguredProjectWorkspaceRootProvenance(tx: DbTransaction, input: {
companyId: string; projectId: string; cwd: string | null;
}): Promise<void> {
if (!input.cwd) return;
const directory = await fs.stat(input.cwd).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (!directory) return;
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
const canonicalCwd = await canonicalRepositoryRoot(input.cwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const entries = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
if (entries.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
for (const kind of ["project_source", "shared_project_source"] as const) {
if (!entries.some(entry => entry.kind === kind && entry.scopeId === input.projectId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId: input.projectId });
}
}
}
/** Preserve deny-only owners before a workspace can detach from existing files.
* Caller holds the company root fence, followed by the workspace row lock. */
export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction, input: {
@@ -233,10 +303,10 @@ export async function preserveExecutionWorkspaceRootProvenance(tx: DbTransaction
});
if (!directory) return;
if (!directory.isDirectory()) throw forbidden("Workspace files cannot be detached from unverifiable provenance");
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, {
const { canonicalCwd, rootKey, entries, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await resolveRepositoryRootSources(tx, {
companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
});
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds]] as const) {
for (const [kind, ids] of [["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]] as const) {
for (const scopeId of ids) {
if (!entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, canonicalCwd, rootKey, kind, scopeId });
@@ -254,9 +324,108 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
}
/** Match realization's checkout and common repository roots. A configured cwd
* may be a subdirectory or linked worktree while Git copies a broader tree. */
async function isolatedWorkspaceSourceRoots(sourceCwd: string): Promise<string[]> {
const git = async (cwd: string, argument: string) => (await execFile("git", ["-C", cwd, "rev-parse", argument], {
timeout: 30_000, env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
})).stdout.trim();
const checkoutRoot = await fs.realpath(path.resolve(await git(sourceCwd, "--show-toplevel")));
const commonDir = await git(checkoutRoot, "--git-common-dir");
const ownerRoot = await fs.realpath(path.dirname(path.resolve(checkoutRoot, commonDir)));
return [...new Set([checkoutRoot, ownerRoot])];
}
/** Internal, deny-only snapshot captured before copying; never accepted from a model or API payload. */
export type IsolatedWorkspaceSourceAccess = {
companyId: string;
roots: string[];
workspaceIds: string[];
projectIds: string[];
};
export async function assertIsolatedWorkspaceSourceReadAccess(db: Db, input: {
companyId: string; sourceCwd: string; actor: AuthorizationActor;
}): Promise<IsolatedWorkspaceSourceAccess> {
const roots = await isolatedWorkspaceSourceRoots(input.sourceCwd);
return db.transaction(async tx => {
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
const workspaceIds = new Set<string>(), projectIds = new Set<string>();
for (const cwd of roots) {
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true });
for (const id of closure.sourceIds) {
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id);
workspaceIds.add(id);
}
await assertRootProjectAccess(tx, input.actor, input.companyId, closure.sourceProjectIds);
for (const id of closure.sourceProjectIds) projectIds.add(id);
}
return { companyId: input.companyId, roots, workspaceIds: [...workspaceIds], projectIds: [...projectIds] };
});
}
/** Retain source privacy before copying and after realization, including a failed copy admission.
* Commit deny-only ownership before reporting a revoked/missing source, so
* already-copied private bytes never become an unowned readable target. */
export async function retainIsolatedWorkspaceSourceAccess(db: Db, input: {
companyId: string; sourceCwd: string; targetCwd: string; actor: AuthorizationActor;
captured: IsolatedWorkspaceSourceAccess; issueId?: string;
}): Promise<void> {
if (input.captured.companyId !== input.companyId) throw forbidden("Isolated copy source identity changed");
let sourceFailure: unknown;
const roots = new Set(input.captured.roots);
try { for (const cwd of await isolatedWorkspaceSourceRoots(input.sourceCwd)) roots.add(cwd); }
catch (error) { sourceFailure = error; }
const canonicalCwd = await canonicalRepositoryRoot(input.targetCwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const denied = await db.transaction(async tx => {
await lockExecutionWorkspaceRootProvenance(tx, input.companyId);
const existing = await tx.select().from(executionWorkspaceRepositoryAccess).where(and(
eq(executionWorkspaceRepositoryAccess.companyId, input.companyId), eq(executionWorkspaceRepositoryAccess.rootKey, rootKey)));
if (existing.some(entry => entry.canonicalCwd !== canonicalCwd)) throw forbidden("Repository root identity conflicts with its retained access record");
const workspaceIds = new Set(input.captured.workspaceIds), projectIds = new Set(input.captured.projectIds);
let failure = sourceFailure;
for (const cwd of roots) {
try {
const closure = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd, inspectCurrentAliases: true });
for (const id of closure.sourceIds) workspaceIds.add(id);
for (const id of closure.sourceProjectIds) projectIds.add(id);
} catch (error) { failure ??= error; }
}
for (const id of workspaceIds) {
try { await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, id); }
catch (error) { failure ??= error; }
}
for (const id of projectIds) {
try { await assertRootProjectAccess(tx, input.actor, input.companyId, new Set([id])); }
catch (error) { failure ??= error; }
}
for (const [kind, ids] of [["isolated_source", workspaceIds], ["isolated_project_source", projectIds]] as const) {
for (const scopeId of ids) if (!existing.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
}
}
// The target may already contain independently owned files. Copy source
// READ authority never grants permission to provision/write that target.
try {
await assertExecutionWorkspaceRootWriteAccess(tx, {
companyId: input.companyId, cwd: canonicalCwd, actor: input.actor, issueId: input.issueId,
});
const target = await resolveRepositoryRootSources(tx, { companyId: input.companyId, cwd: canonicalCwd });
for (const reader of target.overlappingEntries.filter(entry => entry.kind === "reader")) {
const { actor } = await retainedRootReader(tx, input.companyId, reader);
for (const id of target.sourceIds) await assertTaskWorkspaceAccess(tx, actor, input.companyId, id);
await assertRootProjectAccess(tx, actor, input.companyId, target.sourceProjectIds);
}
} catch (error) { failure ??= error; }
return failure;
});
if (denied) throw denied;
}
/** Selection validates writable source scope without registering a reader or changing policy. */
export async function assertExecutionWorkspaceRootWriteAccess(db: Db | DbTransaction, input: {
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId: string;
companyId: string; cwd: string; actor: AuthorizationActor; sourceWorkspaceId?: string;
} & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">): Promise<void> {
const { sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(db, { ...input, inspectCurrentAliases: true });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(db, input.actor, input.companyId, sourceId);
@@ -297,9 +466,14 @@ export function executionWorkspaceRepositoryService(db: Db) {
async function reserveRootPolicy(tx: DbTransaction, companyId: string, cwd: string, mode: "anonymous_only" | "managed") {
const canonicalCwd = await canonicalRepositoryRoot(cwd);
const rootKey = createHash("sha256").update(canonicalCwd).digest("hex");
const [existing] = await tx.select().from(executionWorkspaceRepositoryRoots).where(and(
eq(executionWorkspaceRepositoryRoots.companyId, companyId), eq(executionWorkspaceRepositoryRoots.rootKey, rootKey)));
if (existing && (existing.canonicalCwd !== canonicalCwd || existing.mode !== mode)) throw forbidden(lowTrustRepositoryMessage);
const reservations = await tx.select().from(executionWorkspaceRepositoryRoots)
.where(eq(executionWorkspaceRepositoryRoots.companyId, companyId));
const existing = reservations.find(entry => entry.rootKey === rootKey);
const overlapping = reservations.filter(entry => repositoryRootsOverlap(entry.canonicalCwd, canonicalCwd));
if ((existing && existing.canonicalCwd !== canonicalCwd)
|| overlapping.some(entry => entry.mode !== mode || createHash("sha256").update(entry.canonicalCwd).digest("hex") !== entry.rootKey)) {
throw forbidden(lowTrustRepositoryMessage);
}
if (mode === "anonymous_only") {
// Legacy receipts predate root reservations. Even pending/staging work
// can publish after admission, so no managed intent in this root is safe.
@@ -331,7 +505,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
}
}
if (!existing) for (const peer of managed) {
for (const peer of managed) {
if (peer.cwd && await legacyRepositoryRootMayMatch(peer.cwd, canonicalCwd)) throw forbidden(lowTrustRepositoryMessage);
}
}
@@ -342,9 +516,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
async function authorizeRootAccess(tx: DbTransaction, input: {
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
}) {
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, input);
const readers = entries.filter(entry => entry.kind === "reader");
if (input.reader && !readers.some(reader => reader.scopeId === input.reader!.runId)) {
const { canonicalCwd, rootKey, entries, overlappingEntries, sourceIds, sharedSourceIds, isolatedSourceIds, sourceProjectIds, sharedProjectIds, isolatedProjectIds } = await resolveRepositoryRootSources(tx, input);
const readers = overlappingEntries.filter(entry => entry.kind === "reader");
if (input.reader && !entries.some(entry => entry.kind === "reader" && entry.scopeId === input.reader!.runId)) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
readers.push({ id: randomUUID(), companyId: input.companyId, rootKey, canonicalCwd, kind: "reader", scopeId: input.reader.runId,
issueId: input.reader.issueId, agentId: input.reader.agentId, responsibleUserId: run?.responsibleUserId ?? null, createdAt: new Date(), updatedAt: new Date() });
@@ -356,15 +530,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
&& sourceIds.size === 1 && sourceIds.has(input.sourceWorkspaceId)
&& readers.every(reader => reader.issueId === input.reader!.issueId);
for (const reader of readers) {
const [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, reader.scopeId), eq(heartbeatRuns.companyId, input.companyId)));
const [agent] = reader.agentId ? await tx.select({ id: agents.id }).from(agents).where(and(eq(agents.id, reader.agentId), eq(agents.companyId, input.companyId))) : [];
const [issue] = reader.issueId ? await tx.select().from(issues).where(and(eq(issues.id, reader.issueId), eq(issues.companyId, input.companyId))) : [];
if (!run || !agent || !issue || run.agentId !== reader.agentId || run.responsibleUserId !== reader.responsibleUserId
|| (run.nativeIssueId ?? run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId ?? run.issueId) !== reader.issueId) {
throw forbidden("Repository root retains a reader whose identity is no longer available");
}
const actor: AuthorizationActor = { type: "agent", source: "agent_jwt", companyId: input.companyId,
agentId: reader.agentId!, runId: reader.scopeId, onBehalfOfUserId: reader.responsibleUserId === "local-board" ? null : reader.responsibleUserId };
const { actor, issue } = await retainedRootReader(tx, input.companyId, reader);
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
if (ownAdmissionOnly) {
@@ -393,23 +559,18 @@ export function executionWorkspaceRepositoryService(db: Db) {
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds, { sharedProjectIds, issueId: input.issueId });
for (const sourceId of sourceIds) await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, sourceId);
}
for (const sourceId of sourceIds) {
if (!entries.some(entry => entry.kind === "source" && entry.scopeId === sourceId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "source", scopeId: sourceId });
}
}
for (const sourceId of sourceProjectIds) {
if (!entries.some(entry => entry.kind === "project_source" && entry.scopeId === sourceId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "project_source", scopeId: sourceId });
}
}
for (const sourceId of sharedProjectIds) {
if (!entries.some(entry => entry.kind === "shared_project_source" && entry.scopeId === sourceId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind: "shared_project_source", scopeId: sourceId });
for (const [kind, ids] of [["source", sharedSourceIds], ["isolated_source", isolatedSourceIds], ["project_source", sourceProjectIds],
["shared_project_source", sharedProjectIds], ["isolated_project_source", isolatedProjectIds]] as const) {
for (const scopeId of ids) {
if (!entries.some(entry => entry.kind === kind && entry.scopeId === scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values({ companyId: input.companyId, rootKey, canonicalCwd, kind, scopeId });
}
}
}
for (const reader of readers) {
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
if (reader.rootKey === rootKey && !entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) {
await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
}
}
}
async function reserveRootAccess(input: Parameters<typeof authorizeRootAccess>[1]) {
@@ -28,6 +28,10 @@ import { readNativeWorkspaceSyncReference, readNativeWorkspaceSyncLocalCwd } fro
import { parseObject } from "../../adapters/utils.js";
import { materializeNativeChatTaskRoot } from "../native-runtime/native-chat-workspace.js";
import { materializeIsolatedTaskDirectory } from "../isolated-task-directory.js";
import {
assertIsolatedWorkspaceSourceReadAccess,
retainIsolatedWorkspaceSourceAccess,
} from "../execution-workspace-repositories.js";
import {
cleanupExecutionWorkspaceArtifacts,
ensurePersistedExecutionWorkspaceAvailable,
@@ -468,6 +472,21 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
agentId: agent.id,
},
);
const isolatedWorkspaceSourceActor = { type: "agent" as const, source: "agent_jwt" as const, companyId: agent.companyId,
agentId: agent.id, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
// Capture the source's ownership before Git can copy tracked bytes. Keep the
// server-owned snapshot so a concurrent deletion cannot erase copied privacy.
const isolatedWorkspaceSourceAccess = !isDotRun && issueId && !persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId
&& (latestWorkspaceStrategyType === "git_worktree"
|| (workspaceReuseProvisioningPolicy.shouldRestoreExistingWorkspace && reusableExistingExecutionWorkspace?.strategyType === "git_worktree"))
? await assertIsolatedWorkspaceSourceReadAccess(db, {
companyId: agent.companyId, sourceCwd: executionWorkspaceBase.baseCwd, actor: isolatedWorkspaceSourceActor,
}) : null;
const beforeIsolatedSourceCopy = isolatedWorkspaceSourceAccess
? (sourceCwd: string, targetCwd: string) => retainIsolatedWorkspaceSourceAccess(db, {
companyId: agent.companyId, sourceCwd, targetCwd,
actor: isolatedWorkspaceSourceActor, captured: isolatedWorkspaceSourceAccess, issueId: issueId ?? undefined,
}) : undefined;
const {
executionWorkspace,
reusedExecutionWorkspace,
@@ -535,6 +554,7 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
.enableWorkspaceDirtyQuarantineRepair,
recorder: workspaceOperationRecorder,
resolveGitAuth: workspaceGitAuthProvider,
beforeSourceCopy: beforeIsolatedSourceCopy,
})
: null,
realizeWorkspace: () =>
@@ -567,9 +587,20 @@ export async function prepareHeartbeatWorkspace(db: Db, input: HeartbeatWorkspac
.enableWorkspaceDirtyQuarantineRepair,
recorder: workspaceOperationRecorder,
resolveGitAuth: workspaceGitAuthProvider,
beforeSourceCopy: beforeIsolatedSourceCopy,
}),
},
);
if (isolatedWorkspaceSourceAccess && executionWorkspace.strategy === "git_worktree") {
await retainIsolatedWorkspaceSourceAccess(db, {
companyId: agent.companyId,
sourceCwd: executionWorkspaceBase.baseCwd,
targetCwd: executionWorkspace.cwd,
actor: isolatedWorkspaceSourceActor,
captured: isolatedWorkspaceSourceAccess,
issueId: issueId ?? undefined,
});
}
const resolvedProjectId =
reusableExistingExecutionWorkspace
? reusableExistingExecutionWorkspace.projectId
+30 -7
View File
@@ -37,6 +37,17 @@ import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-poli
import { mergeProjectWorkspaceRuntimeConfig, readProjectWorkspaceRuntimeConfig } from "./project-workspace-runtime-config.js";
import { resolveManagedProjectWorkspaceDir } from "../home-paths.js";
import { recordProjectLifecycleEvent, recordResourceCreationEvent } from "./resource-lifecycle-events.js";
import { lockExecutionWorkspaceRootProvenance, preserveConfiguredProjectWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
type ProjectTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
/** Root provenance fences precede project/workspace locks in every caller. */
async function lockProjectRootMutation(tx: ProjectTransaction, projectId: string) {
const [identity] = await tx.select({ companyId: projects.companyId }).from(projects).where(eq(projects.id, projectId));
if (!identity) return null;
await lockExecutionWorkspaceRootProvenance(tx, identity.companyId);
return tx.select().from(projects).where(eq(projects.id, projectId)).for("update").then(rows => rows[0] ?? null);
}
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
@@ -914,7 +925,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
replaceRepositories: async (projectId: string, repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals | null> => {
return db.transaction(async (tx) => {
const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update");
const project = await lockProjectRootMutation(tx, projectId);
if (!project) return null;
const service = projectService(tx as unknown as Db, { captureWorkspaceUpdates: false });
const existing = await service.listWorkspaces(projectId);
@@ -1055,13 +1066,17 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
return cleared;
},
remove: async (id: string) => {
const [retained] = await db.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
remove: async (id: string) => db.transaction(async tx => {
const project = await lockProjectRootMutation(tx, id);
if (!project) return null;
const [retained] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces)
.where(eq(executionWorkspaces.projectId, id)).limit(1);
if (retained) throw conflict("This project still supplies access policy for retained task workspaces. Archive the project or explicitly clean up its workspaces before deleting it.");
const [row] = await db.delete(projects).where(eq(projects.id, id)).returning();
const workspaces = await tx.select().from(projectWorkspaces).where(eq(projectWorkspaces.projectId, id)).for("update");
for (const workspace of workspaces) await preserveConfiguredProjectWorkspaceRootProvenance(tx, workspace);
const [row] = await tx.delete(projects).where(eq(projects.id, id)).returning();
return row ? { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) } : null;
},
}),
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
const rows = await db
@@ -1174,7 +1189,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
data: UpdateWorkspaceInput,
): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
if (!await lockProjectRootMutation(tx, projectId)) return null;
const existing = await db
.select()
.from(projectWorkspaces)
@@ -1184,6 +1199,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
eq(projectWorkspaces.projectId, projectId),
),
)
.for("update")
.then((rows) => rows[0] ?? null);
if (!existing) return null;
@@ -1241,6 +1257,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
: data.metadata;
}
if ((["cwd", "sourceType", "repoUrl", "remoteProvider", "remoteWorkspaceRef", "sharedWorkspaceKey"] as const)
.some(key => patch[key] !== undefined && patch[key] !== existing[key])) {
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
}
const updated = await db.transaction(async (tx) => {
if (data.isPrimary === true) {
await tx
@@ -1327,7 +1348,7 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => db.transaction(async tx => {
const db = tx as unknown as Db;
await db.select({ id: projects.id }).from(projects).where(eq(projects.id, projectId)).for("update");
if (!await lockProjectRootMutation(tx, projectId)) return null;
const existing = await db
.select()
.from(projectWorkspaces)
@@ -1337,9 +1358,11 @@ export function projectService(db: Db, options: { captureWorkspaceUpdates?: bool
eq(projectWorkspaces.projectId, projectId),
),
)
.for("update")
.then((rows) => rows[0] ?? null);
if (!existing) return null;
await preserveConfiguredProjectWorkspaceRootProvenance(tx, existing);
const removed = await db.transaction(async (tx) => {
const row = await tx
.delete(projectWorkspaces)
+5
View File
@@ -3323,6 +3323,7 @@ export async function realizeExecutionWorkspace(input: {
enableWorkspaceDirtyQuarantineRepair?: boolean;
recorder?: WorkspaceOperationRecorder | null;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
}): Promise<RealizedExecutionWorkspace> {
const rawStrategy = parseObject(input.config.workspaceStrategy);
const strategyType = asString(rawStrategy.type, "project_primary");
@@ -3404,6 +3405,7 @@ export async function realizeExecutionWorkspace(input: {
},
);
}
await input.beforeSourceCopy?.(repoRoot, worktreePath);
let pendingForwardBranchReconcile: PendingForwardBranchReconcile | null = null;
const configuredBaseRef = typeof rawStrategy.baseRef === "string" && rawStrategy.baseRef.length > 0
? rawStrategy.baseRef
@@ -3426,6 +3428,7 @@ export async function realizeExecutionWorkspace(input: {
await fs.mkdir(worktreeParentDir, { recursive: true });
async function reuseExistingWorktree(reusablePath: string, effectiveBranchName = branchName, extraWarnings: string[] = []) {
if (reusablePath !== worktreePath) await input.beforeSourceCopy?.(repoRoot, reusablePath);
// An exact-branch attach must never move the requested branch, so skip
// the unstarted-worktree fast-forward that template-derived reuse gets.
const refresh = currentBaseRefSha && !requestedExistingBranch
@@ -3773,6 +3776,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
enableWorkspaceDirtyQuarantineRepair?: boolean;
recorder?: WorkspaceOperationRecorder | null;
resolveGitAuth?: GitRemoteAuthProvider | null;
beforeSourceCopy?: (sourceCwd: string, targetCwd: string) => Promise<void>;
}): Promise<RealizedExecutionWorkspace | null> {
const cwd = asString(input.workspace.cwd ?? input.workspace.providerRef, "").trim();
if (!cwd) return null;
@@ -3825,6 +3829,7 @@ export async function ensurePersistedExecutionWorkspaceAvailable(input: {
);
}
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], baseCwd);
await input.beforeSourceCopy?.(repoRoot, realized.worktreePath ?? cwd);
const recordedBaseRefSha = readRecordedBaseRefSha(input.workspace.metadata);
if (await directoryExists(cwd)) {
const reuseBaseRef = input.workspace.baseRef ?? input.base.repoRef ?? null;