feat(auth): default-open visible issue writes (#10804)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Agents coordinate through company-visible issues, comments, child
tasks, and assignments.
> - The current authorization rules give these write channels different
and narrow ownership grants.
> - Those differences prevent standard-trust agents from coordinating on
work that they can already read.
> - A responsible human user must still bound every agent action.
> - This pull request gives the four issue-write channels one
default-open rule based on issue visibility.
> - The benefit is consistent multi-agent coordination without weakening
company, user, trust-scope, or run-lifecycle controls.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

This improves authorization for comments, issue updates, child creation,
and assignment on company-visible issues.

**Subsystem affected**

`server/` REST API authorization and issue routes.

**Current behavior**

Standard-trust agents can read company-visible issues, but narrow
ownership, parent, or mention grants can still deny related writes. Each
write channel also applies a different rule.

**Proposed behavior**

Allow standard-trust agents to comment, update fields, create child
issues, and assign work when they can read the target issue and the
responsible user is also authorized. Keep company boundaries, low-trust
scopes, checkout conflicts, status rules, pause gates, budget gates, and
explicit reopen rules unchanged.

**Reason and benefit**

Agents can coordinate on visible company work without relay issues or
unnecessary manager runs. One shared rule also makes the authorization
model easier to test and maintain.

**Breaking changes**

This intentionally broadens write access for standard-trust agents on
visible issues. Existing company boundaries and governance controls
remain in force.

Related prior approaches: Refs #10233 and Refs #9768. This change
unifies the comment case with visible issue updates, child creation, and
assignment while preserving the responsible-user ceiling and excluded
trust scopes.

## What Changed

- Added a shared default-open authorization decision for visible issue
writes.
- Applied the shared rule to comments, issue updates, child creation,
and assignment.
- Preserved low-trust, `skill_test`, `task_bridge`, responsible-user,
checkout, lifecycle, pause, and budget controls.
- Preserved explicit resume/restore authority for direct peer lifecycle
transitions on blocked, completed, and cancelled issues.
- Added regression coverage for cross-company denial, user intersection,
excluded scopes, comment-read structure, closed issues, child creation,
assignment, and peer updates.
- Updated the V1 implementation contract for the shared rule.

## Verification

- `pnpm exec vitest run
server/src/__tests__/authorization-service.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/issue-comment-reopen-routes.test.ts
server/src/__tests__/low-trust-red-team-routes.test.ts --reporter=dot` —
217 tests passed.
- `pnpm --filter @paperclipai/server typecheck` — passed.
- `git diff --check public-gh/master...HEAD` — passed.
- Independent security review covered broken access control,
object-level authorization, excessive agency, cross-company access,
responsible-user intersection, excluded scopes, and lifecycle controls;
its peer lifecycle-transition finding is fixed with regression coverage.

## Risks

- Standard-trust agents gain broader write influence on issues that they
can already read.
- Future issue-visibility controls must keep `issue:read` as the
canonical authorization hook.
- Regression tests cover company boundaries, responsible-user
intersection, excluded scopes, active checkout conflicts, closed-issue
behavior, and non-transitive mention authority.

> 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 GPT-5 through Codex. The runtime does not expose the exact
deployment revision or context-window size. Agentic reasoning,
repository tools, shell execution, and test execution were enabled.

## 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-08-04 09:18:44 -05:00
1 parent cb52f0b750
commit dfcda67650
7 files changed
+434 -49

No files matched your search

@@ -501,6 +501,34 @@ describeEmbeddedPostgres("authorization service", () => {
expect(decision.explanation).toContain("Agent key cannot access another company");
});
it("denies cross-company default-open issue writes", async () => {
const sourceCompany = await createCompany(db, "WriteSource");
const targetCompany = await createCompany(db, "WriteTarget");
const actorAgent = await createAgent(db, sourceCompany.id);
const targetIssue = await createIssue(db, targetCompany.id);
const authorization = authorizationService(db);
for (const action of ["issue:comment", "issue:mutate"] as const) {
await expect(authorization.decide({
actor: {
type: "agent",
agentId: actorAgent.id,
companyId: sourceCompany.id,
source: "agent_jwt",
},
action,
resource: {
type: "issue",
companyId: targetCompany.id,
issueId: targetIssue.id,
},
})).resolves.toMatchObject({
allowed: false,
reason: "deny_company_boundary",
});
}
});
it("allows simple-mode task assignment between same-company agents without explicit grants", async () => {
const company = await createCompany(db, "AssignmentDefault");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
@@ -522,9 +550,153 @@ describeEmbeddedPostgres("authorization service", () => {
expect(decision).toMatchObject({
allowed: true,
reason: "allow_simple_company_member",
reason: "allow_visible_issue_write",
});
expect(decision.explanation).toContain("shared default-open");
});
it("allows standard-trust agents to comment on and update visible peer-owned issues", async () => {
const company = await createCompany(db, "DefaultOpenPeerWrites");
const actorAgent = await createAgent(db, company.id);
const ownerAgent = await createAgent(db, company.id);
const issue = await createIssue(db, company.id, { assigneeAgentId: ownerAgent.id });
const actor = {
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
source: "agent_jwt" as const,
};
const resource = {
type: "issue" as const,
companyId: company.id,
issueId: issue.id,
assigneeAgentId: ownerAgent.id,
status: issue.status,
};
const authorization = authorizationService(db);
for (const action of ["issue:comment", "issue:mutate"] as const) {
await expect(authorization.decide({ actor, action, resource })).resolves.toMatchObject({
allowed: true,
reason: "allow_visible_issue_write",
});
}
});
it("keeps the responsible-user ceiling on every default-open peer write", async () => {
const company = await createCompany(db, "DefaultOpenPeerWriteCeiling");
const actorAgent = await createAgent(db, company.id);
const ownerAgent = await createAgent(db, company.id);
const issue = await createIssue(db, company.id, { assigneeAgentId: ownerAgent.id });
const unavailableUserId = await createUser(db);
const actor = {
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
onBehalfOfUserId: unavailableUserId,
source: "agent_jwt" as const,
};
const resource = {
type: "issue" as const,
companyId: company.id,
issueId: issue.id,
assigneeAgentId: ownerAgent.id,
status: issue.status,
};
const authorization = authorizationService(db);
for (const action of ["issue:comment", "issue:mutate"] as const) {
await expect(authorization.decide({ actor, action, resource })).resolves.toMatchObject({
allowed: false,
code: "RESPONSIBLE_USER_UNAVAILABLE",
reason: "deny_missing_membership",
});
}
});
it("structurally keeps default-open comments inside issue visibility", async () => {
const company = await createCompany(db, "CommentReadSubset");
const project = await createProject(db, company.id, "Visible");
const outsideProject = await createProject(db, company.id, "Outside");
const rootIssueId = randomUUID();
const actorAgent = await createAgent(db, company.id, {
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [project.id],
rootIssueId,
},
},
},
});
const ownerAgent = await createAgent(db, company.id);
const visibleIssue = await createIssue(db, company.id, {
id: rootIssueId,
projectId: project.id,
assigneeAgentId: ownerAgent.id,
});
const hiddenIssue = await createIssue(db, company.id, {
projectId: outsideProject.id,
assigneeAgentId: ownerAgent.id,
});
const actor = {
type: "agent" as const,
agentId: actorAgent.id,
companyId: company.id,
source: "agent_key" as const,
};
const authorization = authorizationService(db);
for (const issue of [visibleIssue, hiddenIssue]) {
const resource = {
type: "issue" as const,
companyId: company.id,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
status: issue.status,
};
const read = await authorization.decide({ actor, action: "issue:read", resource });
const comment = await authorization.decide({ actor, action: "issue:comment", resource });
expect(comment.allowed && !read.allowed).toBe(false);
}
});
it("does not let default-open non-assignee comments mint mention grants", async () => {
const company = await createCompany(db, "DefaultOpenMentionNonTransitive");
const ownerAgent = await createAgent(db, company.id);
const commentingAgent = await createAgent(db, company.id);
const mentionedAgent = await createAgent(db, company.id);
const issue = await createIssue(db, company.id, { assigneeAgentId: ownerAgent.id });
await db.insert(issueComments).values({
companyId: company.id,
issueId: issue.id,
authorAgentId: commentingAgent.id,
body: `[@Mentioned](agent://${mentionedAgent.id}) please take a look`,
});
await expect(authorizationService(db).decide({
actor: {
type: "agent",
agentId: mentionedAgent.id,
companyId: company.id,
source: "agent_jwt",
},
action: "issue:comment",
resource: {
type: "issue",
companyId: company.id,
issueId: issue.id,
assigneeAgentId: ownerAgent.id,
status: issue.status,
},
})).resolves.toMatchObject({
allowed: true,
reason: "allow_visible_issue_write",
});
expect(decision.explanation).toContain("simple mode");
});
it("denies delegated protected assignment when the responsible user lacks matching authority", async () => {
@@ -822,6 +994,17 @@ describeEmbeddedPostgres("authorization service", () => {
action: "company_scope:read",
resource: { type: "company", companyId: company.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId: company.id,
projectId: project.id,
assigneeAgentId: collaborator.id,
},
scope: { projectId: project.id, assigneeAgentId: collaborator.id },
})).resolves.toMatchObject({ allowed: true, reason: "allow_simple_company_member" });
await expect(authorization.decide({
actor,
action: "tasks:assign",
@@ -803,21 +803,43 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects non-mentioned peer agents from posting comments", async () => {
it("allows non-mentioned peer agents to post comments on visible issues", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:read",
allowed: input.action === "issue:read" || input.action === "issue:comment",
action: input.action,
reason: input.action === "issue:read" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "issue:read" ? "Allowed by test read grant." : "Missing permission.",
reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_explicit_grant",
explanation: "Allowed by the shared visible-issue write rule.",
}));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "I was not mentioned." });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Issue is outside this actor's authorization boundary");
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalledWith(
issueId,
"I was not mentioned.",
expect.any(Object),
expect.any(Object),
);
});
it("keeps default-open peer comments on closed issues inert", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "done", assigneeAgentId: ownerAgentId }));
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:read",
action: input.action,
reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_company_agent",
explanation: "Allowed by the shared visible-issue write rule.",
}));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/comments`)
.send({ body: "Closed issue context only." });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
});
it("rejects peer agents from listing comments when issue read is outside their boundary", async () => {
@@ -899,7 +921,7 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockIssueService.getComment).not.toHaveBeenCalled();
});
it("keeps true issue mutations denied for mentioned peer agents", async () => {
it("allows visible issue field updates for peer agents", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:comment" || input.action === "issue:mutate",
@@ -922,9 +944,11 @@ describe("agent issue mutation checkout ownership", () => {
.patch(`/api/issues/${issueId}`)
.send({ status: "done" });
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.update).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ status: "done" }),
);
});
it("denies cross-company agents before comment authorization is evaluated", async () => {
@@ -1207,6 +1231,24 @@ describe("agent issue mutation checkout ownership", () => {
);
});
it("authorizes child creation through the shared visible-issue write path", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: ownerAgentId }));
const res = await request(await createApp(peerActor()))
.post(`/api/issues/${issueId}/children`)
.send({ title: "Peer-created child" });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "issue:mutate",
resource: expect.objectContaining({ issueId }),
}));
expect(mockIssueService.createChild).toHaveBeenCalledWith(
issueId,
expect.objectContaining({ title: "Peer-created child" }),
);
});
it("preserves explicit workspace choices on agent-created root issues", async () => {
const app = await createApp(
ownerActor(),
@@ -1492,18 +1534,36 @@ describe("agent issue mutation checkout ownership", () => {
it.each([
["todo", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Todo update" })],
["blocked", "patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked update" })],
])("rejects peer agent %s issue %s mutations outside active checkout ownership", async (status, _kind, sendRequest) => {
])("allows peer agent %s issue %s updates outside active checkout ownership", async (status, _kind, sendRequest) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status: status as "todo" | "blocked", assigneeAgentId: ownerAgentId }));
const res = await sendRequest(await createApp(peerActor()));
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockIssueService.assertCheckoutOwner).not.toHaveBeenCalled();
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
});
it.each([
["done", "todo", 403, "Agent cannot request follow-up for another agent's issue"],
["cancelled", "todo", 409, "Cancelled issues must be restored through the dedicated restore flow"],
["blocked", "done", 403, "Agent cannot request follow-up for another agent's issue"],
])(
"rejects peer agent direct status transitions from %s to %s",
async (status, nextStatus, expectedStatus, expectedError) => {
mockIssueService.getById.mockResolvedValue(makeIssue({ status, assigneeAgentId: ownerAgentId }));
const res = await request(await createApp(peerActor()))
.patch(`/api/issues/${issueId}`)
.send({ status: nextStatus });
expect(res.status, JSON.stringify(res.body)).toBe(expectedStatus);
expect(res.body.error).toBe(expectedError);
expect(mockIssueService.update).not.toHaveBeenCalled();
},
);
it("allows same-company agent mutations on unassigned in-progress issues", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: null }));
mockIssueService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
@@ -537,7 +537,7 @@ describe.sequential("issue comment reopen routes", () => {
));
});
it("rejects non-assignee agent POST comments on closed issues", async () => {
it("allows default-open non-assignee POST comments on closed issues without reopening", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
@@ -549,6 +549,12 @@ describe.sequential("issue comment reopen routes", () => {
authorAgentId: "33333333-3333-4333-8333-333333333333",
authorUserId: null,
});
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: input.action !== "tasks:manage_active_checkouts",
action: input.action,
reason: input.action === "issue:comment" ? "allow_visible_issue_write" : "allow_explicit_grant",
explanation: "Allowed by the shared visible-issue write rule.",
}));
const res = await request(await installActor(createApp(), {
type: "agent",
@@ -560,10 +566,9 @@ describe.sequential("issue comment reopen routes", () => {
.post("/api/issues/11111111-1111-4111-8111-111111111111/comments")
.send({ body: "hello" });
expect(res.status).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
expect(res.status).toBe(201);
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
@@ -1419,7 +1424,7 @@ describe.sequential("issue comment reopen routes", () => {
);
});
it("rejects non-assignee agent PATCH comments on closed issues", async () => {
it("allows default-open non-assignee PATCH comments on closed issues without reopening", async () => {
mockIssueService.getById.mockResolvedValue(makeIssue("done"));
mockIssueService.addComment.mockResolvedValue({
id: "comment-1",
@@ -1446,10 +1451,9 @@ describe.sequential("issue comment reopen routes", () => {
.patch("/api/issues/11111111-1111-4111-8111-111111111111")
.send({ comment: "hello" });
expect(res.status).toBe(403);
expect(res.body.error).toBe("Agent cannot mutate another agent's issue");
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(res.status).toBe(200);
expect(mockIssueService.update).toHaveBeenCalled();
expect(mockIssueService.addComment).toHaveBeenCalled();
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
});
@@ -786,7 +786,7 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
});
});
it("allows only standard checked-out runs to comment one hop upward", async () => {
it("preserves direct-parent reporting while default-opening visible standard-trust writes", async () => {
const fixture = await seedLowTrustFixture(db);
const standardApp = createApp(db, standardReportActor(fixture));
const lowTrustApp = createApp(db, agentActor(fixture));
@@ -810,25 +810,31 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () =>
.send({ body: "Contained report must not cross" });
expect(lowTrustParentComment.status, JSON.stringify(lowTrustParentComment.body)).toBe(403);
const forbiddenStandardWrites = [
const defaultOpenComments = [
request(standardApp)
.post(`/api/issues/${fixture.issues.reviewGrandparent.id}/comments`)
.send({ body: "No grandparent report" }),
.send({ body: "Visible grandparent context" }),
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" }),
.send({ body: "Visible sibling context" }),
];
for (const forbiddenWrite of forbiddenStandardWrites) {
const response = await forbiddenWrite;
expect(response.status, JSON.stringify(response.body)).toBe(403);
for (const defaultOpenComment of defaultOpenComments) {
const response = await defaultOpenComment;
expect(response.status, JSON.stringify(response.body)).toBe(201);
}
const checkedOutPeerUpdate = await request(standardApp)
.patch(`/api/issues/${fixture.issues.reviewRoot.id}`)
.send({ status: "blocked" });
expect(checkedOutPeerUpdate.status, JSON.stringify(checkedOutPeerUpdate.body)).toBe(409);
expect(checkedOutPeerUpdate.body.error).toBe("Issue is checked out by another agent");
const documentWrite = await request(standardApp)
.put(`/api/issues/${fixture.issues.reviewRoot.id}/documents/upward-write`)
.send({ format: "markdown", body: "No upward document write" });
expect(documentWrite.status, JSON.stringify(documentWrite.body)).toBe(409);
expect(documentWrite.body.error).toBe("Issue is checked out by another agent");
for (const closedParent of [
{ assigneeAgentId: null, intent: { reopen: true } },
{ assigneeAgentId: fixture.agents.standard.id, intent: { resume: true } },