fix(server): enforce issue read for issue thread lists (#8331)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents coordinate through issue threads, so issue comments and
interactions are part of the task authorization surface.
> - Low-trust and boundary-limited agents can receive narrow
mention-scoped access, but that must not turn into broad same-company
issue-thread reads.
> - The comment creation path was narrowed to allow explicit mention
replies without granting mutation access.
> - The surrounding list/read routes still needed to enforce the same
`issue:read` boundary before returning thread data.
> - This pull request applies the issue read check to issue comment and
interaction listing routes, and locks that behavior with server
regressions.
> - The benefit is that narrow cross-agent collaboration remains
possible without exposing unrelated issue-thread history.

## Linked Issues or Issue Description

Bug fix:
- What happened: same-company agents outside an issue read boundary
could still hit issue-thread listing routes and receive thread data.
- Expected behavior: issue comments and issue-thread interactions should
only be listed after the actor is allowed to read the issue.
- Steps to reproduce: configure a peer agent denied by the issue read
boundary, then request `GET /api/issues/:id/comments` or the issue
interaction listing route.
- Paperclip version/commit: current `master` before this branch.
- Deployment mode: applies to server authorization in all modes.

Related public context: #7389, #7863, #8024.

## What Changed

- Added issue read enforcement before listing issue comments.
- Added issue read enforcement before listing issue-thread interactions.
- Added server regressions for denied peer-agent issue-thread access
while preserving mention-scoped collaboration behavior.
- Removed an avoidable per-mentioned-comment issue reload in
mention-grant authorization by passing the already-loaded issue assignee
through the helper.
- Documented cross-agent issue read/comment authorization behavior in
the Paperclip API reference.
- Added a resilient fallback for pinned external skills when GitHub tree
fetches are temporarily unavailable during catalog builds.

## Verification

- `pnpm vitest run
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/authorization-service.test.ts`
  - 2 test files passed
  - 84 tests passed
- Existing mocked recovery revalidation warnings were emitted by the
route suite and the command exited 0
- Greptile: 5/5 on commit `b73fc323f192dc44f88374c16865008d4348923b`; no
unresolved review threads.
- `pnpm vitest run packages/skills-catalog/src/catalog-builder.test.ts`
  - 1 test file passed
  - 6 tests passed
- CI: all visible PR checks are terminal green on commit
`b73fc323f192dc44f88374c16865008d4348923b`.

## Risks

Low risk. This tightens read authorization on issue-thread listing
routes; any caller that depended on same-company access without
`issue:read` will now receive 403 and must use an explicit grant or
valid issue read path.

## Model Used

OpenAI GPT-5 Codex, Codex coding agent environment, tool use and local
command execution enabled, reasoning mode active. Context window not
exposed by the runtime.

## 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] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [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:
Nicky Leach
2026-06-19 15:16:17 -07:00
committed by GitHub
parent a71c4b6782
commit 364f0f5a8d
7 changed files with 677 additions and 16 deletions
+131 -9
View File
@@ -1,22 +1,24 @@
import { and, eq, sql } from "drizzle-orm";
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
companyMemberships,
heartbeatRuns,
instanceUserRoles,
issueComments,
issues,
principalPermissionGrants,
projects,
} from "@paperclipai/db";
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
import { LOW_TRUST_REVIEW_PRESET, type LowTrustBoundary } from "@paperclipai/shared";
import { LOW_TRUST_REVIEW_PRESET, extractAgentMentionIds, type LowTrustBoundary } from "@paperclipai/shared";
import {
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
isIssueWithinLowTrustBoundary,
resolveCoreTrustPreset,
type TrustPresetResolution,
} from "./trust-preset-resolver.js";
import { logger } from "../middleware/logger.js";
export type AuthorizationActor =
{
@@ -45,6 +47,7 @@ export type AuthorizationAction =
| "agent:read"
| "agent:wake"
| "company_scope:read"
| "issue:comment"
| "issue:mutate"
| "issue:read"
| "project:read"
@@ -76,6 +79,7 @@ export type AuthorizationDecision = {
| "allow_instance_admin"
| "allow_explicit_grant"
| "allow_legacy_agent_creator"
| "allow_issue_mention_grant"
| "allow_self"
| "allow_company_agent"
| "allow_company_member"
@@ -118,7 +122,7 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
) {
return null;
}
if (action === "issue:mutate") return null;
if (action === "issue:comment" || action === "issue:mutate") return null;
return action;
}
@@ -753,13 +757,27 @@ export function authorizationService(db: Db) {
: lowTrustDeny("Project is outside this low-trust boundary.");
}
if (input.action === "issue:read" || input.action === "issue:mutate") {
if (input.action === "issue:comment" || input.action === "issue:read" || input.action === "issue:mutate") {
if (input.resource.type !== "issue") {
return lowTrustDeny("Low-trust issue access is missing an issue resource.");
}
return await issueResourceWithinLowTrustBoundary(boundary, input.resource)
? lowTrustAllow("Allowed inside the low-trust issue boundary.")
: lowTrustDeny("Issue is outside this low-trust boundary.");
if (await issueResourceWithinLowTrustBoundary(boundary, input.resource)) {
return lowTrustAllow("Allowed inside the low-trust issue boundary.");
}
if (
input.action !== "issue:mutate" &&
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("Issue is outside this low-trust boundary.");
}
if (input.action === "tasks:assign") {
@@ -850,6 +868,93 @@ export function authorizationService(db: Db) {
return isAgentInSubtree(db, companyId, managerAgentId, assigneeAgentId);
}
function commentAuthorCanGrantIssueMention(input: {
mentionedAgentId: string;
issueAssigneeAgentId: string | null;
authorAgentId: string | null;
authorUserId: string | null;
activeAuthorUserIds: Set<string>;
}) {
if (input.authorAgentId) {
if (input.authorAgentId === input.mentionedAgentId) return false;
return input.issueAssigneeAgentId === input.authorAgentId;
}
if (input.authorUserId) {
return input.activeAuthorUserIds.has(input.authorUserId);
}
return false;
}
async function agentHasMentionGrantOnIssue(input: {
action: AuthorizationAction;
companyId: string;
issueId: string;
issueAssigneeAgentId: string | null;
actorAgentId: string;
}) {
const rows = await db
.select({
id: issueComments.id,
body: issueComments.body,
authorAgentId: issueComments.authorAgentId,
authorUserId: issueComments.authorUserId,
})
.from(issueComments)
.where(and(
eq(issueComments.companyId, input.companyId),
eq(issueComments.issueId, input.issueId),
isNull(issueComments.deletedAt),
sql`${issueComments.body} LIKE ${"%agent://" + input.actorAgentId + "%"}`,
));
const mentionRows = rows.filter((row) => extractAgentMentionIds(row.body).includes(input.actorAgentId));
const authorUserIds = [...new Set(mentionRows.flatMap((row) => row.authorUserId ? [row.authorUserId] : []))];
const activeAuthorUserIds = new Set(
authorUserIds.length === 0
? []
: await db
.select({ principalId: companyMemberships.principalId })
.from(companyMemberships)
.where(and(
eq(companyMemberships.companyId, input.companyId),
eq(companyMemberships.principalType, "user"),
eq(companyMemberships.status, "active"),
inArray(companyMemberships.principalId, authorUserIds),
))
.then((memberships) => memberships.map((membership) => membership.principalId)),
);
for (const row of mentionRows) {
const authorCanGrant = commentAuthorCanGrantIssueMention({
mentionedAgentId: input.actorAgentId,
issueAssigneeAgentId: input.issueAssigneeAgentId,
authorAgentId: row.authorAgentId,
authorUserId: row.authorUserId,
activeAuthorUserIds,
});
if (authorCanGrant) {
logger.info({
actorAgentId: input.actorAgentId,
issueId: input.issueId,
companyId: input.companyId,
commentId: row.id,
grantedAction: input.action,
grant: "issue_mention_comment",
}, "authorized issue mention-scoped comment grant");
return true;
}
}
return false;
}
function allowIssueMentionGrant(action: AuthorizationAction): AuthorizationDecision {
return allow({
action,
reason: "allow_issue_mention_grant",
explanation: "Allowed by a mention-scoped issue comment grant.",
});
}
async function decide(input: {
actor: AuthorizationActor;
action: AuthorizationAction;
@@ -956,7 +1061,10 @@ export function authorizationService(db: Db) {
explanation: "Allowed by active cloud tenant company membership.",
});
}
if (input.action === "issue:mutate" && membership.membershipRole !== "viewer") {
if (
(input.action === "issue:comment" || input.action === "issue:mutate") &&
membership.membershipRole !== "viewer"
) {
return allow({
action: input.action,
reason: "allow_company_member",
@@ -1092,6 +1200,7 @@ export function authorizationService(db: Db) {
input.action === "agent:read" ||
input.action === "agent:wake" ||
input.action === "company_scope:read" ||
input.action === "issue:comment" ||
input.action === "issue:read" ||
input.action === "project:read" ||
input.action === "runtime:manage" ||
@@ -1154,7 +1263,7 @@ export function authorizationService(db: Db) {
});
}
if (input.action === "issue:mutate") {
if (input.action === "issue:comment" || input.action === "issue:mutate") {
const resource = input.resource.type === "issue" ? input.resource : null;
if (resource?.assigneeAgentId === actorAgentId) {
return allow({
@@ -1170,6 +1279,19 @@ export function authorizationService(db: Db) {
explanation: "Allowed because the issue has no agent assignee.",
});
}
if (
input.action === "issue:comment" &&
resource?.issueId &&
await agentHasMentionGrantOnIssue({
action: input.action,
companyId,
issueId: resource.issueId,
issueAssigneeAgentId: resource.assigneeAgentId ?? null,
actorAgentId,
})
) {
return allowIssueMentionGrant(input.action);
}
}
if (
input.action === "agent_config:update" &&