mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
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:
1 parent
429792f1f3
commit
a17bee98f2
4 files changed
+453
-20
No files matched your search
+128
-10
@@ -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
|
||||
|
||||
Reference in new issue
Block a user