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:
Dotta 2026-08-04 09:18:44 -05:00 committed by GitHub
parent cb52f0b750
commit dfcda67650
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 434 additions and 49 deletions

View File

@ -531,8 +531,9 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
- Bearer API key mapped to one agent and company
- Agent key scope:
- read org/task/company context for own company
- read/write own assigned tasks and comments
- create tasks/comments for delegation
- read company-visible tasks and comments
- comment on and update visible tasks under the shared write rule
- create child tasks and assign visible work for delegation under the same rule
- report heartbeat status
- report cost events
- Agent cannot:
@ -557,6 +558,21 @@ Detailed ownership, execution, blocker, active-run watchdog, crash-recovery, and
| Manage another user's inbox state | yes | scoped `inbox:manage` grant |
| Set work-object visibility (issue/project) | no | no (pro gate) |
### 9.3.1 Shared default-open issue writes
For standard-trust agents, issue comments, issue field/status updates, child
creation under a parent, and assignment share one authorization rule: the
target issue must be visible to the agent and the responsible user represented
by the run must also be authorized. In V1, issue visibility defaults to the
whole company, so these writes are company-wide by default.
The shared rule does not widen low-trust, `skill_test`, or `task_bridge` key
scopes. It also does not replace run-lifecycle controls: checkout ownership,
active-run conflicts, status-transition validation, interaction ownership,
budget gates, and pause gates remain independently enforced. Comment access is
structurally downstream of issue read access (`issue:comment` is a subset of
`issue:read`).
## 9.4 Permission Terminology and Default Visibility Rule
Paperclip V1 keeps a company-scoped visibility model as the default because centralized authorization and scoped work-object controls are not yet a core V1 control surface.

View File

@ -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",

View File

@ -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>) => ({

View File

@ -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();
});

View File

@ -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 } },

View File

