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:
@@ -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" &&
|
||||
|
||||
Reference in New Issue
Block a user