Files
PaperClipAI/packages/shared/src/issue-write-denial.ts
T
DottaandClaude Opus 5.5 1b09920a64 fix(issues): refuse agent status changes that detach another run's live execution (#20271)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - A task that an agent run executes holds a checkout lock and an
execution lock (`checkoutRunId`, `executionRunId`)
> - A status change away from `in_progress` clears these locks. It does
not stop the run that holds them
> - Agents cannot stop another agent's run: run cancel and the task-tree
pause are board-only
> - But an agent that can edit a task (for example a lead with the
manage-active-checkouts grant over its report) can change its status.
The run then keeps running and is no longer bound to its task
> - In chat evals, a lead "paused" a launch tree. It moved running
subtasks to backlog. The runs kept running, detached
> - This pull request refuses an agent's status change when a different
queued or running run holds the task's lock. The denial is final, links
the task, and names the person's controls
> - The benefit is that agents cannot interrupt another agent's run
through a status edit, and runs are not left orphaned

## Linked Issues or Issue Description

Refs #20261 (chat guidance sends a task-tree pause to the person's Pause
subtree control). Refs #19394 (resume restarts the work a subtree pause
interrupted).

**What happened**

An agent changed the status of a task while another agent's run executed
it. The request succeeded. The server cleared `checkoutRunId` and
`executionRunId`. The run continued, but it was no longer bound to the
task. In the chat evals `steering.stop-versus-pause` and
`task-coordination.pause-tree`, a lead moved running subtasks to backlog
to "pause" a launch. Their runs kept running, detached.

**Expected behavior**

An agent cannot get the effect of interrupting another agent's run. When
a different live run holds the task's lock, the agent's status change is
refused before any write. The refusal says it is final. It sends the
person to the task and names the controls that really stop or hold the
work: the run's Stop or Interrupt, or More task actions, then Pause
subtree on the parent task. The lock-holding run, board users, and paths
that stop the run themselves continue to work.

**Steps to reproduce**

1. Create agent A and agent B, with B reporting to A (A gets
`tasks:manage_active_checkouts` through the manager chain).
2. Give B an `in_progress` task. Set `checkoutRunId` and
`executionRunId` to a `running` heartbeat run of B.
3. As A (agent JWT with its own run), send `PATCH /api/issues/:id` with
`{ "status": "backlog" }`.
4. Before this change: `200`, the status is `backlog`, both lock fields
are `null`, and B's run is still `running`.

## What Changed

- `server/src/routes/issues.ts`: new
`assertAgentStatusChangeKeepsLiveRunBound`. For an agent actor, it
refuses a status change when `executionRunId` or `checkoutRunId` names a
different run in the same company that is `queued` or `running`. It runs
in `PATCH /issues/:id` after the final status and assignee are known
(execution-policy transitions included) and before any run stop or
write. It also runs in recovery resolution when the source status
changes.
- The guard applies to any status other than `in_progress`, also to the
current status, because the service clears the locks for those too.
- The write is conditional on the lock the guard checked. The route
re-reads the lock and passes it to `svc.update` as
`expectedExecutionLock`. The service compares it under the row lock and
returns `409 issue_execution_lock_changed` if a checkout landed in
between.
- These cases stay allowed: the lock-holding run itself, board users,
requests with no status, a change to `in_progress`, `cancelled` (the
route cancels the run), reassignment (the route stops the run first),
`done` through PATCH while a Runner goal is active (terminalization
pauses the goal and stops the run; recovery resolution does not get this
exception), and a lock holder that already finished.
- `packages/shared/src/issue-write-denial.ts`: new denial code
`issue_status_change_live_run` (409). The copy says the refusal is
final, links the task as `[identifier](/prefix/issues/identifier)`, and
names Stop or Interrupt and More task actions, then Pause subtree. A new
optional `issuePath` context field carries the link. The UI notice
renders it through the existing generic path.
- `doc/SPEC-implementation.md`: documents the rule in the checkout
contract section.
- Paths checked with no change needed: there is no bulk issue update
route; tree holds and run cancel are board-only; `POST
/issues/:id/release` already lets only the assignee's checkout run
release; API-catalog actions call the same PATCH route; MCP status
changes already refuse with `MCP_ACTIVE_EXECUTION`.

## Verification

