mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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 <noreply@paperclip.ing>
This commit is contained in:
1 parent
026010de0d
commit
0049e01678
15 files changed
+434
-64
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown> { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; }
|
||||
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 } : {}),
|
||||
},
|
||||
|
||||
@@ -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<Parameters<Db["transaction"]>[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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof issues.$inferSelect>) => 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") {
|
||||
|
||||
@@ -13,7 +13,7 @@ type JsonRecord = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user