mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
Enforce private task and project access across direct reads, search, execution, files, plugins, live delivery, and sharing mutations. Preserve downward-only sharing, current responsible-user authorization, and audited emergency access. Bind historical draft assets with migration 0314. Co-Authored-By: Paperclip <noreply@paperclip.ing>
37 lines
2.8 KiB
TypeScript
37 lines
2.8 KiB
TypeScript
import { and, desc, eq, lte } from "drizzle-orm";
|
|
import { documentRevisions, heartbeatRuns, issueDocuments, issues, issueThreadInteractions, type Db } from "@paperclipai/db";
|
|
|
|
import { canActorReadIssuePrivacy, type AuthorizationActor } from "../authorization.js";
|
|
|
|
/** Approval evidence belongs to the source conversation and exact revision.
|
|
* It informs scope; it does not approve the new task's document or waive gates. */
|
|
export async function handoffPlanContext(db: Db, task: typeof issues.$inferSelect, actor?: AuthorizationActor) {
|
|
if (!task.originRunId || task.conversationAgentId) return null;
|
|
const [sourceRun] = await db.select().from(heartbeatRuns).where(and(
|
|
eq(heartbeatRuns.id, task.originRunId), eq(heartbeatRuns.companyId, task.companyId),
|
|
));
|
|
const sourceId = sourceRun?.nativeIssueId ?? sourceRun?.contextSnapshot?.issueId;
|
|
if (typeof sourceId !== "string" || sourceId === task.id) return null;
|
|
const [source] = await db.select().from(issues).where(and(eq(issues.id, sourceId), eq(issues.companyId, task.companyId)));
|
|
if (!source?.conversationAgentId) return null;
|
|
if (actor && !(await canActorReadIssuePrivacy(db, actor, source))) return null;
|
|
const accepted = await db.select().from(issueThreadInteractions).where(and(
|
|
eq(issueThreadInteractions.companyId, task.companyId), eq(issueThreadInteractions.issueId, source.id),
|
|
eq(issueThreadInteractions.kind, "request_confirmation"), eq(issueThreadInteractions.status, "accepted"),
|
|
lte(issueThreadInteractions.resolvedAt, task.createdAt),
|
|
)).orderBy(desc(issueThreadInteractions.resolvedAt));
|
|
for (const interaction of accepted) {
|
|
const target = (interaction.payload as { target?: { type?: string; key?: string; revisionId?: string; issueId?: string } }).target;
|
|
if (target?.type !== "issue_document" || target.key !== "plan" || !target.revisionId ||
|
|
(target.issueId && target.issueId !== source.id)) continue;
|
|
const [revision] = await db.select({ markdown: documentRevisions.body, revisionId: documentRevisions.id })
|
|
.from(documentRevisions).innerJoin(issueDocuments, and(
|
|
eq(issueDocuments.documentId, documentRevisions.documentId), eq(issueDocuments.companyId, task.companyId),
|
|
eq(issueDocuments.issueId, source.id), eq(issueDocuments.key, "plan"),
|
|
)).where(and(eq(documentRevisions.id, target.revisionId), eq(documentRevisions.companyId, task.companyId)));
|
|
if (revision) return { sourceIssueId: source.id, interactionId: interaction.id, ...revision,
|
|
guidance: "This source plan was accepted before this task was created. Execute the assigned scope within that plan; planning and handoff steps already completed in the source are not new work. This is not approval of changes to scope or of a new task document. Preserve other applicable gates." };
|
|
}
|
|
return null;
|
|
}
|