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:
DottaandPaperclip authored and GitHub committed 2026-07-23 13:03:04 -05:00
1 parent 429792f1f3
commit a17bee98f2
4 files changed
+453 -20

No files matched your search

+128 -10
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