- Red on master, green with this change:
- `server/src/__tests__/issue-stale-execution-lock-routes.test.ts`
(embedded Postgres, real issue service): a manager moves its report's
running task to backlog. On master: `200`, status `backlog`,
`checkoutRunId`/`executionRunId` changed to `null`. With the fix: `409
issue_status_change_live_run`, row unchanged, run still `running`.
- `server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts`:
refusals for `backlog`, `todo`, `blocked`, `in_review`, `done`, a queued
lock holder, and a repeated current status (red on master). Allowed
cases: field edits, the lock-holding run, board, `cancelled` (run is
cancelled), and finished lock holders; an allowed change carries
`expectedExecutionLock`.
- Same embedded-Postgres file: `svc.update` with an
`expectedExecutionLock` that differs from the row returns `409
issue_execution_lock_changed` and leaves the row unchanged.
- Also passed locally: `issue-closed-workspace-routes`,
`issue-comment-reopen-routes`, `issue-recovery-actions`,
`issue-scheduled-retry-routes`, `low-trust-red-team-routes`,
`issue-update-comment-wakeup-routes`, `external-object-routes`,
`packages/shared` `issue-write-denial`, `ui` `IssueWriteDenialNotice`.
- `tsc --noEmit` shows no errors in the changed files. Full typecheck,
build and test are left to CI.

## Risks

