mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
Allow trust-gated direct-parent issue reports (#10098)
## Thinking Path > - Paperclip is the control plane used to coordinate and govern AI-agent companies. > - Agent issue access must preserve company boundaries and trust-policy containment without preventing legitimate task coordination. > - Checked-out standard-trust child runs need a narrow way to report progress directly to their parent issue, but existing authorization treated that report like an arbitrary cross-boundary write. > - Low-trust review runs must remain contained, and stop propagation must not copy potentially untrusted child prose into a higher-trust parent context. > - This pull request adds an audited, one-hop direct-parent comment grant only for standard checked-out runs and a sanitized, idempotent relay for blocked or cancelled child stops. > - The benefit is restored parent/child liveness while retaining least privilege, complete mediation, and low-trust output quarantine. ## Linked Issues or Issue Description ### What happened? A standard-trust agent running a checked-out child issue could not post a progress comment to the direct parent issue because the authorization boundary treated it as an arbitrary cross-issue write. This could stall parent/child coordination. Low-trust review runs also need stop propagation without exposing quarantined child-authored prose. ### Expected behavior A standard checked-out child run may add a comment only to its direct parent issue. The grant must not allow grandparent or sibling access, issue mutation, document writes, reopening, or resuming. Low-trust runs remain denied unless separately mentioned, while blocked/cancelled stops relay only sanitized system metadata once. ### Steps to reproduce 1. Create a parent issue and a child issue assigned to different standard-trust agents. 2. Check out the child issue in a heartbeat run and authenticate as that run. 3. Post a comment to the parent issue and observe the authorization denial before this change. 4. Mark a low-trust child blocked or cancelled and observe that no bounded sanitized parent notification preserves liveness before this change. ### Paperclip version or commit Reproduces on `master` before this PR, including base commit `d36ea13e08`. ### Deployment mode Local dev (`pnpm dev`). ### Installation method Built from source (`pnpm dev` / `pnpm build`). ### Agent adapter(s) involved Not adapter-specific (core authorization and issue-routing behavior). ### Database mode External Postgres in the focused route regression suite; behavior is database-mode independent. ### Access context Agent (bearer API key associated with a checked-out heartbeat run). ### Additional context The implementation deliberately distinguishes a direct-parent report decision from general issue mutation permission and records successful grants in the activity log. ### Privacy checklist - [x] I have reviewed all pasted output for PII, API keys, tokens, company names, and private instance references. ## What Changed - Adds a distinct authorization decision for standard checked-out runs commenting on their direct parent issue. - Keeps low-trust direct-parent reports denied unless an existing explicit mention grant applies. - Forces direct-parent grants to remain comment-only even when a closed parent is unassigned or assigned to the reporting agent. - Audits successful direct-parent report grants in issue activity details. - Adds sanitized, parent-scoped, idempotent system comments and parent wakeups for blocked or cancelled child stops. - Extends the low-trust red-team route suite for allowed parent reports, forbidden upward/sibling writes, closed-parent mutation suppression, and non-laundering stop relays. ## Verification - `pnpm exec vitest run server/src/__tests__/low-trust-red-team-routes.test.ts` — 11 tests passed after the review fix. - `pnpm --filter @paperclipai/server typecheck` — passed after the review fix. - Confirmed the PR changes four files and excludes `pnpm-lock.yaml`, workflow changes, migrations, and unrelated branch commits. ## Risks - This is an authorization behavior change. An overly broad grant could enable cross-boundary writes, while an overly narrow grant could preserve the liveness failure. - The implementation constrains the grant to a standard-trust checked-out run, a direct parent target, and comments only; activity auditing and red-team coverage make regressions observable. - Stop relays intentionally contain only system-generated child identity/status metadata and are deduplicated; child-authored prose is not copied. - SecurityEngineer approval is mandatory before merge. > 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 Codex using GPT-5.5 with reasoning, repository tool use, shell execution, and test execution. The runtime does not expose the context-window size. ## 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:
1 parent
429792f1f3
commit
a17bee98f2
4 files changed
+453
-20
No files matched your search
@@ -102,6 +102,7 @@ export type AuthorizationDecision = {
|
||||
| "allow_consented_change"
|
||||
| "allow_legacy_agent_creator"
|
||||
| "allow_issue_mention_grant"
|
||||
| "allow_direct_parent_report"
|
||||
| "allow_self"
|
||||
| "allow_company_agent"
|
||||
| "allow_company_member"
|
||||
@@ -238,6 +239,7 @@ type IssueAuthorizationRow = {
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
checkoutRunId: string | null;
|
||||
status: string;
|
||||
executionPolicy: unknown;
|
||||
originKind: string | null;
|
||||
@@ -743,6 +745,7 @@ export function authorizationService(db: Db) {
|
||||
parentId: issues.parentId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
status: issues.status,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
originKind: issues.originKind,
|
||||
@@ -772,6 +775,46 @@ export function authorizationService(db: Db) {
|
||||
: null;
|
||||
}
|
||||
|
||||
async function loadRunIssueId(runId: string | null | undefined, companyId: string, agentId: string) {
|
||||
if (!runId) return null;
|
||||
const row = await db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, runId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row || row.companyId !== companyId || row.agentId !== agentId) return null;
|
||||
const context = isPlainRecord(row.contextSnapshot) ? row.contextSnapshot : null;
|
||||
const issueId = typeof context?.issueId === "string"
|
||||
? context.issueId.trim()
|
||||
: typeof context?.taskId === "string"
|
||||
? context.taskId.trim()
|
||||
: "";
|
||||
return issueId || null;
|
||||
}
|
||||
|
||||
async function isDirectParentReportTarget(input: {
|
||||
actor: AuthorizationActor;
|
||||
actorAgentId: string;
|
||||
companyId: string;
|
||||
resource: AuthorizationResource;
|
||||
}) {
|
||||
if (input.resource.type !== "issue" || !input.resource.issueId) return false;
|
||||
const runIssueId = await loadRunIssueId(input.actor.runId, input.companyId, input.actorAgentId);
|
||||
if (!runIssueId || runIssueId === input.resource.issueId) return false;
|
||||
const runIssue = await loadIssue(runIssueId);
|
||||
return Boolean(
|
||||
runIssue &&
|
||||
runIssue.companyId === input.companyId &&
|
||||
runIssue.assigneeAgentId === input.actorAgentId &&
|
||||
runIssue.checkoutRunId === input.actor.runId &&
|
||||
runIssue.parentId === input.resource.issueId,
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) {
|
||||
const row = await db
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
@@ -899,6 +942,7 @@ export function authorizationService(db: Db) {
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
resolution: TrustPresetResolution;
|
||||
directParentReportTarget: boolean;
|
||||
}): Promise<AuthorizationDecision | null> {
|
||||
if (input.resolution.kind === "standard") return null;
|
||||
if (input.resolution.kind === "denied") {
|
||||
@@ -962,6 +1006,21 @@ export function authorizationService(db: Db) {
|
||||
if (input.resource.type !== "issue") {
|
||||
return lowTrustDeny("Low-trust issue access is missing an issue resource.");
|
||||
}
|
||||
if (input.action === "issue:comment" && input.directParentReportTarget) {
|
||||
if (
|
||||
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("Direct-parent report comments are disabled for low-trust review runs.");
|
||||
}
|
||||
if (await issueResourceWithinLowTrustBoundary(boundary, input.resource)) {
|
||||
return lowTrustAllow("Allowed inside the low-trust issue boundary.");
|
||||
}
|
||||
@@ -1682,16 +1741,26 @@ export function authorizationService(db: Db) {
|
||||
if (taskBridgeDecision) return taskBridgeDecision;
|
||||
}
|
||||
|
||||
const trustResolution = await resolveActorTrust({
|
||||
actorAgent,
|
||||
actor: input.actor,
|
||||
companyId,
|
||||
resource: input.resource,
|
||||
});
|
||||
const directParentReportTarget =
|
||||
input.action === "issue:comment" &&
|
||||
await isDirectParentReportTarget({
|
||||
actor: input.actor,
|
||||
actorAgentId,
|
||||
companyId,
|
||||
resource: input.resource,
|
||||
});
|
||||
const lowTrustDecision = await decideLowTrustAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
resolution: await resolveActorTrust({
|
||||
actorAgent,
|
||||
actor: input.actor,
|
||||
companyId,
|
||||
resource: input.resource,
|
||||
}),
|
||||
resolution: trustResolution,
|
||||
directParentReportTarget,
|
||||
});
|
||||
if (lowTrustDecision) {
|
||||
if (!lowTrustDecision.allowed) return lowTrustDecision;
|
||||
@@ -1709,6 +1778,18 @@ export function authorizationService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
trustResolution.kind === "standard" &&
|
||||
input.action === "issue:comment" &&
|
||||
directParentReportTarget
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_direct_parent_report",
|
||||
explanation: "Allowed because the target is the current run issue's direct parent under the standard trust preset.",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (input.action === "inbox:manage") {
|
||||
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
||||
|
||||
Reference in new issue
Block a user