Allow trust-gated direct-parent issue reports (#10098)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-07-23 13:03:04 -05:00 committed by GitHub
parent 429792f1f3
commit a17bee98f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 453 additions and 20 deletions

View File

@ -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({

View File

@ -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<ReturnType<typeof decideIssueAccess>>) {
return decision !== true && decision.reason === "allow_direct_parent_report";
}
async function filterIssuesForActor<T extends Parameters<typeof decideIssueAccess>[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<ReturnType<typeof svc.addStopRelayCommentIfNeeded>>;
} = { 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

View File

@ -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<AuthorizationDecision | null> {
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)) {

View File

@ -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") {