mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
fix: authorize actual workspace sharing and assigned board targets
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
ab45588d13
commit
4d7f0c8fe6
7 files changed
+132
-33
No files matched your search
@@ -35,7 +35,7 @@ Explicit invalid/unauthorized choices fail with actionable errors, never fall ba
|
||||
|
||||
Typed selection vocabulary: task_directory; existing execution workspace; configured project source with shared or managed-isolated policy. Operator cwd compatibility is internal and cannot be submitted by an ordinary agent. New repository paths use existing `.paperclip-repositories/<stable-key>` namespace; existing root checkouts retain their paths.
|
||||
|
||||
Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the existing source-project task-assignment authority. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs.
|
||||
Source inspection and isolated copies require source-project read access. Writable shared roots additionally require the existing source-project task-assignment authority. The resolved strategy determines sharing: only `git_worktree` creates a separate checkout; an isolated mode label does not exempt a source-root strategy. Board requests use the task's actual assignee when checking scoped grants. Check that authority at selection, task creation, repository mutation, and each new admission; revocation blocks future use without changing already-admitted recovery inputs.
|
||||
|
||||
Channel defaults use optional properties (inherit), explicit null (clear), concrete value (override). Resource override beats endpoint. Defaults are captured once at task creation and reauthorized on use. Changing/deleting/revoking configuration cannot relocate existing tasks. Project choice and workspace choice are independent; choosing a source does not silently assign organizational project. Endpoints without configuration work normally.
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ suite("task project repository provisioning", () => {
|
||||
}
|
||||
}, 40_000);
|
||||
|
||||
it("blocks shared source adapter admission after its project assignment grant is revoked while read access remains", async () => {
|
||||
it.each(["shared_workspace", "isolated_workspace"] as const)("blocks shared source adapter admission after grant revocation with retained mode %s", async (retainedMode) => {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "protected-shared-source");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
@@ -256,6 +256,11 @@ suite("task project repository provisioning", () => {
|
||||
const [bound] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(bound.projectId).toBeNull();
|
||||
expect(bound.executionWorkspaceId).not.toBeNull();
|
||||
if (retainedMode === "isolated_workspace") {
|
||||
// Legacy or malformed metadata can label the shared physical root isolated.
|
||||
await db.update(executionWorkspaces).set({ mode: retainedMode }).where(eq(executionWorkspaces.id, bound.executionWorkspaceId!));
|
||||
await db.update(issues).set({ executionWorkspaceSettings: { mode: retainedMode, workspaceStrategy: { type: "project_primary" } } }).where(eq(issues.id, issueId));
|
||||
}
|
||||
await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id));
|
||||
const actor = { type: "agent" as const, agentId, companyId, source: "agent_jwt" as const, onBehalfOfUserId: "responsible-user" };
|
||||
expect(await accessService(db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId } })).toMatchObject({ allowed: true });
|
||||
@@ -271,6 +276,50 @@ suite("task project repository provisioning", () => {
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected project files");
|
||||
}, 40_000);
|
||||
|
||||
it.each(["project_primary", "adapter_managed", "cloud_sandbox", "git_worktree"] as const)("authorizes isolated source admission by its actual strategy: %s", async (strategy) => {
|
||||
const companyId = randomUUID(), projectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
const cwd = path.join(root, companyId, "protected-source");
|
||||
await mkdir(cwd, { recursive: true });
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, stdio: "ignore" });
|
||||
git("init", "-b", "main");
|
||||
await writeFile(path.join(cwd, "work.txt"), "Protected source content");
|
||||
git("add", ".");
|
||||
git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "seed");
|
||||
await db.insert(companies).values({ id: companyId, name: "Strategy authorization", issuePrefix: `S${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: "Protected source", executionWorkspacePolicy: {
|
||||
authorizationPolicy: { assignmentPolicy: { mode: "protected" } },
|
||||
} });
|
||||
await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId, name: "Source", sourceType: "local_path", cwd, isPrimary: true });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Reader", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} });
|
||||
await db.insert(issues).values({ id: issueId, companyId, title: "Use an isolated source", status: "todo", assigneeAgentId: agentId,
|
||||
projectWorkspaceId: sourceId, executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: strategy } },
|
||||
workspaceSelection: { version: 1, source: "explicit", selection: { kind: "configured_source", projectWorkspaceId: sourceId, mode: "managed_isolated" } } });
|
||||
const actor = { type: "agent" as const, agentId, companyId, source: "agent_jwt" as const, onBehalfOfUserId: "responsible-user" };
|
||||
expect(await accessService(db).decide({ actor, action: "project:read", resource: { type: "project", companyId, projectId } })).toMatchObject({ allowed: true });
|
||||
expect(await accessService(db).decide({ actor, action: "tasks:assign", resource: { type: "issue", companyId, projectId, assigneeAgentId: agentId },
|
||||
scope: { projectId, assigneeAgentId: agentId } })).toMatchObject({ allowed: false });
|
||||
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);
|
||||
const result = await heartbeat.getRun(run!.id);
|
||||
const calls = execute.mock.calls.filter(([input]) => input.runId === run!.id);
|
||||
if (strategy !== "git_worktree") {
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.error).toContain("Target project is protected and requires an explicit assignment grant");
|
||||
expect(calls).toHaveLength(0);
|
||||
} else {
|
||||
expect(result).toMatchObject({ status: "succeeded", error: null });
|
||||
expect(calls).toHaveLength(1);
|
||||
const workspace = calls[0]![0].context.paperclipWorkspace;
|
||||
expect(workspace.strategy).toBe("git_worktree");
|
||||
expect(await realpath(workspace.cwd)).not.toBe(await realpath(cwd));
|
||||
expect(await readFile(path.join(workspace.cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
}
|
||||
expect(await readFile(path.join(cwd, "work.txt"), "utf8")).toBe("Protected source content");
|
||||
}, 30_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");
|
||||
|
||||
@@ -243,8 +243,23 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
app.use((req, _res, next) => { req.actor = actor; next(); });
|
||||
app.use("/api", taskWorkspaceRoutes(db));
|
||||
app.use(errorHandler);
|
||||
const selection = { selection: { kind: "task_directory" as const }, expectedBindingRevision: 1, requestKey: "member-selection" };
|
||||
const [assignee] = await db.insert(agents).values({ companyId: f.companyId, name: "Assigned worker", status: "idle" }).returning();
|
||||
const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected files",
|
||||
executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning();
|
||||
const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id,
|
||||
name: "Source", cwd: `/tmp/task-${f.issueId}` }).returning();
|
||||
await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId));
|
||||
await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId));
|
||||
const selection = { selection: { kind: "existing" as const, workspaceId: f.workspaceId }, expectedBindingRevision: 1, requestKey: "member-selection" };
|
||||
const repository = { repository: { kind: "url" as const, url: "https://github.com/public/example" }, requestKey: "member-repository" };
|
||||
expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection)).status).toBe(403);
|
||||
expect((await request(app).post(`/api/issues/${f.issueId}/workspace/repositories`).send(repository)).status).toBe(403);
|
||||
await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId,
|
||||
permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } });
|
||||
expect(await issueService(db).create(f.companyId, { title: "Board-assigned shared work", createdByUserId: userId,
|
||||
assigneeAgentId: assignee.id, workspaceSelectionActor: actor,
|
||||
workspaceSelection: { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" } }))
|
||||
.toMatchObject({ assigneeAgentId: assignee.id, projectWorkspaceId: source.id });
|
||||
const selected = await request(app).put(`/api/issues/${f.issueId}/workspace`).send(selection);
|
||||
expect(selected.status, JSON.stringify(selected.body)).toBe(200);
|
||||
expect(selected.body).toMatchObject({ kind: "scheduled" });
|
||||
@@ -301,6 +316,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/);
|
||||
|
||||
await db.update(executionWorkspaces).set({ mode: "isolated_workspace" }).where(eq(executionWorkspaces.id, shared.id));
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: { kind: "existing", workspaceId: shared.id } })).rejects.toThrow(/protected/);
|
||||
await expect(tasks.create(f.companyId, { title: "Mislabelled isolated source", createdByAgentId: agent.id,
|
||||
projectWorkspaceId: source.id, workspaceSelectionActor: actor,
|
||||
executionWorkspaceSettings: { mode: "isolated_workspace", workspaceStrategy: { type: "project_primary" } } })).rejects.toThrow(/protected/);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { enabled: true, defaultMode: "isolated_workspace",
|
||||
workspaceStrategy: { type: "project_primary" }, authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
|
||||
await expect(svc.validateSelection({ ...f, actor, selection: isolatedSelection })).rejects.toThrow(/protected/);
|
||||
await db.update(projects).set({ executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).where(eq(projects.id, project.id));
|
||||
|
||||
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",
|
||||
@@ -309,6 +334,11 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
// 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/);
|
||||
const [otherAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Another assignee", status: "idle" }).returning();
|
||||
await db.update(issues).set({ assigneeAgentId: otherAgent.id }).where(eq(issues.id, f.issueId));
|
||||
await db.update(principalPermissionGrants).set({ scope: { projectId: project.id, assigneeAgentId: otherAgent.id } })
|
||||
.where(eq(principalPermissionGrants.id, grant.id));
|
||||
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" });
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
normalizeIssueExecutionPolicy,
|
||||
parseIssueExecutionState,
|
||||
} from "./issue-execution-policy.js";
|
||||
import { parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js";
|
||||
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import {
|
||||
@@ -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; issueId?: string | null; parentIssueId?: string | null }, reader: Db | DbTransaction = db) {
|
||||
async function validateSelection(input: { companyId: string; actor: AuthorizationActor; selection: TaskWorkspaceSelection; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: 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,18 +1297,26 @@ 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, issueId: input.issueId, parentIssueId: input.parentIssueId });
|
||||
await assertTaskWorkspaceAccess(reader, input.actor, input.companyId, workspace.id, { write: true, 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 } };
|
||||
}
|
||||
if (selection.kind === "configured_source") {
|
||||
const [source] = await reader.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId }).from(projectWorkspaces)
|
||||
const [source] = await reader.select({ id: projectWorkspaces.id, projectId: projectWorkspaces.projectId, executionWorkspacePolicy: projects.executionWorkspacePolicy }).from(projectWorkspaces)
|
||||
.innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)).where(and(
|
||||
eq(projectWorkspaces.id, selection.projectWorkspaceId), eq(projectWorkspaces.companyId, input.companyId),
|
||||
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", issueId: input.issueId, parentIssueId: input.parentIssueId });
|
||||
const mode = selection.mode === "shared" ? "shared_workspace" : "isolated_workspace";
|
||||
const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {},
|
||||
projectPolicy: parseProjectExecutionWorkspacePolicy(source.executionWorkspacePolicy),
|
||||
issueSettings: { mode }, mode, legacyUseProjectWorkspace: null });
|
||||
await assertTaskWorkspaceSourceProjectAccess(reader, input.actor, input.companyId, source.projectId, {
|
||||
write: resolveEffectiveWorkspaceStrategyType(mode, sourceConfig) !== "git_worktree",
|
||||
issueId: input.issueId, parentIssueId: input.parentIssueId,
|
||||
assigneeAgentId: input.assigneeAgentId, assigneeUserId: input.assigneeUserId,
|
||||
});
|
||||
return { executionWorkspaceId: null, projectWorkspaceId: source.id, executionWorkspacePreference: null,
|
||||
executionWorkspaceSettings: { mode: selection.mode === "shared" ? "shared_workspace" : "isolated_workspace" } };
|
||||
}
|
||||
|
||||
@@ -3200,6 +3200,19 @@ export function heartbeatService(
|
||||
workspaceReuseRequest.existingExecutionWorkspaceAvailable
|
||||
? existingExecutionWorkspace
|
||||
: null;
|
||||
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: config,
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
legacyUseProjectWorkspace:
|
||||
issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||
});
|
||||
const sourceWorkspaceConfig = {
|
||||
...workspaceManagedConfig,
|
||||
...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))),
|
||||
...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}),
|
||||
};
|
||||
const workspaceAuthorizationActor = { type: "agent" as const, agentId: agent.id, companyId: agent.companyId,
|
||||
source: "agent_jwt" as const, runId: run.id, onBehalfOfUserId: responsibleUserId === "local-board" ? null : responsibleUserId };
|
||||
if (!persistedNativeExecutionInput && !nativeRecoveryExecutionWorkspaceId) {
|
||||
@@ -3208,7 +3221,7 @@ export function heartbeatService(
|
||||
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", issueId });
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) !== "git_worktree", issueId });
|
||||
}
|
||||
}
|
||||
const requestedReusableExecutionWorkspaceConfig =
|
||||
@@ -3499,18 +3512,8 @@ export function heartbeatService(
|
||||
issueExecutionWorkspaceSettings?.workspaceStrategy,
|
||||
],
|
||||
});
|
||||
const workspaceManagedConfig = buildExecutionWorkspaceAdapterConfig({
|
||||
agentConfig: config,
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
mode: requestedExecutionWorkspaceMode,
|
||||
legacyUseProjectWorkspace:
|
||||
issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||
});
|
||||
const mergedConfig = {
|
||||
...workspaceManagedConfig,
|
||||
...Object.fromEntries(Object.entries(issueAssigneeOverrides?.adapterConfig ?? {}).filter(([key]) => requestedAiBinding?.mode !== "router" || !["provider", "acpxAgent", "model", "modelReasoningEffort", "reasoningEffort", "effort", "variant"].includes(key))),
|
||||
...(requestedAiBinding?.mode === "router" ? parseObject(parseObject(context.aiRouterSelection).runtimeConfig) : {}),
|
||||
...sourceWorkspaceConfig,
|
||||
// The base below is already task-owned. Keep directory transport while
|
||||
// preserving isolated mode and the mandatory sandbox preflight.
|
||||
...((useIsolatedTaskDirectory || explicitTaskDirectory) ? { workspaceStrategy: { type: "project_primary" } } : {}),
|
||||
|
||||
@@ -122,6 +122,8 @@ import {
|
||||
SUCCESSFUL_RUN_HANDOFF_LIVE_WAKE_STATUSES,
|
||||
} from "./successful-run-handoff-state.js";
|
||||
import {
|
||||
buildExecutionWorkspaceAdapterConfig,
|
||||
resolveEffectiveWorkspaceStrategyType,
|
||||
defaultIssueExecutionWorkspaceSettingsForProject,
|
||||
gateProjectExecutionWorkspacePolicy,
|
||||
issueExecutionWorkspaceModeForPersistedWorkspace,
|
||||
@@ -10166,7 +10168,8 @@ export function issueService(db: Db) {
|
||||
if (workspaceSelection && (workspaceSelectionSource === "explicit" || !inheritedWorkspaceSelection)) {
|
||||
const projection = await executionWorkspaceService(db).validateSelection({ companyId,
|
||||
actor: workspaceActor,
|
||||
selection: workspaceSelection, parentIssueId: issueData.parentId }, tx);
|
||||
selection: workspaceSelection, parentIssueId: issueData.parentId,
|
||||
assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId }, tx);
|
||||
projectWorkspaceId = projection.projectWorkspaceId;
|
||||
executionWorkspaceId = projection.executionWorkspaceId;
|
||||
executionWorkspacePreference = projection.executionWorkspacePreference;
|
||||
@@ -10265,20 +10268,21 @@ export function issueService(db: Db) {
|
||||
.innerJoin(projects, eq(projects.id, projectWorkspaces.projectId))
|
||||
.where(and(eq(projectWorkspaces.id, projectWorkspaceId), eq(projectWorkspaces.companyId, companyId), await projectReadSqlCondition(tx, workspaceActor)));
|
||||
if (!readableSource) throw notFound("Workspace source is unavailable or inaccessible");
|
||||
const sourcePolicy = gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled);
|
||||
const sourceSettings = parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings);
|
||||
const sourceMode = resolveExecutionWorkspaceMode({ projectPolicy: sourcePolicy, issueSettings: sourceSettings, legacyUseProjectWorkspace: null });
|
||||
const sourceConfig = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy: sourcePolicy,
|
||||
issueSettings: sourceSettings, mode: sourceMode, legacyUseProjectWorkspace: null });
|
||||
await assertTaskWorkspaceSourceProjectAccess(tx, workspaceActor, companyId, readableSource.projectId, {
|
||||
parentIssueId: issueData.parentId,
|
||||
write: !executionWorkspaceId && resolveExecutionWorkspaceMode({
|
||||
projectPolicy: gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(readableSource.executionWorkspacePolicy), isolatedWorkspacesEnabled),
|
||||
issueSettings: parseIssueExecutionWorkspaceSettings(executionWorkspaceSettings),
|
||||
legacyUseProjectWorkspace: null,
|
||||
}) === "shared_workspace",
|
||||
parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId,
|
||||
write: !executionWorkspaceId && resolveEffectiveWorkspaceStrategyType(sourceMode, sourceConfig) !== "git_worktree",
|
||||
});
|
||||
}
|
||||
if (executionWorkspaceId && workspaceSelectionActor) {
|
||||
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, parentIssueId: issueData.parentId });
|
||||
await assertTaskWorkspaceAccess(tx, workspaceActor, companyId, readableWorkspace.id, { write: true, parentIssueId: issueData.parentId, assigneeAgentId: issueData.assigneeAgentId, assigneeUserId: issueData.assigneeUserId });
|
||||
}
|
||||
if (projectWorkspaceId) {
|
||||
await assertValidProjectWorkspace(
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 };
|
||||
type WorkspaceAccessContext = { write?: boolean; issueId?: string | null; parentIssueId?: string | null; assigneeAgentId?: string | null; assigneeUserId?: string | null };
|
||||
|
||||
/** Filesystem source scope is independent of a task's organizational project. */
|
||||
export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
@@ -19,14 +19,19 @@ export async function assertTaskWorkspaceSourceProjectAccess(
|
||||
if (options.write) {
|
||||
// Sharing a configured root gives the task write access to project files.
|
||||
// Use the same project-scoped authority as assigning work to that project.
|
||||
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : null;
|
||||
const [task] = actor.type === "board" && options.issueId
|
||||
? await db.select({ assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId }).from(issues)
|
||||
.where(and(eq(issues.id, options.issueId), eq(issues.companyId, companyId)))
|
||||
: [];
|
||||
const assigneeAgentId = actor.type === "agent" ? actor.agentId ?? null : task?.assigneeAgentId ?? options.assigneeAgentId ?? null;
|
||||
const assigneeUserId = actor.type === "agent" ? null : task?.assigneeUserId ?? options.assigneeUserId ?? 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, assigneeUserId,
|
||||
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 },
|
||||
scope: { projectId, assigneeAgentId, assigneeUserId },
|
||||
});
|
||||
if (!assignment.allowed) throw forbidden(assignment.explanation);
|
||||
}
|
||||
@@ -41,7 +46,7 @@ export async function assertTaskWorkspaceAccess(
|
||||
await executionWorkspaceReadSqlCondition(db, actor),
|
||||
));
|
||||
if (!workspace) throw forbidden("Task workspace access is no longer available");
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.mode === "shared_workspace" });
|
||||
await assertTaskWorkspaceSourceProjectAccess(db, actor, companyId, workspace.projectId, { ...options, write: options.write && workspace.strategyType !== "git_worktree" });
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user