Files
PaperClipAI/server/src/services/native-runtime/handoff-plan-context.ts
T
DottaandPaperclip 3a726e676f feat: enforce private task permissions across execution and data (#10633)
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>
2026-10-07 07:06:40 -05:00

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;
}