From 0049e01678ace2d570853b2e55ba78f8800d96cd Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 10:06:06 -0500 Subject: [PATCH] fix: preserve task policy and authorize channel workspace admission Keep task project environment and trust separate from workspace source configuration. Authorize channel creation against locked endpoint defaults, realize first native chat worktrees safely, and enforce source authorization on legacy PATCH. Co-Authored-By: Paperclip --- doc/plans/2026-10-09-task-workspaces.md | 4 ++ .../chat-channels.integration.test.ts | 69 +++++++++++++++++++ .../email-channels.integration.test.ts | 44 ++++++++++++ .../heartbeat-process-recovery.test.ts | 41 +++++++++++ .../heartbeat-project-repositories.test.ts | 56 +++++++++++++++ .../__tests__/task-workspace-binding.test.ts | 47 +++++++++++++ .../__tests__/trust-preset-resolver.test.ts | 13 ++++ server/src/routes/issues.ts | 1 + server/src/services/chat-channels.ts | 34 ++++----- .../src/services/chat-execution-defaults.ts | 34 ++++++++- server/src/services/email-channels.ts | 42 +++++------ server/src/services/execution-workspaces.ts | 27 +++++++- server/src/services/heartbeat.ts | 64 +++++++++++------ server/src/services/issues.ts | 10 +++ server/src/services/trust-preset-resolver.ts | 12 +++- 15 files changed, 434 insertions(+), 64 deletions(-) diff --git a/doc/plans/2026-10-09-task-workspaces.md b/doc/plans/2026-10-09-task-workspaces.md index f5e6f5cf212..f83acf17dcf 100644 --- a/doc/plans/2026-10-09-task-workspaces.md +++ b/doc/plans/2026-10-09-task-workspaces.md @@ -39,6 +39,10 @@ Source inspection and isolated copies require source-project read access. Writab 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. +The organizational task project retains task identity, environment, and secret scope regardless of the filesystem source. Both task-project and source-project policies constrain run trust; source policy controls workspace realization. Channel task creation separately checks assignment authority for the effective organizational project and actual assignee against locked endpoint values inside its transaction. Saving defaults does not grant future senders that authority. + +New native chat tasks may realize an authorized managed-isolated Git worktree before their first binding exists. The resulting durable task-owned worktree must pass the existing chat-root guard before provider dispatch; shared roots and mismatched admitted recovery roots remain rejected. + Active root selection stores pending intent and returns next-normal-admission, without interrupting or scheduling a new model turn. If atomic safe nested acquisition/capture is supported, repository preparation may add a new contained subtree to the active root; otherwise it records preparation for next admission with explicit capability result. Never claim a host-only checkout is available in the running remote environment. ## Sharing and durability diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 92d0d5898f2..89e13be69d7 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -7600,6 +7600,75 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect((await service.get(endpoint.id)).executionDefaults).toEqual({ workspace: { kind: "task_directory" } }); }); + it("checks both linked sender and agent assignment grants for protected organizational defaults", async () => { + const fixture = await seedCompany(); + const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true }); + const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected task project", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + const defaults = { projectId: project.id, workspace: { kind: "task_directory" as const } }; + // Saving readable defaults does not grant future senders assignment authority. + await service.update(endpoint.id, { executionDefaults: defaults }, "owner-user"); + await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", + principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" }); + const send = async (id: string) => { + const thread = makeThread({ channelId: "C-ASSIGNMENT", id: `slack:C-ASSIGNMENT:${id}`, name: "assignment" }); + await deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id, text: "@maya create a report", mentioned: true, userId: "UBOARD" }), trigger: "mention" }); + }; + const tasks = () => db.select().from(issues).where(eq(issues.companyId, fixture.companyId)); + await expect(send("8310.1")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") }); + expect(await tasks()).toHaveLength(0); + expect(wakeup).not.toHaveBeenCalled(); + const [senderGrant] = await db.insert(principalPermissionGrants).values({ companyId: fixture.companyId, + principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } }).returning(); + await expect(send("8310.2")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") }); + expect(await tasks()).toHaveLength(0); + expect(wakeup).not.toHaveBeenCalled(); + await db.insert(principalPermissionGrants).values({ companyId: fixture.companyId, + principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } }); + await send("8310.3"); + expect(await tasks()).toEqual([expect.objectContaining({ projectId: project.id, + assigneeAgentId: fixture.assignedAgentId, + workspaceSelection: { version: 1, selection: { kind: "task_directory" }, source: "channel" } })]); + expect(wakeup).toHaveBeenCalledTimes(1); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, senderGrant.id)); + await expect(send("8310.4")).rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") }); + expect(await tasks()).toHaveLength(1); + expect(wakeup).toHaveBeenCalledTimes(1); + expect(await db.select().from(chatConversations).where(eq(chatConversations.endpointId, endpoint.id))).toHaveLength(1); + }); + + it("checks protected organizational defaults against the assignee locked at creation", async () => { + const fixture = await seedCompany(); + let endpointId = ""; + const { callbacks, endpoint, service, wakeup } = await configuredSlackEndpoint(fixture, { + linkedBoardUser: true, + reachAuthorizationBarrier: async () => { + await db.update(chatEndpoints).set({ assignedAgentId: fixture.replacementAgentId }).where(eq(chatEndpoints.id, endpointId)); + }, + }); + endpointId = endpoint.id; + const [project] = await db.insert(projects).values({ companyId: fixture.companyId, name: "Protected reassignment", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + await service.update(endpoint.id, { executionDefaults: { projectId: project.id, workspace: { kind: "task_directory" } } }, "owner-user"); + await db.insert(companyMemberships).values({ companyId: fixture.companyId, principalType: "agent", + principalId: fixture.assignedAgentId, status: "active", membershipRole: "member" }); + await db.insert(principalPermissionGrants).values([ + { companyId: fixture.companyId, principalType: "user", principalId: "owner-user", permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } }, + { companyId: fixture.companyId, principalType: "agent", principalId: fixture.assignedAgentId, permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: fixture.assignedAgentId } }, + ]); + const thread = makeThread({ channelId: "C-REASSIGNED", id: "slack:C-REASSIGNED:8320.1", name: "reassigned" }); + await expect(deliverMessage({ callbacks, endpointId: endpoint.id, thread: thread.thread, + message: makeMessage({ id: "8320.1", text: "@maya create a report", mentioned: true, userId: "UBOARD" }), trigger: "mention" })) + .rejects.toMatchObject({ status: 403, message: expect.stringContaining("protected") }); + expect(await db.select().from(issues).where(eq(issues.companyId, fixture.companyId))).toHaveLength(0); + expect(wakeup).not.toHaveBeenCalled(); + }); + it("authorizes protected channel workspace defaults for the endpoint's assigned agent", async () => { const fixture = await seedCompany(); const { callbacks, endpoint, service } = await configuredSlackEndpoint(fixture, { linkedBoardUser: true }); diff --git a/server/src/__tests__/email-channels.integration.test.ts b/server/src/__tests__/email-channels.integration.test.ts index 7e9d0bcce3e..4e47c590e82 100644 --- a/server/src/__tests__/email-channels.integration.test.ts +++ b/server/src/__tests__/email-channels.integration.test.ts @@ -491,6 +491,50 @@ describe("AgentMail durable email pipeline", () => { expect(await tasks()).toEqual(expect.arrayContaining([expect.objectContaining({ workspaceSelection: null })])); }); + it("checks sponsor and agent assignment grants for protected email organizational defaults", async () => { + const f = await fixture(); + const [project] = await db.insert(projects).values({ companyId: f.companyId, name: "Protected email tasks", + executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }).returning(); + // Change defaults after provider fetch has captured the endpoint, before the + // creation transaction locks it. Only the locked defaults are authoritative. + const fetcher = vi.mocked(f.fetcher); + const fetch = fetcher.getMockImplementation()!; + let changedDefaults = false; + fetcher.mockImplementation(async (...args) => { + if (!changedDefaults && String(args[0]).includes("/threads/")) { + changedDefaults = true; + await db.update(chatEndpoints).set({ executionDefaults: { projectId: project.id, workspace: { kind: "task_directory" } } }) + .where(eq(chatEndpoints.id, f.endpointId)); + } + return fetch(...args); + }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "agent", + principalId: f.agentId, status: "active", membershipRole: "member" }); + const tasks = () => db.select().from(issues).where(eq(issues.companyId, f.companyId)); + await f.receive(f.message()); + expect(await tasks()).toHaveLength(0); + expect(f.wakeup).not.toHaveBeenCalled(); + const [sponsorGrant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, + principalType: "user", principalId: "email-board", permissionKey: "tasks:assign_scope", + scope: { projectId: project.id, assigneeAgentId: f.agentId } }).returning(); + await f.receive(f.message()); + expect(await tasks()).toHaveLength(0); + expect(f.wakeup).not.toHaveBeenCalled(); + 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 f.receive(f.message()); + expect(await tasks()).toEqual([expect.objectContaining({ projectId: project.id, + assigneeAgentId: f.agentId, + workspaceSelection: { version: 1, selection: { kind: "task_directory" }, source: "channel" } })]); + expect(f.wakeup).toHaveBeenCalledTimes(1); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, sponsorGrant.id)); + await f.receive(f.message()); + expect(await tasks()).toHaveLength(1); + expect(f.wakeup).toHaveBeenCalledTimes(1); + expect(await db.select().from(chatConversations).where(eq(chatConversations.endpointId, f.endpointId))).toHaveLength(1); + }); + it("deduplicates events/messages, keeps identical subjects separate, and never grants sender board identity", async () => { const f = await fixture(); const m = f.message(); diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 84b8e0314d4..fb3497f3cf1 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -3394,6 +3394,47 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); }); + it.each(["managed_isolated", "shared"] as const)("prepares a fresh native chat configured source safely: %s", async (mode) => { + await withTempPaperclipHome(async () => { + const { companyId, agentId, issueId, runId } = await seedQueuedIssueRunFixture(); + const instanceRoot = resolvePaperclipInstanceRoot(); + await fs.mkdir(instanceRoot, { recursive: true }); + const cwd = path.join(instanceRoot, "source-repository"); + await fs.mkdir(cwd, { recursive: true }); + execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" }); + await fs.writeFile(path.join(cwd, "report.txt"), "source bytes"); + execFileSync("git", ["add", "report.txt"], { cwd, stdio: "ignore" }); + execFileSync("git", ["-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "Source"], { cwd, stdio: "ignore" }); + const [project] = await db.insert(projects).values({ companyId, name: "Chat source" }).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId, projectId: project.id, name: "Source", cwd, isPrimary: true }).returning(); + await db.update(agents).set({ adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "gpt-5.6-luna" } }).where(eq(agents.id, agentId)); + await db.update(issues).set({ originKind: "chat_channel", projectWorkspaceId: source.id, + workspaceSelection: { version: 1, source: "channel", selection: { kind: "configured_source", projectWorkspaceId: source.id, mode } }, + executionWorkspaceSettings: { mode: mode === "managed_isolated" ? "isolated_workspace" : "shared_workspace" }, + }).where(eq(issues.id, issueId)); + await db.update(heartbeatRuns).set({ runtimeMode: "native", runtimeModeResolvedAt: new Date(), nativeIssueId: issueId }).where(eq(heartbeatRuns.id, runId)); + const nativeSessionBackendFactory = vi.fn((_execution: { workspace: { cwd: string } }) => { throw new NativeRunnerOwnershipUnverifiedError(); }); + const heartbeat = heartbeatService(db, { nativeSessionBackendFactory }); + await heartbeat.resumeQueuedRuns(); + await waitForValue(async () => nativeSessionBackendFactory.mock.calls.length > 0 || Boolean((await heartbeat.getRun(runId))?.errorCode), 15_000); + await heartbeat.waitForRunExecutionDrain(runId); + if (mode === "shared") { + expect(nativeSessionBackendFactory).not.toHaveBeenCalled(); + expect((await heartbeat.getRun(runId))?.error).toContain("External chat requires a task-owned isolated workspace"); + } else { + expect(nativeSessionBackendFactory).toHaveBeenCalledTimes(1); + const input = nativeSessionBackendFactory.mock.calls[0]![0]; + expect(await fs.realpath(input.workspace.cwd)).not.toBe(await fs.realpath(cwd)); + expect(await fs.readFile(path.join(input.workspace.cwd, "report.txt"), "utf8")).toBe("source bytes"); + const [task] = await db.select().from(issues).where(eq(issues.id, issueId)); + const [workspace] = await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, task.executionWorkspaceId!)); + expect(workspace).toMatchObject({ sourceIssueId: issueId, projectId: project.id, mode: "isolated_workspace", strategyType: "git_worktree", cwd: input.workspace.cwd, providerRef: input.workspace.cwd }); + } + expect(await fs.readFile(path.join(cwd, "report.txt"), "utf8")).toBe("source bytes"); + expect(mockAdapterExecute).not.toHaveBeenCalled(); + }); + }); + it("carries the ordinary initial description provenance through native heartbeat assembly", async () => { await withTempPaperclipHome(async () => { const { agentId, issueId, runId } = await seedQueuedIssueRunFixture(); diff --git a/server/src/__tests__/heartbeat-project-repositories.test.ts b/server/src/__tests__/heartbeat-project-repositories.test.ts index 3207f9bdda6..b60a1984930 100644 --- a/server/src/__tests__/heartbeat-project-repositories.test.ts +++ b/server/src/__tests__/heartbeat-project-repositories.test.ts @@ -223,6 +223,62 @@ suite("task project repository provisioning", () => { } }, 40_000); + it.each(["task_directory", "configured_source"] as const)("keeps organizational environment separate from %s files", async (kind) => { + const companyId = randomUUID(), projectId = randomUUID(), sourceProjectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const cwd = path.join(root, companyId, "source"); + await mkdir(cwd, { recursive: true }); + await db.insert(companies).values({ id: companyId, name: "Environment ownership", issuePrefix: `V${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: "Task project", env: { PROJECT_VALUE: "task", TASK_ONLY: "present" } }, + { id: sourceProjectId, companyId, name: "File source", env: { PROJECT_VALUE: "source", SOURCE_ONLY: "must-not-leak" } }, + ]); + await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId: sourceProjectId, name: "Source", cwd, isPrimary: true }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} }); + await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Use task environment", status: "todo", assigneeAgentId: agentId, executionWorkspaceSettings: { mode: "shared_workspace" }, + projectWorkspaceId: kind === "configured_source" ? sourceId : null, + workspaceSelection: { version: 1, source: "explicit", selection: kind === "configured_source" + ? { kind, projectWorkspaceId: sourceId, mode: "shared" } : { kind } }, + }); + const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } }); + await vi.waitFor(async () => { expect(await heartbeat.getRun(run!.id)).toMatchObject({ status: "succeeded", error: null }); }, { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const call = execute.mock.calls.find(([input]) => input.runId === run!.id)![0]; + expect(call.config.env).toMatchObject({ PROJECT_VALUE: "task", TASK_ONLY: "present" }); + expect(call.config.env.SOURCE_ONLY).toBeUndefined(); + expect(call.context.projectId).toBe(projectId); + if (kind === "configured_source") expect(await realpath(call.context.paperclipWorkspace.cwd)).toBe(await realpath(cwd)); + }, 25_000); + + it.each([ + { policy: "organization", selection: "task_directory" }, + { policy: "organization", selection: "configured_source" }, + { policy: "source", selection: "configured_source" }, + ] as const)("retains $policy low-trust restrictions with $selection", async ({ policy, selection }) => { + const companyId = randomUUID(), projectId = randomUUID(), sourceProjectId = randomUUID(), sourceId = randomUUID(), agentId = randomUUID(), issueId = randomUUID(); + const cwd = path.join(root, companyId, "source"); + await mkdir(cwd, { recursive: true }); + await db.insert(companies).values({ id: companyId, name: "Policy ownership", issuePrefix: `L${companyId.slice(0, 6)}`, defaultResponsibleUserId: "responsible-user" }); + await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "responsible-user", status: "active", membershipRole: "owner" }); + const restricted = { authorizationPolicy: { trustPreset: "low_trust_review", trustBoundary: { mode: "low_trust_review", companyId, issueIds: [issueId] } } }; + await db.insert(projects).values([ + { id: projectId, companyId, name: "Task project", executionWorkspacePolicy: policy === "organization" ? restricted : null }, + { id: sourceProjectId, companyId, name: "File source", executionWorkspacePolicy: policy === "source" ? restricted : null }, + ]); + await db.insert(projectWorkspaces).values({ id: sourceId, companyId, projectId: sourceProjectId, name: "Source", cwd, isPrimary: true }); + await db.insert(agents).values({ id: agentId, companyId, name: "Worker", status: "idle", adapterType: "codex_local", adapterConfig: {}, permissions: {} }); + await db.insert(issues).values({ id: issueId, companyId, projectId, title: "Do not relax policy", status: "todo", assigneeAgentId: agentId, executionWorkspaceSettings: { mode: "shared_workspace" }, + projectWorkspaceId: selection === "configured_source" ? sourceId : null, + workspaceSelection: { version: 1, source: "explicit", selection: selection === "configured_source" + ? { kind: selection, projectWorkspaceId: sourceId, mode: "shared" } : { kind: selection } }, + }); + const run = await heartbeat.wakeup(agentId, { source: "on_demand", triggerDetail: "manual", contextSnapshot: { issueId } }); + await vi.waitFor(async () => { expect(await heartbeat.getRun(run!.id)).toMatchObject({ status: "failed" }); }, { timeout: 15_000 }); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + expect(execute.mock.calls.filter(([input]) => input.runId === run!.id)).toHaveLength(0); + expect((await heartbeat.getRun(run!.id))?.contextSnapshot).toMatchObject({ executionPolicy: { trustPreset: "low_trust_review", authorizationPolicy: { trustBoundary: { issueIds: [issueId] } } } }); + }, 25_000); + 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"); diff --git a/server/src/__tests__/task-workspace-binding.test.ts b/server/src/__tests__/task-workspace-binding.test.ts index 5b68885d88e..53d32edc496 100644 --- a/server/src/__tests__/task-workspace-binding.test.ts +++ b/server/src/__tests__/task-workspace-binding.test.ts @@ -11,6 +11,7 @@ import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; import { taskWorkspaceRoutes } from "../routes/task-workspaces.js"; +import { issueRoutes } from "../routes/issues.js"; import { errorHandler } from "../middleware/error-handler.js"; import { agents, authUsers, companies, companyMemberships, createDb, executionWorkspaceRepositories, executionWorkspaces, heartbeatRuns, issues, principalPermissionGrants, projects, projectWorkspaces } from "@paperclipai/db"; import { executionWorkspaceService } from "../services/execution-workspaces.js"; @@ -287,6 +288,52 @@ const support = await getEmbeddedPostgresTestSupport(); expect(await db.select().from(executionWorkspaceRepositories).where(eq(executionWorkspaceRepositories.executionWorkspaceId, f.workspaceId))).toHaveLength(1); }); + it.each(["binding", "configured_source"] as const)("enforces source authority on legacy PATCH %s and rolls back denied edits", async kind => { + const f = await fixture(), userId = randomUUID(), settings = instanceSettingsService(db); + const previous = await settings.getExperimental(); + await settings.updateExperimental({ enableIsolatedWorkspaces: true }); + try { + await db.insert(authUsers).values({ id: userId, name: "Workspace selector", email: `${userId}@example.test`, createdAt: new Date(), updatedAt: new Date() }); + await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" }); + const [assignee, nextAssignee] = await db.insert(agents).values(["Current worker", "Next worker"].map(name => ({ companyId: f.companyId, name, status: "idle", runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false } } }))).returning(); + await db.update(issues).set({ assigneeAgentId: assignee.id }).where(eq(issues.id, f.issueId)); + const [project, organization] = await db.insert(projects).values([ + { companyId: f.companyId, name: "Protected source", executionWorkspacePolicy: { authorizationPolicy: { assignmentPolicy: { mode: "protected" } } } }, + { companyId: f.companyId, name: "Organization" }, + ]).returning(); + const [source] = await db.insert(projectWorkspaces).values({ companyId: f.companyId, projectId: project.id, name: "Source", cwd: `/tmp/source-${f.issueId}` }).returning(); + await db.update(executionWorkspaces).set({ projectId: project.id, projectWorkspaceId: source.id }).where(eq(executionWorkspaces.id, f.workspaceId)); + const actor = { type: "board" as const, source: "session" as const, userId, companyIds: [f.companyId], isInstanceAdmin: false }; + const beforeMetadata = (await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata; + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { req.actor = actor; next(); }); + app.use("/api", taskWorkspaceRoutes(db)); + app.use("/api", issueRoutes(db, {} as never)); + app.use(errorHandler); + const patch = kind === "binding" ? { executionWorkspaceId: f.workspaceId } : { projectWorkspaceId: source.id, executionWorkspaceSettings: { mode: "shared_workspace" } }; + const selection = kind === "binding" ? { kind: "existing", workspaceId: f.workspaceId } : { kind: "configured_source", projectWorkspaceId: source.id, mode: "shared" }; + expect((await request(app).put(`/api/issues/${f.issueId}/workspace`).send({ selection, expectedBindingRevision: 0, requestKey: "denied" })).status).toBe(403); + const denied = await request(app).patch(`/api/issues/${f.issueId}`).send(patch); + expect(denied.status, JSON.stringify(denied.body)).toBe(403); + const readTask = () => db.select().from(issues).where(eq(issues.id, f.issueId)).then(rows => rows[0]); + expect(await readTask()).toMatchObject({ executionWorkspaceId: null, projectWorkspaceId: null, workspaceBindingRevision: 0 }); + expect((await db.select().from(executionWorkspaces).where(eq(executionWorkspaces.id, f.workspaceId)))[0].metadata).toEqual(beforeMetadata); + const [grant] = await db.insert(principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: userId, permissionKey: "tasks:assign_scope", scope: { projectId: project.id, assigneeAgentId: assignee.id } }).returning(); + // The same transaction must check the final assignee, not the old row. + await expect(issueService(db).update(f.issueId, { ...patch, assigneeAgentId: nextAssignee.id, workspaceSelectionActor: actor })).rejects.toThrow(/protected/); + expect(await readTask()).toMatchObject({ assigneeAgentId: assignee.id, workspaceBindingRevision: 0 }); + const allowed = await request(app).patch(`/api/issues/${f.issueId}`).send(patch); + expect(allowed.status, JSON.stringify(allowed.body)).toBe(200); + const bound = await readTask(); + expect(bound).toMatchObject({ ...patch, projectWorkspaceId: source.id, workspaceBindingRevision: 1 }); + await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.id, grant.id)); + const organized = await request(app).patch(`/api/issues/${f.issueId}`).send({ projectId: organization.id }); + expect(organized.status, JSON.stringify(organized.body)).toBe(200); + expect(await readTask()).toMatchObject({ projectId: organization.id, executionWorkspaceId: bound.executionWorkspaceId, projectWorkspaceId: source.id, workspaceBindingRevision: 1 }); + } finally { await settings.updateExperimental({ enableIsolatedWorkspaces: previous.enableIsolatedWorkspaces }); } + }); + it("requires source project assignment authority for shared files while preserving isolated reads", async () => { const f = await fixture(), svc = executionWorkspaceService(db), tasks = issueService(db); const settings = instanceSettingsService(db), previous = await settings.getExperimental(); diff --git a/server/src/__tests__/trust-preset-resolver.test.ts b/server/src/__tests__/trust-preset-resolver.test.ts index 5aa206ed83c..d7ffd6398ab 100644 --- a/server/src/__tests__/trust-preset-resolver.test.ts +++ b/server/src/__tests__/trust-preset-resolver.test.ts @@ -53,6 +53,19 @@ describe("resolveCoreTrustPreset", () => { }); }); + it("intersects organizational and workspace-source project restrictions", () => { + const organization = { companyId, executionWorkspacePolicy: boundaryPolicy(lowTrustBoundary({ projectIds: [projectA, projectB], allowedToolClasses: ["git.read", "tests.local"] })) }; + const source = { companyId, executionWorkspacePolicy: boundaryPolicy(lowTrustBoundary({ projectIds: [projectB, projectC], allowedToolClasses: ["git.read"] })) }; + expect(resolveCoreTrustPreset({ companyId, project: organization })).toMatchObject({ kind: "low_trust_review" }); + expect(resolveCoreTrustPreset({ companyId, workspaceSourceProject: source })).toMatchObject({ kind: "low_trust_review" }); + expect(resolveCoreTrustPreset({ companyId, project: organization, workspaceSourceProject: source })).toMatchObject({ + kind: "low_trust_review", boundary: { projectIds: [projectB], allowedToolClasses: ["git.read"] }, + }); + expect(resolveCoreTrustPreset({ companyId, project: organization, + workspaceSourceProject: { companyId, executionWorkspacePolicy: boundaryPolicy(lowTrustBoundary({ projectIds: [projectC] })) }, + })).toMatchObject({ kind: "denied", reason: "missing_low_trust_boundary_scope" }); + }); + it("intersects low-trust agent, project, and issue policy boundaries", () => { const result = resolveCoreTrustPreset({ companyId, diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index b5ad656ec8e..4bb23465dad 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -14321,6 +14321,7 @@ export function issueRoutes( const postCommitIssueActions: IssuePostCommitAction[] = []; const issueUpdateData = { ...updateFields, + workspaceSelectionActor: req.actor, expectedExecutionPolicy, actorAgentId: actor.agentId ?? null, actorRunId: actor.agentId ? actor.runId : null, diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index cd1d8d51098..428b6fc9f0f 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -31,7 +31,7 @@ import { instanceSettingsService } from "./instance-settings.js"; import { registerSlackTaskAuthority, slackRunOrigin } from "./connectors/slack-authority.js"; import { captureRunIdentity } from "./run-identity.js"; import { buildChatCommunicationGuidance } from "./chat-communication-guidance.js"; -import { assertChatExecutionDefaultsAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; +import { assertChatExecutionDefaultsAccess, assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; function githubPolicyRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; } import { githubChatManagementService } from "./chat-github-management.js"; import { githubReviewCheckService } from "./chat-github-checks.js"; @@ -16093,40 +16093,40 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const [taskResource] = await taskTx.select({ executionDefaults: chatEndpointResources.executionDefaults }) .from(chatEndpointResources).where(and(eq(chatEndpointResources.id, resource.id), eq(chatEndpointResources.endpointId, taskEndpoint.id))); const executionDefaults = resolveChatExecutionDefaults(taskEndpoint.executionDefaults, taskResource?.executionDefaults); - const responsibleUserId = taskUserId ?? endpoint.sponsorUserId; - await assertChatExecutionDefaultsAccess(db, { - companyId: endpoint.companyId, - assigneeAgentId: endpoint.assignedAgentId, + const responsibleUserId = taskUserId ?? taskEndpoint.sponsorUserId; + await assertChatTaskCreationAccess(db, { + companyId: taskEndpoint.companyId, + assigneeAgentId: taskEndpoint.assignedAgentId, actor: responsibleUserId ? { type: "board", userId: responsibleUserId, source: "session", ignoreInstanceAdmin: true } : { type: "none" }, defaults: executionDefaults, }, taskTx); - await assertChatExecutionDefaultsAccess(db, { - companyId: endpoint.companyId, - assigneeAgentId: endpoint.assignedAgentId, - actor: { type: "agent", agentId: endpoint.assignedAgentId, companyId: endpoint.companyId, onBehalfOfUserId: responsibleUserId, source: "agent_key" }, + await assertChatTaskCreationAccess(db, { + companyId: taskEndpoint.companyId, + assigneeAgentId: taskEndpoint.assignedAgentId, + actor: { type: "agent", agentId: taskEndpoint.assignedAgentId, companyId: taskEndpoint.companyId, onBehalfOfUserId: responsibleUserId, source: "agent_key" }, defaults: executionDefaults, }, taskTx); const sessionGeneration = isLinear ? (latestConversation?.sessionGeneration ?? 0) + 1 : 1; const issue = await issuesSvc.create( - endpoint.companyId, + taskEndpoint.companyId, { title: safeTitle( githubAutomatic ? `PR #${githubAutomatic.context.pullNumber}: ${githubAutomatic.context.title}` : githubIssue ? `GitHub issue #${githubIssue.context.issueNumber}: ${githubIssue.context.title}` : message.text, - `${PROVIDER_LABELS[endpoint.provider]} conversation`, + `${PROVIDER_LABELS[taskEndpoint.provider]} conversation`, ), - description: `Started from ${PROVIDER_LABELS[endpoint.provider]}: ${resource.label}`, + description: `Started from ${PROVIDER_LABELS[taskEndpoint.provider]}: ${resource.label}`, status: "todo", priority: "medium", - assigneeAgentId: endpoint.assignedAgentId, - createdByUserId: taskUserId ?? endpoint.sponsorUserId, - responsibleUserId: taskUserId ?? endpoint.sponsorUserId, + assigneeAgentId: taskEndpoint.assignedAgentId, + createdByUserId: taskUserId ?? taskEndpoint.sponsorUserId, + responsibleUserId: taskUserId ?? taskEndpoint.sponsorUserId, originKind: "chat_channel", - originId: `${endpoint.id}:${thread.id}:${sessionGeneration}`, - idempotencyKey: `chat:${endpoint.id}:${thread.id}:${sessionGeneration}`, + originId: `${taskEndpoint.id}:${thread.id}:${sessionGeneration}`, + idempotencyKey: `chat:${taskEndpoint.id}:${thread.id}:${sessionGeneration}`, ...(executionDefaults.projectId !== undefined ? { projectId: executionDefaults.projectId } : {}), ...(executionDefaults.workspace ? { workspaceSelection: executionDefaults.workspace, workspaceSelectionSource: "channel" as const } : {}), }, diff --git a/server/src/services/chat-execution-defaults.ts b/server/src/services/chat-execution-defaults.ts index b09febf486f..14cc9ded3ee 100644 --- a/server/src/services/chat-execution-defaults.ts +++ b/server/src/services/chat-execution-defaults.ts @@ -3,14 +3,21 @@ import { type Db, projects } from "@paperclipai/db"; import { and, eq } from "drizzle-orm"; import { projectReadSqlCondition, type AuthorizationActor } from "./authorization.js"; import { executionWorkspaceService } from "./execution-workspaces.js"; -import { notFound } from "../errors.js"; +import { forbidden, notFound } from "../errors.js"; +import { accessService } from "./access.js"; type DbTransaction = Parameters[0]>[0]; +type DefaultsAccessInput = { + companyId: string; + actor: AuthorizationActor; + assigneeAgentId?: string | null; + defaults: ChatExecutionDefaults | null | undefined; +}; /** Configuration selects resources; it never grants their access to future senders. */ export async function assertChatExecutionDefaultsAccess( db: Db, - input: { companyId: string; actor: AuthorizationActor; assigneeAgentId?: string | null; defaults: ChatExecutionDefaults | null | undefined }, + input: DefaultsAccessInput, reader: Db | DbTransaction = db, ): Promise { const defaults = chatExecutionDefaultsSchema.parse(input.defaults ?? {}); @@ -26,6 +33,29 @@ export async function assertChatExecutionDefaultsAccess( }, reader); } +/** Apply defaults and assignment authority together in the task creation transaction. */ +export async function assertChatTaskCreationAccess( + db: Db, + input: DefaultsAccessInput & { assigneeAgentId: string; parentIssueId?: string | null }, + transaction: Db | DbTransaction, +): Promise { + await assertChatExecutionDefaultsAccess(db, input, transaction); + if (!input.defaults?.projectId) return; + const scope = { + projectId: input.defaults.projectId, + parentIssueId: input.parentIssueId ?? null, + assigneeAgentId: input.assigneeAgentId, + assigneeUserId: null, + }; + const decision = await accessService(transaction as Db).decide({ + actor: input.actor, + action: "tasks:assign", + resource: { type: "issue", companyId: input.companyId, ...scope }, + scope, + }); + if (!decision.allowed) throw forbidden(decision.explanation); +} + /** Resolve once when a conversation creates its task. Later endpoint edits are irrelevant. */ export function resolveChatExecutionDefaults( endpoint: ChatExecutionDefaults | null | undefined, diff --git a/server/src/services/email-channels.ts b/server/src/services/email-channels.ts index a576abcf32b..bd7d9ed35e8 100644 --- a/server/src/services/email-channels.ts +++ b/server/src/services/email-channels.ts @@ -45,7 +45,7 @@ import { resolveExecutionWorkspaceEnvironmentId } from "./execution-workspace-po import { emailConnectionService } from "./email-connections.js"; import { secretService } from "./secrets.js"; import { authorizationService } from "./authorization.js"; -import { assertChatExecutionDefaultsAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; +import { assertChatTaskCreationAccess, resolveChatExecutionDefaults } from "./chat-execution-defaults.js"; import { issueService } from "./issues.js"; import { logActivity } from "./activity-log.js"; import { instanceSettingsService } from "./instance-settings.js"; @@ -1316,41 +1316,43 @@ export function emailChannelService(db: Db, options: EmailChannelOptions) { } if (!conversation) { const placement = await inboundPlacement( - endpoint.companyId, - endpoint.assignedAgentId, + current.companyId, + current.assignedAgentId, ); - const defaults = resolveChatExecutionDefaults(endpoint.executionDefaults, null); + const defaults = resolveChatExecutionDefaults(current.executionDefaults, null); // A channel default cannot widen the receiving agent's trust boundary. if ("projectId" in placement && defaults.projectId !== undefined && defaults.projectId !== placement.projectId) { throw forbidden("Default project conflicts with the assigned agent's trust boundary"); } - await assertChatExecutionDefaultsAccess(db, { - companyId: endpoint.companyId, - assigneeAgentId: endpoint.assignedAgentId, - actor: endpoint.sponsorUserId ? { type: "board", userId: endpoint.sponsorUserId, source: "session", ignoreInstanceAdmin: true } : { type: "none" }, - defaults, + await assertChatTaskCreationAccess(db, { + companyId: current.companyId, + assigneeAgentId: current.assignedAgentId, + actor: current.sponsorUserId ? { type: "board", userId: current.sponsorUserId, source: "session", ignoreInstanceAdmin: true } : { type: "none" }, + defaults: { ...defaults, ...("projectId" in placement ? { projectId: placement.projectId } : {}) }, + parentIssueId: "parentId" in placement ? placement.parentId : null, }, tx); - await assertChatExecutionDefaultsAccess(db, { - companyId: endpoint.companyId, - assigneeAgentId: endpoint.assignedAgentId, - actor: { type: "agent", agentId: endpoint.assignedAgentId, companyId: endpoint.companyId, onBehalfOfUserId: endpoint.sponsorUserId, source: "agent_key" }, - defaults, + await assertChatTaskCreationAccess(db, { + companyId: current.companyId, + assigneeAgentId: current.assignedAgentId, + actor: { type: "agent", agentId: current.assignedAgentId, companyId: current.companyId, onBehalfOfUserId: current.sponsorUserId, source: "agent_key" }, + defaults: { ...defaults, ...("projectId" in placement ? { projectId: placement.projectId } : {}) }, + parentIssueId: "parentId" in placement ? placement.parentId : null, }, tx); const task = await issueService(db).create( - endpoint.companyId, + current.companyId, { ...(defaults.projectId !== undefined ? { projectId: defaults.projectId } : {}), ...placement, ...(defaults.workspace ? { workspaceSelection: defaults.workspace, workspaceSelectionSource: "channel" as const } : {}), title: message.subject.slice(0, 200), - description: `Email conversation for ${endpoint.botExternalId}`, + description: `Email conversation for ${current.botExternalId}`, status: "todo", priority: "medium", - assigneeAgentId: endpoint.assignedAgentId, - responsibleUserId: endpoint.sponsorUserId, + assigneeAgentId: current.assignedAgentId, + responsibleUserId: current.sponsorUserId, originKind: "chat_channel", - originId: `email:${endpoint.id}:${message.thread_id}`, - idempotencyKey: `email:${endpoint.id}:${message.thread_id}`, + originId: `email:${current.id}:${message.thread_id}`, + idempotencyKey: `email:${current.id}:${message.thread_id}`, }, tx, ); diff --git a/server/src/services/execution-workspaces.ts b/server/src/services/execution-workspaces.ts index fc06f68a824..46417d5b2aa 100644 --- a/server/src/services/execution-workspaces.ts +++ b/server/src/services/execution-workspaces.ts @@ -50,7 +50,7 @@ import { normalizeIssueExecutionPolicy, parseIssueExecutionState, } from "./issue-execution-policy.js"; -import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy } from "./execution-workspace-policy.js"; +import { buildExecutionWorkspaceAdapterConfig, resolveEffectiveWorkspaceStrategyType, parseProjectExecutionWorkspacePolicy, gateProjectExecutionWorkspacePolicy, parseIssueExecutionWorkspaceSettings, resolveExecutionWorkspaceMode } from "./execution-workspace-policy.js"; import { issueRecoveryActionService } from "./issue-recovery-actions.js"; import { logActivity } from "./activity-log.js"; import { @@ -1419,6 +1419,29 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic } /** Ordinary issue edits participate in the same binding revision transaction. */ + async function assertTaskWorkspaceUpdateAccess(input: { + task: typeof issues.$inferSelect; actor: AuthorizationActor; isolatedWorkspacesEnabled: boolean; + }, tx: DbTransaction) { + const { task, actor } = input; + if (task.executionWorkspaceId) { + await assertTaskWorkspaceAccess(tx, actor, task.companyId, task.executionWorkspaceId, { write: true, issueId: task.id }); + return; + } + if (!task.projectWorkspaceId) return; + const [source] = await tx.select({ projectId: projectWorkspaces.projectId, executionWorkspacePolicy: projects.executionWorkspacePolicy }) + .from(projectWorkspaces).innerJoin(projects, eq(projects.id, projectWorkspaces.projectId)) + .where(and(eq(projectWorkspaces.id, task.projectWorkspaceId), eq(projectWorkspaces.companyId, task.companyId), + await projectReadSqlCondition(tx, actor))); + if (!source) throw notFound("Workspace source is unavailable or inaccessible"); + const projectPolicy = gateProjectExecutionWorkspacePolicy(parseProjectExecutionWorkspacePolicy(source.executionWorkspacePolicy), input.isolatedWorkspacesEnabled); + const issueSettings = parseIssueExecutionWorkspaceSettings(task.executionWorkspaceSettings); + const mode = resolveExecutionWorkspaceMode({ projectPolicy, issueSettings, legacyUseProjectWorkspace: null }); + const config = buildExecutionWorkspaceAdapterConfig({ agentConfig: {}, projectPolicy, issueSettings, mode, legacyUseProjectWorkspace: null }); + await assertTaskWorkspaceSourceProjectAccess(tx, actor, task.companyId, source.projectId, { + issueId: task.id, write: resolveEffectiveWorkspaceStrategyType(mode, config) !== "git_worktree", + }); + } + async function prepareTaskWorkspaceUpdate(input: { companyId: string; issueId: string; patch: TaskWorkspaceBindingPatch; }, tx: DbTransaction) { @@ -2052,7 +2075,7 @@ export function executionWorkspaceService(db: Db, opts: ExecutionWorkspaceServic requestTaskRepository: executionWorkspaceRepositoryService(db).request, listTaskRepositories: executionWorkspaceRepositoryService(db).list, prepareTaskRepositoriesForAdmission: executionWorkspaceRepositoryService(db).prepareForAdmission, - validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, + validateSelection, inspectTaskWorkspace, selectTaskWorkspace, applyPendingTaskWorkspaceSelection, bindTaskWorkspace, prepareTaskWorkspaceUpdate, assertTaskWorkspaceUpdateAccess, listOverview: async ( companyId: string, filters: WorkspaceOverviewQuery, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index f218f50ee52..765b088b5e1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2472,7 +2472,7 @@ export function heartbeatService( .where(and(eq(projectWorkspaces.id, issueContext.projectWorkspaceId), eq(projectWorkspaces.companyId, agent.companyId))) : []; const executionProjectId = boundSourceWorkspace ? boundSourceWorkspace.projectId : taskWorkspaceIntentRow?.intent?.selection.kind === "task_directory" ? null : selectedWorkspaceSource?.projectId ?? issueContext?.projectId ?? contextProjectId; - const projectContext = executionProjectId + const loadProjectContext = async (projectId: string | null | undefined) => projectId ? await db .select({ id: projects.id, @@ -2491,12 +2491,20 @@ export function heartbeatService( .from(projects) .where( and( - eq(projects.id, executionProjectId), + eq(projects.id, projectId), eq(projects.companyId, agent.companyId), ), ) .then((rows) => rows[0] ?? null) : null; + const taskProjectId = issueContext ? issueContext.projectId : contextProjectId; + const taskProjectContext = await loadProjectContext(taskProjectId); + if (issueContext) { + if (taskProjectId) context.projectId = taskProjectId; + else delete context.projectId; + } + const workspaceProjectContext = executionProjectId === taskProjectId + ? taskProjectContext : await loadProjectContext(executionProjectId); const acceptedPlanContinuationWake = issueContext && !isConversation(issueContext) ? readNonEmptyString(context.workspaceRefreshReason) === "accepted_plan_confirmation" || @@ -2607,7 +2615,7 @@ export function heartbeatService( } const parsedProjectExecutionWorkspacePolicy = parseProjectExecutionWorkspacePolicy( - projectContext?.executionWorkspacePolicy, + workspaceProjectContext?.executionWorkspacePolicy, ); const projectExecutionWorkspacePolicy = applyDefaultIsolatedExecutionWorkspacePolicy({ @@ -2618,7 +2626,7 @@ export function heartbeatService( defaultIsolatedWorkspacesEnabled, // Projects without workspace configuration get a plain managed // directory. The operator default cannot turn it into a worktree. - hasProjectWorkspace: projectContext?.hasWorkspace ?? false, + hasProjectWorkspace: workspaceProjectContext?.hasWorkspace ?? false, }); const retainedTrust = await resolveAndRetainRunTrustPreset(db, { companyId: agent.companyId, @@ -2628,12 +2636,12 @@ export function heartbeatService( companyId: agent.companyId, permissions: agent.permissions, }, - project: projectContext - ? { - companyId: agent.companyId, - // Workspace feature gates must not erase authorization policy. - executionWorkspacePolicy: projectContext.executionWorkspacePolicy, - } + project: taskProjectContext + ? { companyId: agent.companyId, executionWorkspacePolicy: taskProjectContext.executionWorkspacePolicy } + : null, + // Selecting files cannot erase the task's policy or relax the source's. + workspaceSourceProject: workspaceProjectContext && workspaceProjectContext.id !== taskProjectContext?.id + ? { companyId: agent.companyId, executionWorkspacePolicy: workspaceProjectContext.executionWorkspacePolicy } : null, issue: issueContext ? { @@ -3153,7 +3161,7 @@ export function heartbeatService( const workspaceRequiringAccess = reusableExistingExecutionWorkspace ?? boundSourceWorkspace; if (workspaceRequiringAccess) { await assertTaskWorkspaceAccess(db, workspaceAuthorizationActor, agent.companyId, workspaceRequiringAccess.id, { write: true, issueId }); - } else if (!isDotRun && executionProjectId && (selectedWorkspaceSource || projectContext?.hasWorkspace)) { + } else if (!isDotRun && executionProjectId && (selectedWorkspaceSource || workspaceProjectContext?.hasWorkspace)) { // Check source authority before resolution can clone or expose files. await assertTaskWorkspaceSourceProjectAccess(db, workspaceAuthorizationActor, agent.companyId, executionProjectId, { write: resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) !== "git_worktree", issueId }); } @@ -3283,7 +3291,7 @@ export function heartbeatService( admittedCwd: persistedNativeExecutionInput && persistedNativeExecutionInput.schema !== "paperclip.native-execution-input.v6" ? persistedNativeExecutionInput.workspace.cwd : null, }); - const nativeChatExpectedCwd = nativeChatWorkspaceScope + let nativeChatExpectedCwd = nativeChatWorkspaceScope ? nativeChatWorkspaceCwd( nativeChatWorkspaceScope, reusableExistingExecutionWorkspace, @@ -3306,13 +3314,20 @@ export function heartbeatService( "native_chat_workspace_scope_mismatch", ); } + const materializeNativeChatWorktree = Boolean( + nativeChatWorkspaceScope?.projectId && !persistedNativeExecutionInput && !existingExecutionWorkspace && + taskWorkspaceIntentRow?.intent?.selection.kind === "configured_source" && + taskWorkspaceIntentRow.intent.selection.mode === "managed_isolated" && + requestedExecutionWorkspaceMode === "isolated_workspace" && + resolveEffectiveWorkspaceStrategyType(requestedExecutionWorkspaceMode, sourceWorkspaceConfig) === "git_worktree", + ); if ( nativeChatWorkspaceScope && - (!nativeChatExpectedCwd || + ((!nativeChatExpectedCwd && !materializeNativeChatWorktree) || executionProjectId !== nativeChatWorkspaceScope.projectId) ) { throw new ConfigurationIncompleteFailure( - "External chat requires a task-owned isolated workspace. Configure and select an existing isolated worktree for this project task; shared project workspaces cannot be used for external chat.", + "External chat requires a task-owned isolated workspace. Select a managed isolated source or an existing task-owned isolated worktree; shared project workspaces cannot be used for external chat.", { configurationIncomplete: { reason: "native_chat_workspace_isolation_required", @@ -3437,7 +3452,7 @@ export function heartbeatService( trustPreset: trustPreset.kind, environmentDriver: selectedEnvironmentForConfig?.driver ?? null, mode: requestedExecutionWorkspaceMode, - hasProjectWorkspace: projectContext?.hasWorkspace ?? false, + hasProjectWorkspace: workspaceProjectContext?.hasWorkspace ?? false, projectWorkspaceId: issueRef.projectWorkspaceId, workspaceStrategies: [ config.workspaceStrategy, @@ -3498,11 +3513,11 @@ export function heartbeatService( environmentId: selectedEnvironmentForConfig?.id ?? null, environmentEnv: aiBinding ? stripAiAuthBindings(selectedEnvironmentForConfig?.envVars) : selectedEnvironmentForConfig?.envVars ?? null, environmentDriver: selectedEnvironmentForConfig?.driver ?? null, - projectId: projectContext?.id ?? null, + projectId: taskProjectContext?.id ?? null, routineId: routineEnvContext.routineId, responsibleUserId, executionRunConfig: aiBinding ? { ...executionRunConfig, env: stripAiAuthBindings(executionRunConfig.env) } : executionRunConfig, - projectEnv: aiBinding ? stripAiAuthBindings(projectContext?.env) : projectContext?.env ?? null, + projectEnv: aiBinding ? stripAiAuthBindings(taskProjectContext?.env) : taskProjectContext?.env ?? null, routineEnv: aiBinding ? stripAiAuthBindings(routineEnvContext.env) : routineEnvContext.env, secretsSvc, trustPreset, @@ -3640,9 +3655,9 @@ export function heartbeatService( ? issueContext.updatedAt.toISOString() : (issueContext?.updatedAt ?? null), projectConfigRevisionAt: - projectContext?.updatedAt instanceof Date - ? projectContext.updatedAt.toISOString() - : (projectContext?.updatedAt ?? null), + workspaceProjectContext?.updatedAt instanceof Date + ? workspaceProjectContext.updatedAt.toISOString() + : (workspaceProjectContext?.updatedAt ?? null), projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, reusableExecutionWorkspaceConfig: @@ -3678,7 +3693,7 @@ export function heartbeatService( executionPolicy, }, environmentEnv: selectedEnvironmentForConfig?.envVars ?? null, - projectEnv: projectContext?.env ?? null, + projectEnv: taskProjectContext?.env ?? null, routineEnv: routineEnvContext.env, secretManifest, runtimeSkills: runtimeSkillEntries, @@ -4674,6 +4689,11 @@ export function heartbeatService( catch { throw new ConfigurationIncompleteFailure("Project authentication conflicts with this agent’s managed AI connection", { configurationIncomplete: { reason: "ai_connection_incompatible", actionUrl: `/agents/${agent.id}/runtime` } }); } } const remoteExecution = realizationResult.remoteExecution; + if (nativeChatWorkspaceScope && materializeNativeChatWorktree) { + // The fresh plan is not filesystem authority. Validate the realized, + // durable task-owned worktree before exposing it to the native runner. + nativeChatExpectedCwd = nativeChatWorkspaceCwd(nativeChatWorkspaceScope, persistedExecutionWorkspace, true); + } if ( nativeChatWorkspaceScope && (executionTarget?.kind === "remote" || @@ -5001,7 +5021,7 @@ export function heartbeatService( delete context.paperclipRuntimeServiceIntents; } if ( - executionWorkspace.projectId && + !issueContext && executionWorkspace.projectId && !readNonEmptyString(context.projectId) ) { context.projectId = executionWorkspace.projectId; diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 4b469df7dfb..98830888de6 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10866,6 +10866,7 @@ export function issueService(db: Db) { actorRunStopId?: string | null; actorUserId?: string | null; companyGuard?: string; + workspaceSelectionActor?: AuthorizationActor; expectedExecutionPolicy?: typeof issues.$inferInsert.executionPolicy; }, dbOrTx: any = db, @@ -10914,6 +10915,7 @@ export function issueService(db: Db) { actorRunStopId, actorUserId, companyGuard, + workspaceSelectionActor, expectedExecutionPolicy, ...issueData } = data; @@ -11281,6 +11283,14 @@ export function issueService(db: Db) { .returning() .then((rows: Array) => rows[0] ?? null); if (!updated) return null; + if (workspaceSelectionActor && updated.workspaceBindingRevision !== receiptExisting.workspaceBindingRevision) { + // Authorize the persisted target inside this transaction so a combined + // assignment/workspace edit checks the actual new assignee. A denial + // rolls back the binding, revision and retained-privacy trigger too. + await executionWorkspaceService(db).assertTaskWorkspaceUpdateAccess({ + task: updated, actor: workspaceSelectionActor, isolatedWorkspacesEnabled, + }, tx); + } if (issueData.description !== undefined) await attachOwnedDraftImages(tx, updated, updated.description, { userId: actorUserId, agentId: actorAgentId }); if (changesPrivacy && updated.visibility === "private") { diff --git a/server/src/services/trust-preset-resolver.ts b/server/src/services/trust-preset-resolver.ts index 7b0ab3b7f6b..7d7221ba84d 100644 --- a/server/src/services/trust-preset-resolver.ts +++ b/server/src/services/trust-preset-resolver.ts @@ -13,7 +13,7 @@ type JsonRecord = Record; export const LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH = 12; -export type TrustPresetPolicySource = "agent" | "project" | "issue" | "run"; +export type TrustPresetPolicySource = "agent" | "project" | "workspace_source_project" | "issue" | "run"; export type ResolveCoreTrustPresetInput = { companyId: string; @@ -25,6 +25,10 @@ export type ResolveCoreTrustPresetInput = { companyId?: string | null; executionWorkspacePolicy?: unknown; } | null; + workspaceSourceProject?: { + companyId?: string | null; + executionWorkspacePolicy?: unknown; + } | null; issue?: { companyId?: string | null; executionPolicy?: unknown; @@ -280,6 +284,12 @@ export function resolveCoreTrustPreset(input: ResolveCoreTrustPresetInput): Trus if ("kind" in project) return project; sources.push(project); + const workspaceSourcePolicy = asRecord(input.workspaceSourceProject?.executionWorkspacePolicy); + const workspaceSource = parseSource("workspace_source_project", input.workspaceSourceProject?.companyId, + workspaceSourcePolicy, workspaceSourcePolicy?.authorizationPolicy, sourcePresets); + if ("kind" in workspaceSource) return workspaceSource; + sources.push(workspaceSource); + const issuePolicy = asRecord(input.issue?.executionPolicy); const issue = parseSource("issue", input.issue?.companyId, issuePolicy, issuePolicy?.authorizationPolicy, sourcePresets); if ("kind" in issue) return issue;