@ -3469,6 +3469,22 @@ export function issueRoutes(
return false;
}
async function assertIssueWriteInfluenceAllowed(
req: Request,
res: Response,
issue: Parameters<typeof decideIssueAccess>[1],
) {
if (req.actor.type !== "agent") return true;
// Watchdog child creation keeps its dedicated subtree/revalidation grant;
// assertTaskWatchdogCreateIssueAllowed performs that check immediately
// after this generic collaboration gate at both create call sites.
if ((await resolveTaskWatchdogMutationScope(db, req.actor)).kind !== "none") return true;
const decision = await decideIssueAccess(req, issue, "issue:mutate");
if (decision.allowed) return true;
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
return false;
}
async function assertAgentIssueCommentAllowed(
req: Request,
res: Response,
@ -3519,6 +3535,10 @@ export function issueRoutes(
return decision !== true && decision.reason === "allow_direct_parent_report";
}
function isDefaultOpenIssueWriteDecision(decision: true | Awaited<ReturnType<typeof decideIssueAccess>>) {
return decision !== true && decision.reason === "allow_visible_issue_write";
}
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);
@ -3566,6 +3586,7 @@ export function issueRoutes(
assigneeAgentId: string | null;
assigneeUserId: string | null;
},
options: { allowVisibleIssueWrite?: boolean } = {},
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
@ -3617,7 +3638,7 @@ export function issueRoutes(
actorAgentId,
},
});
} else {
} else if (!options.allowVisibleIssueWrite) {
res.status(403).json({
error: "Agent cannot mutate another agent's issue",
details: {
@ -3629,7 +3650,7 @@ export function issueRoutes(
},
});
}
return false;
return issue.status !== "in_progress" && options.allowVisibleIssueWrite === true;
}
if (issue.status !== "in_progress") {
return true;
@ -7168,7 +7189,7 @@ export function issueRoutes(
res.status(404).json({ error: "Parent issue not found" });
return;
}
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, createParent))) return;
if (!isTaskBridgeKeyActor(req) && !(await assertIssueWriteInfluenceAllowed(req, res, createParent))) return;
}
if (
!watchdogProductBugFollowUp &&
@ -7386,7 +7407,7 @@ export function issueRoutes(
const parentId = req.params.id as string;
const parent = await getAccessibleResource(req, res, svc.getById(parentId), "Parent issue not found");
if (!parent) return;
if (!isTaskBridgeKeyActor(req) && !(await assertIssueReadAllowed(req, res, parent))) return;
if (!isTaskBridgeKeyActor(req) && !(await assertIssueWriteInfluenceAllowed(req, res, parent))) return;
if (!(await assertTaskWatchdogCreateIssueAllowed(req, res, parent.companyId, parent))) return;
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
@ -7821,7 +7842,7 @@ export function issueRoutes(
const existing = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!existing) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, existing, { allowVisibleIssueWrite: true }))) return;
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return;
const actor = getActorInfo(req);
@ -7861,7 +7882,12 @@ export function issueRoutes(
return;
}
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
const agentStatusTransitionRequiresResumeAuthority =
req.actor.type === "agent" &&
typeof updateFields.status === "string" &&
updateFields.status !== existing.status &&
(isBlocked || (isClosed && !isClosedIssueStatus(updateFields.status)));
if (resumeRequested !== true && req.actor.type === "agent" && reopenRequested === true) {
if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
}
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
@ -7890,6 +7916,13 @@ export function issueRoutes(
) {
return;
}
if (
resumeRequested !== true &&
agentStatusTransitionRequiresResumeAuthority &&
!(await assertExplicitResumeIntentAllowed(req, res, existing))
) {
return;
}
const scheduledRetryForHumanComment =
shouldHumanCommentResumeInProgressScheduledRetry({
hasComment: !!commentBody,
@ -10133,7 +10166,8 @@ export function issueRoutes(
issue.assigneeAgentId !== req.actor.agentId &&
!reopenRequested &&
!resumeRequested &&
isIssueMentionGrantDecision(commentAccessDecision)));
(isIssueMentionGrantDecision(commentAccessDecision) ||
isDefaultOpenIssueWriteDecision(commentAccessDecision))));
const effectiveReopenRequested = crossIssueCommentOnlyGrant ? false : reopenRequested;
const effectiveResumeRequested = crossIssueCommentOnlyGrant ? false : resumeRequested;
if (

View File

@ -106,6 +106,7 @@ export type AuthorizationDecision = {
| "allow_legacy_agent_creator"
| "allow_issue_mention_grant"
| "allow_direct_parent_report"
| "allow_visible_issue_write"
| "allow_self"
| "allow_company_agent"
| "allow_company_member"
@ -1405,6 +1406,76 @@ export function authorizationService(db: Db) {
const permissionKey = permissionForAction(input.action);
const companyId = companyIdForResource(input.resource);
/**
* Shared default-open decision for issue write-influence channels.
*
* Keep visibility structurally upstream of every standard-trust write so
* future visibility scoping can be implemented in issue:read without
* recreating per-action scope checks. The responsible-user ceiling remains
* in decide(), after this base decision.
*/
async function decideVisibleIssueWrite(): Promise<AuthorizationDecision> {
let visibilityResource: AuthorizationResource = input.resource;
// New-child assignment decisions identify the issue being influenced by
// parentIssueId. Resolve that parent into the same resource shape used by
// issue:read so child-create and assign share the visibility hook.
if (
input.resource.type === "issue" &&
!input.resource.issueId &&
input.resource.parentIssueId
) {
const parent = await loadIssue(input.resource.parentIssueId);
if (!parent || parent.companyId !== companyId) {
return deny({
action: input.action,
reason: "deny_company_boundary",
explanation: "The issue write target is not visible in this company.",
});
}
visibilityResource = {
type: "issue",
companyId: parent.companyId,
issueId: parent.id,
projectId: parent.projectId,
parentIssueId: parent.parentId,
assigneeAgentId: parent.assigneeAgentId,
assigneeUserId: parent.assigneeUserId,
originKind: parent.originKind,
originId: parent.originId,
status: parent.status,
};
}
const visibilityDecision = visibilityResource.type === "issue" && visibilityResource.issueId
? await decideBase({
actor: input.actor,
action: "issue:read",
resource: visibilityResource,
scope: input.scope,
})
: await decideBase({
actor: input.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
scope: input.scope,
});
if (!visibilityDecision.allowed) {
return {
...visibilityDecision,
action: input.action,
explanation: `Issue write denied because the target is not visible: ${visibilityDecision.explanation}`,
};
}
return allow({
action: input.action,
reason: "allow_visible_issue_write",
explanation: "Allowed by the shared default-open visible-issue write rule.",
});
}
async function decideWithTaskAssignmentGrants(
principalType: PrincipalType,
principalId: string,
@ -1809,6 +1880,15 @@ export function authorizationService(db: Db) {
}
}
const visibleIssueWriteDecision =
trustResolution.kind === "standard" &&
(input.action === "issue:comment" || input.action === "issue:mutate")
? await decideVisibleIssueWrite()
: null;
if (visibleIssueWriteDecision && !visibleIssueWriteDecision.allowed) {
return visibleIssueWriteDecision;
}
if (
trustResolution.kind === "standard" &&
input.action === "issue:comment" &&
@ -1994,10 +2074,11 @@ export function authorizationService(db: Db) {
if (grantDecision.allowed) return grantDecision;
return denyRestrictedAssignmentPolicy(policyEffect);
}
if (trustResolution.kind === "standard") return decideVisibleIssueWrite();
return allow({
action: input.action,
reason: "allow_simple_company_member",
explanation: "Allowed by simple mode company-wide task assignment default.",
explanation: "Allowed by the existing bounded task assignment rule.",
});
}
@ -2030,6 +2111,7 @@ export function authorizationService(db: Db) {
) {
return allowIssueMentionGrant(input.action);
}
if (visibleIssueWriteDecision) return visibleIssueWriteDecision;
}
if (
input.action === "agent_config:update" &&