- Behavior change for agents: a lead or CEO that used status edits to
take over a report's running task now gets a 409. The supported paths
(comment, cancel, reassign, or the person's Stop or Pause subtree) still
work.
- A queued run that holds the lock also blocks the change. This is
deliberate: the queued run would otherwise start later with no task
bound to it.
- Agents can still cancel another agent's run by cancelling its task.
That is existing, documented behavior ("task cancellation cancels the
intent"), and this PR does not change it.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with
tool use (shell, file edits, test execution).

## 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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 08:21:40 -06:00

363 lines
14 KiB
TypeScript

/**
* Copy contract for denied issue writes (open cross-task writes: failure UX).
*
* Cross-issue issue writes are default-open for standard-trust agents on issues
* they can already read (see SPEC-implementation §9.3). The remaining walls are
* rare — but a real incident burned a full detour discovering a workaround
* behind an opaque 403, so every one of them must say three things:
*
* 1. which boundary fired,
* 2. who *can* act,
* 3. the sanctioned path forward.
*
* This module is the single source of truth for that copy, so the API error
* body an agent reads and the notice a human sees in the UI are the same words.
* It is the issue-write sibling of `responsible-user-denial.ts`: the two
* responsible-user ceiling codes delegate to that module's copy so terminology
* ("on behalf of {user}", never "impersonate") stays consistent.
*/
import {
describeResponsibleUserDenial,
responsibleUserLabel,
type ResponsibleUserDenialCode,
} from "./responsible-user-denial.js";
export const ISSUE_WRITE_DENIAL_CODES = [
"issue_write_not_visible",
"issue_write_actor_class_excluded",
"issue_write_responsible_user_ceiling",
"issue_write_responsible_user_unavailable",
"issue_write_assignee_run_lock",
"issue_status_change_live_run",
"cross_issue_influence_cap_exceeded",
"cross_issue_influence_run_context_required",
"issue_write_attribution_spoof_rejected",
] as const;
export type IssueWriteDenialCode = (typeof ISSUE_WRITE_DENIAL_CODES)[number];
/**
* Why the write stopped, which drives icon + colour. `boundary` is an
* authorization wall, `lock` is run-lifecycle machinery that will clear on its
* own, `cap` is a rate backstop, and `attribution` is a rejected spoof.
*/
export type IssueWriteDenialTone = "boundary" | "lock" | "cap" | "attribution";
export interface IssueWriteDenialCopy {
code: IssueWriteDenialCode;
/** HTTP status the server pairs with this code. */
status: 403 | 409 | 422 | 429;
tone: IssueWriteDenialTone;
/** The boundary that fired, as a short noun phrase for a banner title. */
boundary: string;
/** Short heading. */
title: string;
/** What happened and why, in one or two sentences. */
description: string;
/** Who is able to perform this write instead. */
whoCanAct: string;
/** The supported way to get the work moving. */
sanctionedPath: string;
}
export interface IssueWriteDenialContext {
/** Display name of the agent or user that attempted the write. */
actorLabel?: string | null;
/** Display name of the responsible ("on behalf of") user for the attempt. */
responsibleUserName?: string | null;
/** Display name of the target issue's current assignee. */
assigneeLabel?: string | null;
/** Target issue identifier, e.g. `TASK-482`. */
issueIdentifier?: string | null;
/** Board path of the target issue, e.g. `/TASK/issues/TASK-482`. */
issuePath?: string | null;
/** Per-run cross-issue influence cap. */
cap?: number | null;
/** Attempt count that tripped the cap. */
count?: number | null;
/** ISO timestamp at which log-only rollout becomes enforcement. */
enforceAt?: string | null;
}
export function isIssueWriteDenialCode(
code: string | null | undefined,
): code is IssueWriteDenialCode {
return ISSUE_WRITE_DENIAL_CODES.includes(code as IssueWriteDenialCode);
}
/**
* Bridge the two responsible-user ceiling codes emitted by the authorization
* layer into this module's code space, so a single UI notice covers every way
* an issue write can be refused.
*/
export function issueWriteDenialCodeForResponsibleUserDenial(
code: ResponsibleUserDenialCode,
): IssueWriteDenialCode {
return code === "RESPONSIBLE_USER_UNAVAILABLE"
? "issue_write_responsible_user_unavailable"
: "issue_write_responsible_user_ceiling";
}
/** "this task" when the identifier is unknown, so copy never shows a raw id. */
function issueLabel(identifier: string | null | undefined): string {
const trimmed = identifier?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "this task";
}
/** "the assignee" when the name is unknown. */
function assigneeLabel(name: string | null | undefined): string {
const trimmed = name?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "the current assignee";
}
/** "this agent" when the name is unknown. */
function actorLabel(name: string | null | undefined): string {
const trimmed = name?.trim();
return trimmed && trimmed.length > 0 ? trimmed : "this agent";
}
/**
* The escape hatch an earlier incident had to discover by trial and error. Naming it in
* every boundary denial is the point of plan §6 — an agent that reads the error
* should not need a detour to find the supported path.
*/
const CHILD_ISSUE_PATH =
"create a child issue with the request in its description (issue creation is a " +
"separate, open write path) and let its assignee act";
export function describeIssueWriteDenial(
code: IssueWriteDenialCode,
context: IssueWriteDenialContext = {},
): IssueWriteDenialCopy {
const issue = issueLabel(context.issueIdentifier);
const actor = actorLabel(context.actorLabel);
const assignee = assigneeLabel(context.assigneeLabel);
const responsible = responsibleUserLabel(context.responsibleUserName);
switch (code) {
case "issue_write_not_visible":
return {
code,
status: 403,
tone: "boundary",
boundary: "Issue visibility",
title: "Task is outside this actor's visibility",
description:
`Issue writes are open by default, but only for tasks the actor can already ` +
`read. ${issue} is not visible to ${actor}, so its comment, update, child, and ` +
`assignment channels are all closed — the wall is visibility, not the write itself.`,
whoCanAct:
`${assignee}, and any agent or board member the task is visible to.`,
sanctionedPath:
`Ask the board to widen visibility for ${actor}, or ${CHILD_ISSUE_PATH}.`,
};
case "issue_write_actor_class_excluded":
return {
code,
status: 403,
tone: "boundary",
boundary: "Actor-class boundary",
title: "This actor class cannot write to tasks",
description:
`Default-open issue writes are a standard-trust privilege. Low-trust, ` +
`skill-test, and task-bridge scopes keep their existing tight walls, so ` +
`${actor} cannot write to ${issue} no matter who it acts for.`,
whoCanAct:
`A standard-trust agent in this company, or a board member.`,
sanctionedPath:
`Report the request upward and let a standard-trust agent make the write — ` +
`actor-class scope cannot be widened per task.`,
};
case "issue_write_responsible_user_ceiling": {
const ceiling = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAUTHORIZED", {
userName: context.responsibleUserName,
});
return {
code,
status: 403,
tone: "boundary",
boundary: "Responsible-user ceiling",
title: ceiling.title,
description: `${ceiling.description} The write to ${issue} was refused for that reason.`,
whoCanAct:
`${responsible} once authorized, or anyone already permitted to write to ${issue}.`,
sanctionedPath: ceiling.recommendedAction,
};
}
case "issue_write_responsible_user_unavailable": {
const unavailable = describeResponsibleUserDenial("RESPONSIBLE_USER_UNAVAILABLE", {
userName: context.responsibleUserName,
});
return {
code,
status: 403,
tone: "boundary",
// Distinct from the title, which already says "unavailable" — the
// boundary names the *mechanism*, so the two do not read as a stutter.
boundary: "Responsible-user availability",
title: unavailable.title,
description: `${unavailable.description} The write to ${issue} was refused for that reason.`,
whoCanAct: `A board member, or ${actor} once it has an active responsible user.`,
sanctionedPath: unavailable.recommendedAction,
};
}
case "issue_write_assignee_run_lock":
return {
code,
status: 409,
tone: "lock",
boundary: "Run checkout lock",
title: "Another agent's run owns this task",
description:
`${assignee} has ${issue} checked out and a run is live. Checkout and run ` +
`ownership stay assignee-scoped even though writes are open, so field edits ` +
`belong to the run that holds the lock until it finishes.`,
whoCanAct:
`${assignee}'s live run, or an agent holding the manage-active-checkouts permission.`,
sanctionedPath:
`Comment instead of patching — comments stay open and wake ${assignee} — or ` +
`wait for the run to release the lock and retry.`,
};
case "issue_status_change_live_run": {
const identifier = context.issueIdentifier?.trim();
const path = context.issuePath?.trim();
const taskLink = identifier && path ? `[${identifier}](${path})` : issue;
return {
code,
status: 409,
tone: "boundary",
boundary: "Live run execution lock",
title: "Another run is executing this task",
description:
`A run is live on ${issue} and holds its execution lock. Changing the status ` +
`would release that lock without stopping the run, so the run would keep ` +
`going with no task bound to it. Agents cannot stop or interrupt another ` +
`agent's run, so this status change is refused.`,
whoCanAct:
`A board user, or the run of ${assignee} that holds the lock.`,
sanctionedPath:
`This refusal is final: do not retry with another status, assignee or route. ` +
`Reply now and send the person to ${taskLink}. To stop the live run they use its ` +
`Stop or Interrupt control there; to hold this task and the tasks under it, they ` +
`open the parent task and choose More task actions, then Pause subtree. To pass ` +
`a request to the running agent, comment on the task instead.`,
};
}
case "cross_issue_influence_cap_exceeded": {
const cap = context.cap ?? 20;
const attempt = context.count ?? null;
return {
code,
status: 429,
tone: "cap",
// No parentheses: surfaces render the boundary inside their own parens.
boundary: `Per-run cross-issue cap of ${cap} writes`,
title: "This run has spent its cross-issue write budget",
description:
`A single heartbeat run may make at most ${cap} cross-issue comments or task ` +
`updates combined${attempt !== null ? `; this was attempt ${attempt}` : ""}. The cap ` +
`bounds runaway comment sprays and loops — it is a rate backstop, not a ` +
`permission decision, so ${actor} is still allowed to write to ${issue}.`,
whoCanAct:
`${actor} on its next heartbeat run, or ${assignee} on ${issue} directly.`,
sanctionedPath:
`Consolidate what is left into one comment on your own task, or end the run and ` +
`continue on the next heartbeat — the budget resets per run.`,
};
}
case "cross_issue_influence_run_context_required":
return {
code,
status: 403,
tone: "boundary",
boundary: "Heartbeat run context",
title: "Cross-issue writes need a run to attribute them to",
description:
`Every agent comment and task update is attributed to a heartbeat run so the ` +
`cross-issue cap can be counted and the audit trail can name who acted for whom. ` +
`This request arrived without a valid run, so it could not be contained.`,
whoCanAct: `${actor}, once the request carries its own run id.`,
sanctionedPath:
`Send the \`X-Paperclip-Run-Id\` header with your current run (\`$PAPERCLIP_RUN_ID\`) ` +
`and retry.`,
};
case "issue_write_attribution_spoof_rejected":
return {
code,
status: 422,
tone: "attribution",
boundary: "Server-derived attribution",
title: "Responsible user cannot be chosen by the caller",
description:
`\`onBehalfOfUserId\` is derived from the authenticated actor, never from the ` +
`request body — an agent cannot pick the human whose authority it rides. The ` +
`attempt was recorded in the audit log.`,
whoCanAct:
`${actor} itself: the write is allowed, only the chosen attribution is not.`,
sanctionedPath:
`Remove \`onBehalfOfUserId\` from the request and retry; the server fills in ` +
`${responsible} from your run.`,
};
}
}
/**
* Flatten a denial into the single `error` string an API client sees.
*
* Agents typically surface only `error`, so all three §6 obligations — boundary,
* who can act, sanctioned path — have to survive the flattening.
*/
export function issueWriteDenialApiMessage(copy: IssueWriteDenialCopy): string {
return [
`${copy.title} (${copy.boundary}).`,
copy.description,
`Who can act: ${copy.whoCanAct}`,
`Try this: ${copy.sanctionedPath}`,
].join(" ");
}
/**
* Build the full `{ error, details }` body for a denied issue write. Keeping the
* machine-readable `code` next to the prose lets the board UI render the same
* copy without parsing sentences.
*/
export function issueWriteDenialResponse(
code: IssueWriteDenialCode,
context: IssueWriteDenialContext = {},
): {
status: IssueWriteDenialCopy["status"];
body: {
error: string;
details: {
code: IssueWriteDenialCode;
boundary: string;
whoCanAct: string;
sanctionedPath: string;
} & Record<string, unknown>;
};
} {
const copy = describeIssueWriteDenial(code, context);
return {
status: copy.status,
body: {
error: issueWriteDenialApiMessage(copy),
details: {
code: copy.code,
boundary: copy.boundary,
whoCanAct: copy.whoCanAct,
sanctionedPath: copy.sanctionedPath,
},
},
};
}