Enforce shared-root write authority and recover low-disk exports safely

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-12 11:27:36 -05:00
1 parent 52ea8711ef
commit 1b98e5df2f
10 files changed
+352 -36

No files matched your search

@@ -3,7 +3,7 @@ import { mkdtemp } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns } from "@paperclipai/db";
import { agents, executionWorkspaceRepositories, executionWorkspaces, issues, heartbeatRuns, projects, projectWorkspaces, principalPermissionGrants, companyMemberships } from "@paperclipai/db";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
@@ -33,6 +33,30 @@ const support = await getEmbeddedPostgresTestSupport();
expect(runs).toHaveLength(1);
expect(runs[0]).toMatchObject({ id: f.runId, status: "running" });
});
it("keeps projectless protected-folder aliases readable while enforcing scoped write grants", async () => {
const f = await server.fixture({ conversation: true, disableWakeOnDemand: true });
const [project] = await server.db.insert(projects).values({ companyId: f.companyId, name: "Protected physical source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await server.db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected folder", cwd: f.workspace });
const [workspace] = await server.db.insert(executionWorkspaces).values({ companyId: f.companyId, sourceIssueId: f.issueId,
name: "Projectless alias", cwd: f.workspace, mode: "shared_workspace", strategyType: "task_directory" }).returning();
await server.db.update(issues).set({ projectId: null, projectWorkspaceId: null, executionWorkspaceId: workspace.id }).where(eq(issues.id, f.issueId));
const call = (tool: string, args: Record<string, unknown>) => f.authority.execute({ tool, arguments: args, callId: randomUUID() });
const selection = { selection: { kind: "existing", workspaceId: workspace.id }, expectedBindingRevision: 0, requestKey: "protected-alias" };
const repository = { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "protected-root-write" };
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: workspace.id, projectId: null } });
await expect(call("select_workspace", selection)).rejects.toThrow(/protected|not authorized/);
await expect(call("prepare_repository", repository)).rejects.toThrow(/protected|not authorized/);
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
membershipRole: "member", status: "active" });
await server.db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId!, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]);
await expect(call("select_workspace", selection)).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
});
it("does not expose repository preparation or workspace selection to Ask mode", async () => {
const f = await server.fixture({ mode: "ask", conversation: true, disableWakeOnDemand: true });
await expect(f.authority.execute({ tool: "prepare_repository", callId: randomUUID(), arguments: { repository: { kind: "url", url: "https://github.com/public/example" }, requestKey: "denied" } })).rejects.toThrow();
@@ -88,7 +112,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
await server.db.update(agents).set({ permissions: policy([f.projectId]) }).where(eq(agents.id, f.agentId));
await expect(call("get_workspace", {})).resolves.toMatchObject({ workspace: { id: source.id, projectId: f.projectId } });
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).rejects.toThrow(/outside.*boundary/i);
// Project permission cannot expose the unrelated task's files through a
// shared physical root. Give that fixture its own folder before testing
// the permitted source-only preparation path.
@@ -96,6 +120,7 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await server.db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.companyId, f.companyId))).toHaveLength(0);
const unrelatedCwd = await mkdtemp(join(dirname(f.workspace), "unrelated-workspace-"));
await server.db.update(executionWorkspaces).set({ cwd: unrelatedCwd }).where(eq(executionWorkspaces.id, unrelated.id));
await expect(call("select_workspace", { selection: { kind: "existing", workspaceId: source.id }, expectedBindingRevision: 0, requestKey: "source-allowed" })).resolves.toMatchObject({ kind: "scheduled" });
await expect(call("prepare_repository", repository)).resolves.toMatchObject({ kind: "requires_next_admission" });
expect((await server.db.select().from(issues).where(eq(issues.id, f.issueId)))[0]?.projectId).toBeNull();
});
@@ -89,7 +89,7 @@ const support = await getEmbeddedPostgresTestSupport();
{ kind: "source", scopeId: f.workspaceId, cwd: await fs.realpath(oldCwd) },
{ kind: "project_source", scopeId: project.id, cwd: await fs.realpath(oldCwd) },
]));
expect(retained.every(entry => ["source", "project_source"].includes(entry.kind))).toBe(true);
expect(retained.every(entry => ["source", "project_source", "shared_project_source"].includes(entry.kind))).toBe(true);
expect(await fs.readFile(path.join(oldCwd, "private.txt"), "utf8")).toBe("Retained old files");
expect(await service.update(f.workspaceId, { metadata: null })).toMatchObject({
metadata: { _issuePrivacySources: { [ancestor.id]: true, [f.issueId]: true } },
@@ -8,7 +8,7 @@ import path from "node:path";
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, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
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 { executionWorkspaceService } from "../services/execution-workspaces.js";
import { buildNativeExecutionInput } from "../services/native-runtime/native-execution-input.js";
@@ -228,6 +228,94 @@ const support = await getEmbeddedPostgresTestSupport();
expect(await readFile(path.join(checkout.cwd, "private.txt"), "utf8")).toBe("Private task edits\n");
});
it("requires the admitted actor's write grant for a projectless alias of a protected configured root", async () => {
const f = await fixture();
await db.update(companyMemberships).set({ membershipRole: "member" }).where(eq(companyMemberships.principalId, f.userId));
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected shared root",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const alias = path.join(root, `${f.issueId}-protected-alias`);
await symlink(f.admission.cwd, alias);
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Protected source", cwd: alias }).returning();
const read = () => assertExecutionWorkspaceRootReadAccess(db, { companyId: f.companyId, cwd: f.admission.cwd,
sourceWorkspaceId: f.workspace.id, actor: f.actor });
await expect(read()).resolves.toBeUndefined();
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
await expect(f.request({ kind: "url", url: "https://github.com/public/writable" })).rejects.toThrow(/protected|not authorized/);
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent", principalId: f.agentId,
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } });
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: f.userId,
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning();
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
await db.delete(projectWorkspaces).where(eq(projectWorkspaces.id, source.id));
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(read()).resolves.toBeUndefined();
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
await expect(f.service.guardForPublication(f.admission)).rejects.toThrow(/Responsible user.*not authorized/);
});
it("keeps genuine isolated Git copies read-only against their protected source project", async () => {
const f = await fixture();
const sourceRoot = path.join(root, `${f.issueId}-isolated-source`);
await mkdir(sourceRoot);
const exec = promisify(execFileCallback);
await exec("git", ["init", "--initial-branch=main", sourceRoot]);
await writeFile(path.join(sourceRoot, "README.md"), "Protected source\n");
await exec("git", ["-C", sourceRoot, "add", "."]);
await exec("git", ["-C", sourceRoot, "-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "-m", "source"]);
await exec("git", ["-C", sourceRoot, "worktree", "add", "-b", "isolated-task", f.admission.cwd]);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected copy source",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Original", cwd: sourceRoot }).returning();
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id, strategyType: "git_worktree" }).where(eq(executionWorkspaces.id, f.workspace.id));
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(f.admission)).resolves.toBeUndefined();
// Physical configured ownership overrides a misleading isolation label.
await db.update(projectWorkspaces).set({ cwd: f.admission.cwd }).where(eq(projectWorkspaces.id, source.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
});
it.each(["move", "delete"] as const)("retains protected shared execution-source write scope after %s", async change => {
const f = await fixture(), source = await standardPeer(f);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected execution owner",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.update(executionWorkspaces).set({ projectId: project.id }).where(eq(executionWorkspaces.id, source.workspace.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized/);
const grants = await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]).returning();
await expect(f.service.prepareForAdmission(f.admission)).resolves.toEqual([]);
if (change === "move") {
const moved = path.join(root, `${source.issueId}-shared-moved`);
await mkdir(moved);
await executionWorkspaceService(db).update(source.workspace.id, { cwd: moved, projectId: null });
} else await db.delete(executionWorkspaces).where(eq(executionWorkspaces.id, source.workspace.id));
for (const grant of grants) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(f.service.prepareForAdmission(f.admission)).rejects.toThrow(/protected|not authorized|workspace access/);
});
it("does not require historical readers to retain write grants for another authorized writer", async () => {
const f = await fixture(), peer = await standardPeer(f);
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Shared source writers",
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Shared", cwd: f.admission.cwd });
const prior = await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: f.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: f.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: f.agentId } },
]).returning();
await f.service.prepareForAdmission(f.admission);
await db.insert(principalPermissionGrants).values([
{ companyId: f.companyId, principalType: "agent", principalId: peer.agentId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
{ companyId: f.companyId, principalType: "user", principalId: peer.userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: peer.agentId } },
]);
for (const grant of prior) await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
await expect(f.service.prepareForAdmission(peer.admission)).resolves.toEqual([]);
await expect(f.service.guardForPublication(peer.admission)).resolves.toBeUndefined();
});
it("checks configured private aliases against both admitted agent and responsible user before capture and publication", async () => {
const f = await fixture();
const alias = path.join(root, `${f.issueId}-configured-alias`);
@@ -172,29 +172,44 @@ async function resolveRepositoryRootSources(tx: Db | DbTransaction, input: {
// 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>();
if (sourceIds.size) {
const owners = await tx.select({ projectId: executionWorkspaces.projectId }).from(executionWorkspaces).where(and(
const owners = await tx.select({ 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);
for (const owner of owners) if (owner.projectId) {
sourceProjectIds.add(owner.projectId);
if (owner.strategyType === "git_worktree") isolatedProjectIds.add(owner.projectId);
else sharedProjectIds.add(owner.projectId);
}
}
for (const source of configured) {
if (!source.cwd || sourceProjectIds.has(source.projectId)) continue;
if (!source.cwd || sharedProjectIds.has(source.projectId)) continue;
const sourceRoot = await fs.realpath(source.cwd).catch((error: NodeJS.ErrnoException) => {
if (["ENOENT", "ENOTDIR", "EACCES", "EPERM", "ELOOP", "ENAMETOOLONG"].includes(error.code ?? "")) return null;
throw error;
});
if (sourceRoot !== canonicalCwd) continue;
sourceProjectIds.add(source.projectId);
sharedProjectIds.add(source.projectId);
}
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds };
// 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)) {
sharedProjectIds.add(entry.scopeId);
}
return { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds };
}
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>) {
async function assertRootProjectAccess(db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectIds: Set<string>,
write?: { sharedProjectIds: Set<string> } & Omit<NonNullable<Parameters<typeof assertTaskWorkspaceSourceProjectAccess>[4]>, "write">) {
for (const projectId of projectIds) {
const [project] = await db.select({ id: projects.id }).from(projects).where(and(
eq(projects.id, projectId), eq(projects.companyId, companyId), await projectReadSqlCondition(db, actor)));
if (!project) throw forbidden("Workspace source project is no longer accessible");
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId);
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, projectId, {
...write, write: write?.sharedProjectIds.has(projectId),
});
}
}
@@ -216,10 +231,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 } = await resolveRepositoryRootSources(tx, {
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds, sharedProjectIds } = await resolveRepositoryRootSources(tx, {
companyId: input.companyId, cwd: input.cwd, sourceWorkspaceId: input.workspaceId, inspectCurrentAliases: true,
});
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds]] as const) {
for (const [kind, ids] of [["source", sourceIds], ["project_source", sourceProjectIds], ["shared_project_source", sharedProjectIds]] 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 });
@@ -237,6 +252,15 @@ export async function assertExecutionWorkspaceRootReadAccess(db: Db | DbTransact
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds);
}
/** 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;
} & 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);
await assertRootProjectAccess(db, input.actor, input.companyId, sourceProjectIds, { ...input, sharedProjectIds });
}
/** Workspace aggregate child. It owns acquisition receipts, never task binding or provider sessions. */
export function executionWorkspaceRepositoryService(db: Db) {
async function catalog(companyId: string, userId: string | null, localTrusted: boolean) {
@@ -308,9 +332,9 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
type RootReader = { runId: string; issueId: string; agentId: string };
async function authorizeRootAccess(tx: DbTransaction, input: {
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; preserveOwnAdmission?: boolean;
companyId: string; cwd: string; sourceWorkspaceId?: string; reader?: RootReader; actor?: AuthorizationActor; issueId?: string; preserveOwnAdmission?: boolean;
}) {
const { canonicalCwd, rootKey, entries, sourceIds, sourceProjectIds } = await resolveRepositoryRootSources(tx, input);
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 [run] = await tx.select().from(heartbeatRuns).where(and(eq(heartbeatRuns.id, input.reader.runId), eq(heartbeatRuns.companyId, input.companyId)));
@@ -333,7 +357,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
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 };
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds);
await assertRootProjectAccess(tx, actor, input.companyId, sourceProjectIds,
input.reader?.runId === reader.scopeId ? { sharedProjectIds, issueId: issue.id } : undefined);
if (ownAdmissionOnly) {
const [ownSource] = await tx.select({ id: executionWorkspaces.id }).from(executionWorkspaces).where(and(
eq(executionWorkspaces.companyId, input.companyId), eq(executionWorkspaces.id, input.sourceWorkspaceId!),
@@ -356,7 +381,8 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
}
if (input.actor) {
await assertRootProjectAccess(tx, input.actor, input.companyId, sourceProjectIds);
if (!input.issueId) throw forbidden("Workspace write authorization requires a task");
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) {
@@ -369,6 +395,11 @@ export function executionWorkspaceRepositoryService(db: Db) {
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 reader of readers) {
if (!entries.some(entry => entry.kind === "reader" && entry.scopeId === reader.scopeId)) await tx.insert(executionWorkspaceRepositoryAccess).values(reader);
}
@@ -427,7 +458,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
await reserveRootPolicy(tx, input.companyId, workspace.cwd, anonymousOnly ? "anonymous_only" : "managed");
}
if (!workspace.cwd) throw conflict("The task workspace has no directory for repository preparation");
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor });
await authorizeRootAccess(tx, { companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor, issueId: input.issueId });
const existing = await tx.select().from(executionWorkspaceRepositories).where(and(eq(executionWorkspaceRepositories.executionWorkspaceId, workspace.id), or(eq(executionWorkspaceRepositories.repositoryIdentity, source.repositoryIdentity), sql`${executionWorkspaceRepositories.requestKeys} ? ${parsed.requestKey}`)));
for (const row of existing) {
assertAcquisitionReceipt(row, anonymousOnly);
+5 -1
View File
@@ -3,7 +3,7 @@ import { parseObject } from "../adapters/utils.js";
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
import { instanceSettingsService } from "./instance-settings.js";
import { taskWorkspaceSelectableCondition } from "./task-workspace-selection.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance } from "./execution-workspace-repositories.js";
import { executionWorkspaceRepositoryService, lockExecutionWorkspaceRootProvenance, preserveExecutionWorkspaceRootProvenance, assertExecutionWorkspaceRootWriteAccess } from "./execution-workspace-repositories.js";
import { forbidden } from "../errors.js";
import { taskWorkspaceSelectionSchema, type TaskWorkspaceSelection, type TaskWorkspaceIntent } from "@paperclipai/shared";
import { authorizationService, executionWorkspaceReadSqlCondition, projectReadSqlCondition, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js";
@@ -1412,6 +1412,10 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
));
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId });
if (workspace.cwd) await assertExecutionWorkspaceRootWriteAccess(reader, {
companyId: input.companyId, cwd: workspace.cwd, sourceWorkspaceId: workspace.id, actor: input.actor,
issueId: input.issueId, parentIssueId: input.parentIssueId, assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
});
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
}