fix: retain task context in shared source authorization

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-10 09:09:33 -05:00
1 parent 1b41e74faa
commit ab45588d13
6 files changed
+30 -18

No files matched your search

@@ -301,9 +301,16 @@ const support = await getEmbeddedPostgresTestSupport();
await expect(tasks.create(f.companyId, { title: "Denied legacy binding", createdByAgentId: agent.id,
executionWorkspaceId: shared.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/);
const [organization] = await db.insert(projects).values({ companyId: f.companyId, name: "Task organization" }).returning();
await db.update(issues).set({ projectId: organization.id }).where(eq(issues.id, f.issueId));
const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "agent",
principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: agent.id } }).returning();
principalId: agent.id, permissionKey: "tasks:assign_scope", scope: { projectId: organization.id, assigneeAgentId: agent.id } }).returning();
const selectionRequest = { ...f, actor, selection: sharedSelection, expectedBindingRevision: 0, requestKey: "authorized-shared" };
// Task lineage and organizational-project authority cannot grant writes
// to an independently protected source project's folder.
await expect(svc.selectTaskWorkspace(selectionRequest)).rejects.toThrow(/protected/);
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: agent.id } })
.where(eq(principalPermissionGrants.id, grant.id));
await expect(svc.selectTaskWorkspace(selectionRequest)).resolves.toMatchObject({ kind: "scheduled" });
expect(await tasks.create(f.companyId, { title: "Authorized shared source", createdByAgentId: agent.id,
workspaceSelection: sharedSelection, workspaceSelectionActor: actor })).toMatchObject({ projectId: null, projectWorkspaceId: source.id });
@@ -76,7 +76,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
// Serialize receipts across tasks without blocking unchanged-ID foreign-key reads.
const [workspace] = await tx.select().from(executionWorkspaces).where(and(eq(executionWorkspaces.id, issue.executionWorkspaceId), eq(executionWorkspaces.companyId, input.companyId))).for("no key update");
if (!workspace || workspace.status !== "active") throw conflict("Execution workspace is unavailable");
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true });
await assertTaskWorkspaceAccess(tx, input.actor, input.companyId, workspace.id, { write: true, 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) assertRepositoryRetryMatches(row, source.repositoryIdentity, requestedRef);
if (existing[0]) {
@@ -99,7 +99,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
/** Logical rows may deliberately name one physical folder. Reuse bytes only
* after validating every published owner's retained privacy and source intent. */
async function sharedPublicationPin(input: { row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) {
async function sharedPublicationPin(input: { issueId: string; row: RepositoryRow; root: string; actor: AuthorizationActor; ownership: { repositoryId?: string; pinnedCommit?: string } | null }) {
const candidates = await db.select({ repository: executionWorkspaceRepositories, workspace: executionWorkspaces })
.from(executionWorkspaceRepositories).innerJoin(executionWorkspaces, eq(executionWorkspaces.id, executionWorkspaceRepositories.executionWorkspaceId))
.where(and(eq(executionWorkspaceRepositories.companyId, input.row.companyId), eq(executionWorkspaces.companyId, input.row.companyId),
@@ -113,7 +113,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
if (await fs.realpath(workspace.cwd).catch(() => null) !== input.root) continue;
assertRepositoryRetryMatches(repository, input.row.repositoryIdentity, input.row.requestedRef);
if (!(await canActorReadExecutionWorkspace(db, input.actor, workspace.id))) throw forbidden("The shared repository retains files from a workspace outside this actor's access");
await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true });
await assertTaskWorkspaceAccess(db, input.actor, input.row.companyId, workspace.id, { write: true, issueId: input.issueId });
if (pin && pin !== candidatePin) throw conflict("Shared repository publication receipts disagree; repair is required before reuse");
pin = candidatePin;
}
@@ -129,7 +129,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
// local-board is a server identity, not a membership record. Preserve the
// agent's own authority rather than promoting it to a board actor.
onBehalfOfUserId: input.responsibleUserId === "local-board" ? null : input.responsibleUserId };
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true });
await assertTaskWorkspaceAccess(db, actor, input.companyId, input.workspaceId, { write: true, issueId: input.issueId });
const root = await fs.realpath(input.cwd);
const repositoryRoot = path.join(root, ".paperclip-repositories");
await fs.mkdir(repositoryRoot, { recursive: true });
@@ -194,7 +194,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
} catch { return null; } finally { await handle.close(); }
};
const initialOwnership = await readOwnership();
const authorizedPin = await sharedPublicationPin({ row, root, actor, ownership: initialOwnership });
const authorizedPin = await sharedPublicationPin({ issueId: input.issueId, row, root, actor, ownership: initialOwnership });
if (existing && !authorizedPin && initialOwnership?.repositoryId !== row.id) {
throw conflict("Repository directory has no matching preparation receipt; repair it before retrying");
}
+3 -3
View File
@@ -1288,7 +1288,7 @@ const inspectGitForDisplay = createWorkspaceGitInspectionCache(inspectGitCloseRe
export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServiceOptions = {}) {
/** Validate intent without allocating files or changing a task binding. */
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection }, reader: Db | DbTransaction = db) {
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null }, reader: Db | DbTransaction = db) {
const selection = taskWorkspaceSelectionSchema.parse(input.selection);
if (selection.kind === "existing") {
const [workspace] = await reader.select().from(executionWorkspaces).where(and(
@@ -1297,7 +1297,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
await executionWorkspaceReadSqlCondition(reader, input.actor),
));
if (!workspace) throw notFound("Workspace is unavailable or inaccessible");
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true });
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, issueId: input.issueId, parentIssueId: input.parentIssueId });
return { executionWorkspaceId: workspace.id, projectWorkspaceId: workspace.projectWorkspaceId,
executionWorkspacePreference: "reuse_existing", executionWorkspaceSettings: { mode: workspace.mode } };
}
@@ -1308,7 +1308,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic
await projectReadSqlCondition(reader, input.actor),
));
if (!source) throw notFound("Workspace source is unavailable or inaccessible");
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared" });
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, { write: selection.mode === "shared", issueId: input.issueId, parentIssueId: input.parentIssueId });
return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null,
executionWorkspaceSettings: { mode: selection.mode === "shared" ? "shared_workspace" : "isolated_workspace" } };
}
+3 -3
View File
@@ -3205,10 +3205,10 @@ export function heartbeatService(
if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace;
if (workspaceRequiringAccess) {
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true });
await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId });
} else if (!isDotRun && executionProjectId && (selectedWorkspaceSource || projectContext?.hasWorkspace)) {
// Check source authority before resolution can clone or expose files.
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace" });
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: requestedExecutionWorkspaceMode === "shared_workspace", issueId });
}
}
const requestedReusableExecutionWorkspaceConfig =
@@ -3483,7 +3483,7 @@ export function heartbeatService(
if (taskWorkspaceIntentRow?.intent && !boundSourceWorkspace && !nativeRecoveryExecutionWorkspaceId) {
await executionWorkspacesSvc.validateSelection({ companyId: agent.companyId,
actor: { type: "agent", agentId: agent.id, companyId: agent.companyId, runId: run.id, source: "agent_jwt", onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId },
selection: taskWorkspaceIntentRow.intent.selection });
selection: taskWorkspaceIntentRow.intent.selection, issueId });
}
const explicitTaskDirectory = taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory";
const useIsolatedTaskDirectory = issueRef !== null && shouldUseIsolatedTaskDirectory({
+3 -2
View File
@@ -10166,7 +10166,7 @@ export function issueService(db: Db) {
if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) {
const projection = await executionWorkspaceService(db).validateSelection({ companyId,
actor: workspaceActor,
selection: workspaceSelection }, tx);
selection: workspaceSelection, parentIssueId: issueData.parentId }, tx);
projectWorkspaceId = projection.projectWorkspaceId;
executionWorkspaceId = projection.executionWorkspaceId;
executionWorkspacePreference = projection.executionWorkspacePreference;
@@ -10266,6 +10266,7 @@ export function issueService(db: Db) {
.where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor)));
if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible");
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, {
parentIssueId: issueData.parentId,
write: !executionWorkspaceId && resolveExecutionWorkspaceMode({
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled),
issueSettings: parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings),
@@ -10277,7 +10278,7 @@ export function issueService(db: Db) {
const [readableWorkspace] = await tx.select({ id: executionWorkspaces.id, projectId: executionWorkspaces.projectId }).from(executionWorkspaces)
.where(and(eq(executionWorkspaces.id, executionWorkspaceId), eq(executionWorkspaces.companyId, companyId), await executionWorkspaceReadSqlCondition(tx, workspaceActor)));
if (!readableWorkspace) throw notFound("Workspace is unavailable or inaccessible");
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true });
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId });
}
if (projectWorkspaceId) {
await assertValidProjectWorkspace(
@@ -5,10 +5,11 @@ import { accessService } from "./access.js";
import { executionWorkspaceReadSqlCondition, type AuthorizationActor } from "./authorization.js";
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null };
/** Filesystem source scope is independent of a task's organizational project. */
export async function assertTaskWorkspaceSourceProjectAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: { write?: boolean } = {},
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, projectId: string | null, options: WorkspaceAccessContext = {},
) {
if (!projectId) return;
const decision = await accessService(db as Db).decide({
@@ -21,7 +22,10 @@ export async function assertTaskWorkspaceSourceProjectAccess(
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : null;
const assignment = await accessService(db as Db).decide({
actor, action: "tasks:assign",
resource: { type: "issue", companyId, projectId, assigneeAgentId },
resource: { type: "issue", companyId, projectId, assigneeAgentId,
issueId: options.issueId, parentIssueId: options.parentIssueId },
// Lineage proves low-trust containment; grants must still cover the source
// project independently of the task's organizational project.
scope: { projectId, assigneeAgentId },
});
if (!assignment.allowed) throw forbidden(assignment.explanation);
@@ -30,14 +34,14 @@ export async function assertTaskWorkspaceSourceProjectAccess(
/** Retained files keep every task's authorization boundary after rebinding. */
export async function assertTaskWorkspaceAccess(
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: { write?: boolean } = {},
db: Db | DbTransaction, actor: AuthorizationActor, companyId: string, workspaceId: string, options: WorkspaceAccessContext = {},
) {
const [workspace] = await db.select().from(executionWorkspaces).where(and(
eq(executionWorkspaces.id, workspaceId), eq(executionWorkspaces.companyId, companyId),
await executionWorkspaceReadSqlCondition(db, actor),
));
if (!workspace) throw forbidden("Task workspace access is no longer available");
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { write: options.write && workspace.mode === "shared_workspace" });
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.mode === "shared_workspace" });
const retainedIssueIds = Object.keys(workspace.metadata?._issuePrivacySources ?? {});
if (workspace.sourceIssueId) retainedIssueIds.push(workspace.sourceIssueId);
const sources = await db.select({ id: issues.id }).from(issues).where(and(