From a17bee98f2b7f4e7d319ddbf313fd3d3119e4e65 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:03:04 -0500 Subject: [PATCH] Allow trust-gated direct-parent issue reports (#10098) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the control plane used to coordinate and govern AI-agent companies. > - Agent issue access must preserve company boundaries and trust-policy containment without preventing legitimate task coordination. > - Checked-out standard-trust child runs need a narrow way to report progress directly to their parent issue, but existing authorization treated that report like an arbitrary cross-boundary write. > - Low-trust review runs must remain contained, and stop propagation must not copy potentially untrusted child prose into a higher-trust parent context. > - This pull request adds an audited, one-hop direct-parent comment grant only for standard checked-out runs and a sanitized, idempotent relay for blocked or cancelled child stops. > - The benefit is restored parent/child liveness while retaining least privilege, complete mediation, and low-trust output quarantine. ## Linked Issues or Issue Description ### What happened? A standard-trust agent running a checked-out child issue could not post a progress comment to the direct parent issue because the authorization boundary treated it as an arbitrary cross-issue write. This could stall parent/child coordination. Low-trust review runs also need stop propagation without exposing quarantined child-authored prose. ### Expected behavior A standard checked-out child run may add a comment only to its direct parent issue. The grant must not allow grandparent or sibling access, issue mutation, document writes, reopening, or resuming. Low-trust runs remain denied unless separately mentioned, while blocked/cancelled stops relay only sanitized system metadata once. ### Steps to reproduce 1. Create a parent issue and a child issue assigned to different standard-trust agents. 2. Check out the child issue in a heartbeat run and authenticate as that run. 3. Post a comment to the parent issue and observe the authorization denial before this change. 4. Mark a low-trust child blocked or cancelled and observe that no bounded sanitized parent notification preserves liveness before this change. ### Paperclip version or commit Reproduces on `master` before this PR, including base commit `d36ea13e08`. ### Deployment mode Local dev (`pnpm dev`). ### Installation method Built from source (`pnpm dev` / `pnpm build`). ### Agent adapter(s) involved Not adapter-specific (core authorization and issue-routing behavior). ### Database mode External Postgres in the focused route regression suite; behavior is database-mode independent. ### Access context Agent (bearer API key associated with a checked-out heartbeat run). ### Additional context The implementation deliberately distinguishes a direct-parent report decision from general issue mutation permission and records successful grants in the activity log. ### Privacy checklist - [x] I have reviewed all pasted output for PII, API keys, tokens, company names, and private instance references. ## What Changed - Adds a distinct authorization decision for standard checked-out runs commenting on their direct parent issue. - Keeps low-trust direct-parent reports denied unless an existing explicit mention grant applies. - Forces direct-parent grants to remain comment-only even when a closed parent is unassigned or assigned to the reporting agent. - Audits successful direct-parent report grants in issue activity details. - Adds sanitized, parent-scoped, idempotent system comments and parent wakeups for blocked or cancelled child stops. - Extends the low-trust red-team route suite for allowed parent reports, forbidden upward/sibling writes, closed-parent mutation suppression, and non-laundering stop relays. ## Verification - `pnpm exec vitest run server/src/__tests__/low-trust-red-team-routes.test.ts` — 11 tests passed after the review fix. - `pnpm --filter @paperclipai/server typecheck` — passed after the review fix. - Confirmed the PR changes four files and excludes `pnpm-lock.yaml`, workflow changes, migrations, and unrelated branch commits. ## Risks - This is an authorization behavior change. An overly broad grant could enable cross-boundary writes, while an overly narrow grant could preserve the liveness failure. - The implementation constrains the grant to a standard-trust checked-out run, a direct parent target, and comments only; activity auditing and red-team coverage make regressions observable. - Stop relays intentionally contain only system-generated child identity/status metadata and are deduplicated; child-authored prose is not copied. - SecurityEngineer approval is mandatory before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex using GPT-5.5 with reasoning, repository tool use, shell execution, and test execution. The runtime does not expose the context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../low-trust-red-team-routes.test.ts | 185 +++++++++++++++++- server/src/routes/issues.ts | 138 ++++++++++++- server/src/services/authorization.ts | 93 ++++++++- server/src/services/issues.ts | 57 ++++++ 4 files changed, 453 insertions(+), 20 deletions(-) diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 53d701af99..90a84f5187 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -3,7 +3,7 @@ import { createServer } from "node:http"; import express from "express"; import request from "supertest"; import { WebSocketServer } from "ws"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { activityLog, @@ -127,6 +127,16 @@ function agentActor(fixture: Fixture, agentId = fixture.agents.lowTrust.id): Exp }; } +function standardReportActor(fixture: Fixture): Express.Request["actor"] { + return { + type: "agent", + agentId: fixture.agents.standard.id, + companyId: fixture.company.id, + runId: fixture.runs.standardReport.id, + source: "agent_jwt", + }; +} + function skillTestActor(fixture: Fixture, issueId = fixture.issues.assignedReview.id): Express.Request["actor"] { return { type: "agent", @@ -407,12 +417,23 @@ async function seedLowTrustFixture(db: Db) { permissions: {}, }).returning(); + const [reviewGrandparent] = await db.insert(issues).values({ + companyId: company!.id, + projectId: allowedProject!.id, + title: "Review grandparent", + status: "in_progress", + priority: "medium", + assigneeAgentId: cto!.id, + responsibleUserId: "board-user", + }).returning(); const [reviewRoot] = await db.insert(issues).values({ companyId: company!.id, projectId: allowedProject!.id, + parentId: reviewGrandparent!.id, title: "Review root", - status: "todo", + status: "in_progress", priority: "medium", + assigneeAgentId: cto!.id, responsibleUserId: "board-user", }).returning(); const [assignedReview] = await db.insert(issues).values({ @@ -431,6 +452,17 @@ async function seedLowTrustFixture(db: Db) { title: "Same boundary child", status: "todo", priority: "medium", + assigneeAgentId: cto!.id, + responsibleUserId: "board-user", + }).returning(); + const [standardChild] = await db.insert(issues).values({ + companyId: company!.id, + projectId: allowedProject!.id, + parentId: reviewRoot!.id, + title: "Assigned standard child", + status: "in_progress", + priority: "medium", + assigneeAgentId: standard!.id, responsibleUserId: "board-user", }).returning(); const [siblingOutOfScope] = await db.insert(issues).values({ @@ -488,6 +520,12 @@ async function seedLowTrustFixture(db: Db) { status: "running", contextSnapshot: { issueId: assignedReview!.id }, }).returning(); + const [standardReportRun] = await db.insert(heartbeatRuns).values({ + companyId: company!.id, + agentId: standard!.id, + status: "running", + contextSnapshot: { issueId: standardChild!.id }, + }).returning(); await db.update(issues).set({ checkoutRunId: lowTrustRun!.id, executionRunId: lowTrustRun!.id, @@ -496,6 +534,12 @@ async function seedLowTrustFixture(db: Db) { assignedReview!.checkoutRunId = lowTrustRun!.id; assignedReview!.executionRunId = lowTrustRun!.id; assignedReview!.executionPolicy = executionPolicy; + await db.update(issues).set({ + checkoutRunId: standardReportRun!.id, + executionRunId: standardReportRun!.id, + }).where(eq(issues.id, standardChild!.id)); + standardChild!.checkoutRunId = standardReportRun!.id; + standardChild!.executionRunId = standardReportRun!.id; await db.insert(issueComments).values({ companyId: company!.id, @@ -630,13 +674,20 @@ async function seedLowTrustFixture(db: Db) { company: company!, agents: { lowTrust: lowTrust!, standard: standard!, collaborator: collaborator!, cto: cto! }, projects: { allowed: allowedProject!, outOfScope: outOfScopeProject! }, - issues: { reviewRoot: reviewRoot!, assignedReview: assignedReview!, sameBoundaryChild: sameBoundaryChild!, siblingOutOfScope: siblingOutOfScope! }, + issues: { + reviewGrandparent: reviewGrandparent!, + reviewRoot: reviewRoot!, + assignedReview: assignedReview!, + standardChild: standardChild!, + sameBoundaryChild: sameBoundaryChild!, + siblingOutOfScope: siblingOutOfScope!, + }, approvals: { issueLinkedCanary: approval! }, sensitiveRows: { siblingAnnotationThreadId: siblingAnnotationThread!.id, siblingAttachmentId: siblingAttachment!.id, }, - runs: { lowTrust: lowTrustRun!, standard: standardRun! }, + runs: { lowTrust: lowTrustRun!, standard: standardRun!, standardReport: standardReportRun! }, canaries, }; } @@ -727,6 +778,132 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => }); }); + it("allows only standard checked-out runs to comment one hop upward", async () => { + const fixture = await seedLowTrustFixture(db); + const standardApp = createApp(db, standardReportActor(fixture)); + const lowTrustApp = createApp(db, agentActor(fixture)); + + const parentComment = await request(standardApp) + .post(`/api/issues/${fixture.issues.reviewRoot.id}/comments`) + .send({ body: "Direct parent report" }); + expect(parentComment.status, JSON.stringify(parentComment.body)).toBe(201); + + const [audit] = await db + .select({ details: activityLog.details }) + .from(activityLog) + .where(and( + eq(activityLog.entityId, fixture.issues.reviewRoot.id), + eq(activityLog.action, "issue.comment_added"), + )); + expect(audit?.details).toMatchObject({ directParentReportGrant: true }); + + const lowTrustParentComment = await request(lowTrustApp) + .post(`/api/issues/${fixture.issues.reviewRoot.id}/comments`) + .send({ body: "Contained report must not cross" }); + expect(lowTrustParentComment.status, JSON.stringify(lowTrustParentComment.body)).toBe(403); + + const forbiddenStandardWrites = [ + request(standardApp) + .post(`/api/issues/${fixture.issues.reviewGrandparent.id}/comments`) + .send({ body: "No grandparent report" }), + request(standardApp) + .post(`/api/issues/${fixture.issues.sameBoundaryChild.id}/comments`) + .send({ body: "No sibling report" }), + request(standardApp) + .patch(`/api/issues/${fixture.issues.reviewRoot.id}`) + .send({ status: "blocked" }), + request(standardApp) + .put(`/api/issues/${fixture.issues.reviewRoot.id}/documents/upward-write`) + .send({ format: "markdown", body: "No upward document write" }), + ]; + for (const forbiddenWrite of forbiddenStandardWrites) { + const response = await forbiddenWrite; + expect(response.status, JSON.stringify(response.body)).toBe(403); + } + + for (const closedParent of [ + { assigneeAgentId: null, intent: { reopen: true } }, + { assigneeAgentId: fixture.agents.standard.id, intent: { resume: true } }, + ]) { + await db + .update(issues) + .set({ status: "done", assigneeAgentId: closedParent.assigneeAgentId }) + .where(eq(issues.id, fixture.issues.reviewRoot.id)); + + const closedParentComment = await request(standardApp) + .post(`/api/issues/${fixture.issues.reviewRoot.id}/comments`) + .send({ body: "Comment only on closed parent", ...closedParent.intent }); + expect(closedParentComment.status, JSON.stringify(closedParentComment.body)).toBe(201); + + const [persistedParent] = await db + .select({ status: issues.status }) + .from(issues) + .where(eq(issues.id, fixture.issues.reviewRoot.id)); + expect(persistedParent?.status).toBe("done"); + } + }); + + it("relays blocked and cancelled stops once without laundering child prose", async () => { + const fixture = await seedLowTrustFixture(db); + const app = createApp(db, boardActor(fixture)); + + const blocked = await request(app) + .patch(`/api/issues/${fixture.issues.assignedReview.id}`) + .send({ status: "blocked", comment: fixture.canaries.raw }); + expect(blocked.status, JSON.stringify(blocked.body)).toBe(200); + + await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "blocked" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "cancelled" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.assignedReview.id}`).send({ status: "todo" }).expect(200); + await db + .update(issues) + .set({ parentId: null }) + .where(eq(issues.id, fixture.issues.assignedReview.id)); + await request(app) + .patch(`/api/issues/${fixture.issues.assignedReview.id}`) + .send({ parentId: fixture.issues.reviewGrandparent.id, status: "blocked" }) + .expect(200); + + await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "blocked" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "todo" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "in_review" }).expect(200); + await request(app).patch(`/api/issues/${fixture.issues.standardChild.id}`).send({ status: "done" }).expect(200); + + const relayComments = await db + .select({ body: issueComments.body, authorType: issueComments.authorType }) + .from(issueComments) + .where(and( + eq(issueComments.issueId, fixture.issues.reviewRoot.id), + eq(issueComments.authorType, "system"), + )); + expect(relayComments).toHaveLength(2); + expect(relayComments.map((comment) => comment.body)).toEqual(expect.arrayContaining([ + expect.stringContaining(`transitioned to \`blocked\``), + expect.stringContaining(`transitioned to \`cancelled\``), + ])); + for (const relay of relayComments) { + expect(relay.authorType).toBe("system"); + expect(relay.body).toContain(fixture.issues.assignedReview.identifier ?? fixture.issues.assignedReview.id); + expect(relay.body).not.toContain(fixture.canaries.raw); + expect(relay.body).not.toContain("in_review"); + expect(relay.body).not.toContain("done"); + expect(relay.body).not.toContain(fixture.issues.standardChild.identifier); + } + + const reparentedRelayComments = await db + .select({ body: issueComments.body, authorType: issueComments.authorType }) + .from(issueComments) + .where(and( + eq(issueComments.issueId, fixture.issues.reviewGrandparent.id), + eq(issueComments.authorType, "system"), + )); + expect(reparentedRelayComments).toHaveLength(1); + expect(reparentedRelayComments[0]?.body).toContain("transitioned to `blocked`"); + expect(reparentedRelayComments[0]?.body).not.toContain(fixture.canaries.raw); + }); + it("allows mentioned low-trust agents to comment on out-of-bound assigned issues", async () => { const fixture = await seedLowTrustFixture(db); const [targetIssue] = await db.insert(issues).values({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3138619e87..0e00405a9d 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -2750,6 +2750,33 @@ export function issueRoutes( return resolution?.kind === "low_trust_review"; } + async function directParentReportDisabledForIssue(issue: { + companyId: string; + projectId?: string | null; + executionPolicy?: unknown; + assigneeAgentId?: string | null; + checkoutRunId?: string | null; + executionRunId?: string | null; + }) { + const resolution = issue.assigneeAgentId + ? await resolveAgentTrustForIssue({ + agentId: issue.assigneeAgentId, + runId: issue.checkoutRunId ?? issue.executionRunId, + }, issue.companyId, issue) + : null; + if (resolution) return resolution.kind !== "standard"; + + const project = issue.projectId ? await projectsSvc.getById(issue.projectId) : null; + return resolveCoreTrustPreset({ + companyId: issue.companyId, + project: project?.companyId === issue.companyId ? project : null, + issue: { + companyId: issue.companyId, + executionPolicy: issue.executionPolicy, + }, + }).kind !== "standard"; + } + async function assertLowTrustControlPlaneDenied( req: Request, res: Response, @@ -3465,6 +3492,10 @@ export function issueRoutes( return decision !== true && decision.reason === "allow_issue_mention_grant"; } + function isDirectParentReportDecision(decision: true | Awaited>) { + return decision !== true && decision.reason === "allow_direct_parent_report"; + } + async function filterIssuesForActor[1]>(req: Request, rows: T[]) { const decisions = await Promise.all(rows.map((issue) => decideIssueAccess(req, issue, "issue:read"))); return rows.filter((_, index) => decisions[index]?.allowed); @@ -7928,6 +7959,28 @@ export function issueRoutes( } } + const nextParentId = updateFields.parentId === undefined + ? existing.parentId + : updateFields.parentId as string | null; + const shouldRelayStop = + Boolean(nextParentId) && + existing.status !== updateFields.status && + (updateFields.status === "blocked" || updateFields.status === "cancelled") && + await directParentReportDisabledForIssue({ + companyId: existing.companyId, + projectId: updateFields.projectId === undefined + ? existing.projectId + : updateFields.projectId as string | null, + executionPolicy: updateFields.executionPolicy === undefined + ? existing.executionPolicy + : updateFields.executionPolicy, + assigneeAgentId: nextAssigneeAgentId, + checkoutRunId: existing.checkoutRunId, + executionRunId: existing.executionRunId, + }); + const stopRelayResult: { + value: Awaited>; + } = { value: null }; let issue; try { if (transition.decision && decisionId) { @@ -7957,6 +8010,21 @@ export function issueRoutes( createdByRunId: actor.runId ?? null, }); + if (shouldRelayStop) { + stopRelayResult.value = await svc.addStopRelayCommentIfNeeded(updated, tx); + } + + return updated; + }); + } else if (shouldRelayStop) { + issue = await db.transaction(async (tx) => { + const updated = await svc.update(id, { + ...updateFields, + actorAgentId: actor.agentId ?? null, + actorUserId: actor.actorType === "user" ? actor.actorId : null, + }, tx); + if (!updated) return null; + stopRelayResult.value = await svc.addStopRelayCommentIfNeeded(updated, tx); return updated; }); } else { @@ -8699,6 +8767,52 @@ export function issueRoutes( } } + const stopRelay = stopRelayResult.value; + if (stopRelay) { + await logActivity(db, { + companyId: issue.companyId, + actorType: "system", + actorId: "issue_stop_relay", + agentId: null, + runId: actor.runId, + agentApiKeyId: actor.agentApiKeyId, + action: "issue.comment_added", + entityType: "issue", + entityId: stopRelay.parent.id, + details: { + commentId: stopRelay.comment.id, + source: "child_stop_relay", + childIssueId: issue.id, + childIdentifier: issue.identifier, + childStatus: issue.status, + }, + }); + if (stopRelay.parent.assigneeAgentId && !isClosedIssueStatus(stopRelay.parent.status)) { + addWakeup(stopRelay.parent.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { + issueId: stopRelay.parent.id, + commentId: stopRelay.comment.id, + mutation: "comment", + }, + requestedByActorType: "system", + requestedByActorId: "issue_stop_relay", + contextSnapshot: { + issueId: stopRelay.parent.id, + taskId: stopRelay.parent.id, + commentId: stopRelay.comment.id, + wakeCommentId: stopRelay.comment.id, + source: "issue.stop_relay", + wakeReason: "issue_commented", + childIssueId: issue.id, + childStatus: issue.status, + }, + }); + } + } + const becameTerminal = !["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status); if (becameTerminal) { @@ -9688,22 +9802,23 @@ export function issueRoutes( const interruptRequested = req.body.interrupt === true; const isClosed = isClosedIssueStatus(issue.status); const isBlocked = issue.status === "blocked"; - const mentionGrantedPeerAgentCommentOnly = + const crossIssueCommentOnlyGrant = isClosed && - req.actor.type === "agent" && - issue.assigneeAgentId !== null && - issue.assigneeAgentId !== req.actor.agentId && - !reopenRequested && - !resumeRequested && - isIssueMentionGrantDecision(commentAccessDecision); - const effectiveReopenRequested = mentionGrantedPeerAgentCommentOnly ? false : reopenRequested; - const effectiveResumeRequested = mentionGrantedPeerAgentCommentOnly ? false : resumeRequested; + (isDirectParentReportDecision(commentAccessDecision) || + (req.actor.type === "agent" && + issue.assigneeAgentId !== null && + issue.assigneeAgentId !== req.actor.agentId && + !reopenRequested && + !resumeRequested && + isIssueMentionGrantDecision(commentAccessDecision))); + const effectiveReopenRequested = crossIssueCommentOnlyGrant ? false : reopenRequested; + const effectiveResumeRequested = crossIssueCommentOnlyGrant ? false : resumeRequested; if ( isClosed && req.actor.type === "agent" && issue.assigneeAgentId !== null && issue.assigneeAgentId !== req.actor.agentId && - !mentionGrantedPeerAgentCommentOnly + !crossIssueCommentOnlyGrant ) { if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; } @@ -10015,6 +10130,9 @@ export function issueRoutes( bodySnippet: comment.body.slice(0, 120), identifier: currentIssue.identifier, issueTitle: currentIssue.title, + ...(isDirectParentReportDecision(commentAccessDecision) + ? { directParentReportGrant: true } + : {}), ...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}), ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), ...(scheduledRetrySupersededByComment diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index a04dc81348..4ca0522947 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -102,6 +102,7 @@ export type AuthorizationDecision = { | "allow_consented_change" | "allow_legacy_agent_creator" | "allow_issue_mention_grant" + | "allow_direct_parent_report" | "allow_self" | "allow_company_agent" | "allow_company_member" @@ -238,6 +239,7 @@ type IssueAuthorizationRow = { parentId: string | null; assigneeAgentId: string | null; assigneeUserId: string | null; + checkoutRunId: string | null; status: string; executionPolicy: unknown; originKind: string | null; @@ -743,6 +745,7 @@ export function authorizationService(db: Db) { parentId: issues.parentId, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, + checkoutRunId: issues.checkoutRunId, status: issues.status, executionPolicy: issues.executionPolicy, originKind: issues.originKind, @@ -772,6 +775,46 @@ export function authorizationService(db: Db) { : null; } + async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) { + if (!runId) return null; + const row = await db + .select({ + companyId: heartbeatRuns.companyId, + agentId: heartbeatRuns.agentId, + contextSnapshot: heartbeatRuns.contextSnapshot, + }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.id, runId)) + .then((rows) => rows[0] ?? null); + if (!row || row.companyId !== companyId || row.agentId !== agentId) return null; + const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null; + const issueId = typeof context?.issueId === "string" + ? context.issueId.trim() + : typeof context?.taskId === "string" + ? context.taskId.trim() + : ""; + return issueId || null; + } + + async function isDirectParentReportTarget(input: { + actor: AuthorizationActor; + actorAgentId: string; + companyId: string; + resource: AuthorizationResource; + }) { + if (input.resource.type !== "issue" || !input.resource.issueId) return false; + const runIssueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgentId); + if (!runIssueId || runIssueId === input.resource.issueId) return false; + const runIssue = await loadIssue(runIssueId); + return Boolean( + runIssue && + runIssue.companyId === input.companyId && + runIssue.assigneeAgentId === input.actorAgentId && + runIssue.checkoutRunId === input.actor.runId && + runIssue.parentId === input.resource.issueId, + ); + } + async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) { const row = await db .select({ executionWorkspacePolicy: projects.executionWorkspacePolicy }) @@ -899,6 +942,7 @@ export function authorizationService(db: Db) { action: AuthorizationAction; resource: AuthorizationResource; resolution: TrustPresetResolution; + directParentReportTarget: boolean; }): Promise { if (input.resolution.kind === "standard") return null; if (input.resolution.kind === "denied") { @@ -962,6 +1006,21 @@ export function authorizationService(db: Db) { if (input.resource.type !== "issue") { return lowTrustDeny("Low-trust issue access is missing an issue resource."); } + if (input.action === "issue:comment" && input.directParentReportTarget) { + if ( + input.resource.issueId && + await agentHasMentionGrantOnIssue({ + action: input.action, + companyId: boundary.companyId, + issueId: input.resource.issueId, + issueAssigneeAgentId: input.resource.assigneeAgentId ?? null, + actorAgentId: input.actorAgentId, + }) + ) { + return allowIssueMentionGrant(input.action); + } + return lowTrustDeny("Direct-parent report comments are disabled for low-trust review runs."); + } if (await issueResourceWithinLowTrustBoundary(boundary, input.resource)) { return lowTrustAllow("Allowed inside the low-trust issue boundary."); } @@ -1682,16 +1741,26 @@ export function authorizationService(db: Db) { if (taskBridgeDecision) return taskBridgeDecision; } + const trustResolution = await resolveActorTrust({ + actorAgent, + actor: input.actor, + companyId, + resource: input.resource, + }); + const directParentReportTarget = + input.action === "issue:comment" && + await isDirectParentReportTarget({ + actor: input.actor, + actorAgentId, + companyId, + resource: input.resource, + }); const lowTrustDecision = await decideLowTrustAccess({ actorAgentId, action: input.action, resource: input.resource, - resolution: await resolveActorTrust({ - actorAgent, - actor: input.actor, - companyId, - resource: input.resource, - }), + resolution: trustResolution, + directParentReportTarget, }); if (lowTrustDecision) { if (!lowTrustDecision.allowed) return lowTrustDecision; @@ -1709,6 +1778,18 @@ export function authorizationService(db: Db) { } } + if ( + trustResolution.kind === "standard" && + input.action === "issue:comment" && + directParentReportTarget + ) { + return allow({ + action: input.action, + reason: "allow_direct_parent_report", + explanation: "Allowed because the target is the current run issue's direct parent under the standard trust preset.", + }); + } + if (input.action === "inbox:manage") { if (!isSimpleAssignableAgentStatus(actorAgent.status)) { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 9b45ca648a..9c83df4911 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -4756,9 +4756,66 @@ export function issueService(db: Db) { }); } + async function addStopRelayCommentIfNeeded( + child: typeof issues.$inferSelect, + dbOrTx: any = db, + ) { + if (!child.parentId || (child.status !== "blocked" && child.status !== "cancelled")) return null; + + const relayKey = `issue-stop-relay:${child.id}:${child.status}`; + await dbOrTx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${relayKey}, 0))`); + + const childIdentifier = child.identifier?.trim() || child.id; + const childPrefix = childIdentifier.split("-")[0] || "PAP"; + const body = `System relay: [${childIdentifier}](/${childPrefix}/issues/${childIdentifier}) transitioned to \`${child.status}\`.`; + const existingRelay = await dbOrTx + .select({ id: issueComments.id }) + .from(issueComments) + .where(and( + eq(issueComments.companyId, child.companyId), + eq(issueComments.issueId, child.parentId), + eq(issueComments.authorType, "system"), + eq(issueComments.body, body), + )) + .limit(1) + .then((rows: Array<{ id: string }>) => rows[0] ?? null); + if (existingRelay) return null; + + const parent = await dbOrTx + .select({ + id: issues.id, + companyId: issues.companyId, + assigneeAgentId: issues.assigneeAgentId, + status: issues.status, + }) + .from(issues) + .where(and(eq(issues.id, child.parentId), eq(issues.companyId, child.companyId))) + .then((rows: Array<{ + id: string; + companyId: string; + assigneeAgentId: string | null; + status: string; + }>) => rows[0] ?? null); + if (!parent) return null; + + const [comment] = await dbOrTx + .insert(issueComments) + .values({ + companyId: child.companyId, + issueId: parent.id, + authorType: "system", + body, + }) + .returning(); + await dbOrTx.update(issues).set({ updatedAt: new Date() }).where(eq(issues.id, parent.id)); + + return { comment, parent }; + } + return { clearExecutionRunIfTerminal, clearCheckoutRunIfTerminal, + addStopRelayCommentIfNeeded, list: async (companyId: string, filters?: IssueFilters) => { if (filters?.attention === "blocked") {