From 5498db107278eb4827ef8ec21d8c2491d3ca1b0e Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 08:31:31 -0600 Subject: [PATCH] fix(interactions): relay a verified reply to a self-requested human-only confirmation (#19243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask users to approve proposals and plans with confirmation cards, and users often answer in the conversation > - `resolve-from-comment` lets the responding agent record that verified reply on the card > - Native agents often create their own proposal or plan confirmation as `human_only`, because the `request_human_input` schema offers only that value > - `resolve-from-comment` then refuses with `interaction_human_only`, and the plan and onboarding gates keep the task in `in_review` or `blocked`, although the user explicitly approved > - This pull request adds one narrow relay for that case and keeps every other human-only gate on its card > - The benefit is that an explicit conversational approval works again, and governed, company-capped and quality-review approvals keep their existing controls ## Linked Issues or Issue Description Refs #19073 **What happened?** TestOps launch https://paperclip.testops.cloud/launch/2694 (GitHub run https://github.com/paperclipai/paperclip/actions/runs/41915965118) failed three Product regression cases for the same reason: `continuation/revision-preserves-approval`, `first-task/clarify-propose-accept` and `first-task/revise-accept`. In each case the agent created a `request_confirmation` with `resolverPolicy: human_only` (`effectiveResolverPolicySource: requested`). The user replied "I approve…" or "Yes, I accept that proposal". The agent's `resolve-from-comment` call returned 403 `interaction_human_only`. The task stayed `in_review` (plan gate) or became `blocked` (onboarding decision gate). **Expected behavior** When the agent itself asked for a human-only answer, the verified latest reply of the user it serves is that human answer. The agent can record it. Company-capped, governed-action, issue-review and quality-review gates still require the human to use the card. **Steps to reproduce** 1. A native agent calls `request_human_input` with `interactionKind: confirmation` and `resolverPolicy: human_only`. 2. The conversation user posts an explicit approval comment. 3. The agent calls `POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment`. Before this change it gets 403. ## What Changed - `issue-thread-interaction-resolution.ts`: the agent actor gets an optional `relayedHumanUserId`. A human-only decision is allowed as `allow_relayed_human` only when all of these are true: - the stored policy is `human_only`, its source is `requested` and its provenance is `explicit` - the resolving agent created the card (`createdByAgentId` matches) - no governed action applies - no additional restriction (issue review) applies - any named addressee user is the relayed user Company caps, the `governed_action` source and legacy rows with an unknown source are still denied. - `confirmation-comment-resolution.ts`: the service sets `relayedHumanUserId` only after its existing comment checks pass: user-authored, not agent-attributed, posted after the card, the latest user message, the same conversation session. It also requires that the author is the card's addressee, the conversation user or the run's responsible user, that the card is not a quality review, and that the company's current cap for the kind is not `human_only` (a tied cap keeps the source `requested`). The same actor goes into the locked accept/reject re-check. The activity entry adds `resolverPolicy: "human_only"` and `resolution: "relayed_human_decision"`. - `routes/issues.ts`: the route pre-check passes the candidate comment author. The service still re-verifies everything under its locks. - `issue-thread-interactions.ts`: carries the relay field into the service's own audience re-check. - Docs: the first-task skill and `api-reference.md` describe the narrow relay. The guidance still says not to add `human_only` to ordinary proposals. ## Verification - `server/src/__tests__/confirmation-comment-resolution.test.ts` (embedded Postgres): 72 pass. - Green: relay through a conversation user and through a run's responsible user. The card is `accepted`, `resolvedByAgentId` and `resolvedByRunId` are set, `resolvedByUserId` stays null, and the audit records `relayed_human_decision`. - Negative controls, each with the card still pending and no audit: agent-authored comment, earlier comment, wrong user, unbound issue/run, `company_cap` source, `governed_action` source, `qualityReview`, `toolAction`, `connectionAuthorization`, issue-review `human_only` restriction, another addressed user, human-created card, another agent's card, inherited company default, and a real capped card created through `svc.create` with a tied cap. The tied-cap test fails as expected when the cap is removed. - Existing negative controls are unchanged, including `human_only` without a bound user and the "rechecks narrowed permissions" retry. - Red check: with the three service files reverted to `master`, the two new green tests fail and the negative controls pass. - `server/src/services/issue-thread-interaction-resolution.test.ts`: 21 pass, including the pure evaluator relay rules. - `server/src/services/native-runtime/confirmation-comment-api.integration.test.ts`: 11 pass. The real route accepts the relayed reply and refuses another user's comment with 403. - Also pass: `issue-thread-interaction-routes`, `onboarding-first-task-admission`, `paperclip-runner-tool-authority`, `runner-api`, `onboarding-first-task-assets` and `company-skills-service` (2,474 tests in total). - Changed files typecheck cleanly. - Live verification: Product regression single-case runs of the three cases are pending after merge. ## Risks - This touches an approval gate. The relay is limited to cards whose creator chose `human_only`. All existing reply-evidence checks still apply, plus a bound-user requirement. Company caps, governed actions, quality reviews, issue-review restrictions and named addressees still deny the relay. - Attribution does not change: the resolver is still the agent and run. The human's comment is kept as `result.commentId` and in the audit. - Legacy rows with a null policy source are not relayed. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use and code 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 - [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 šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../confirmation-comment-resolution.test.ts | 53 +++++++++++++++++++ .../first-task/skills/first-task/SKILL.md | 2 +- server/src/routes/issues.ts | 12 ++++- .../confirmation-comment-resolution.ts | 37 ++++++++++--- ...ssue-thread-interaction-resolution.test.ts | 34 ++++++++++++ .../issue-thread-interaction-resolution.ts | 43 ++++++++++++++- .../src/services/issue-thread-interactions.ts | 3 ++ ...nfirmation-comment-api.integration.test.ts | 18 +++++-- skills/paperclip/references/api-reference.md | 2 +- 9 files changed, 187 insertions(+), 17 deletions(-) diff --git a/server/src/__tests__/confirmation-comment-resolution.test.ts b/server/src/__tests__/confirmation-comment-resolution.test.ts index c854b2ec8da..0c244f46ac7 100644 --- a/server/src/__tests__/confirmation-comment-resolution.test.ts +++ b/server/src/__tests__/confirmation-comment-resolution.test.ts @@ -232,6 +232,59 @@ const support = await getEmbeddedPostgresTestSupport(); expect((await readCard(f.card.id)).status).toBe("pending"); expect(await audit(f.issueId)).toHaveLength(0); }); + // A card the agent itself requested as human_only records the bound user's + // verified latest reply. Every other human-only source still needs the card. + async function seedHumanOnly(binding: "conversation" | "run" | "unbound" = "conversation") { + const f = await seed(false, binding === "conversation"); + await db.update(issueThreadInteractions).set({ effectiveResolverPolicy: "human_only", requestedResolverPolicy: "human_only", + effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" }).where(eq(issueThreadInteractions.id, f.card.id)); + if (binding === "run") await db.update(heartbeatRuns).set({ responsibleUserId: "operator" }).where(eq(heartbeatRuns.id, f.runId)); + return f; + } + it.each(["conversation", "run"] as const)("relays the %s user's verified reply to a self-requested human-only card", async binding => { + const f = await seedHumanOnly(binding); + await resolveConfirmationFromComment(db, f.args); + const card = await readCard(f.card.id); + expect(card).toMatchObject({ status: "accepted", resolvedByAgentId: f.agentId, resolvedByRunId: f.runId, resolvedByUserId: null, + result: { commentId: f.comment.id } }); + expect(await audit(f.issueId)).toMatchObject([{ action: "issue.thread_interaction_accepted", agentId: f.agentId, runId: f.runId, + details: { responseCommentId: f.comment.id, responseUserId: "operator", resolverPolicy: "human_only", resolution: "relayed_human_decision" } }]); + }); + it.each(["agent-comment", "earlier-comment", "wrong-user", "unbound", "company_cap", "governed_action", "qualityReview", + "toolAction", "connectionAuthorization", "review-restriction", "other-addressee", "human-created", "other-agent-created", + "inherited-default"])("keeps a human-only card on its card for %s", async kind => { + const f = await seedHumanOnly(kind === "unbound" ? "unbound" : "conversation"); + const card = (set: Record) => db.update(issueThreadInteractions).set(set).where(eq(issueThreadInteractions.id, f.card.id)); + if (kind === "agent-comment") await db.update(issueComments).set({ authorType: "agent", authorAgentId: f.agentId }).where(eq(issueComments.id, f.comment.id)); + if (kind === "earlier-comment") await db.update(issueComments).set({ createdAt: new Date(0) }).where(eq(issueComments.id, f.comment.id)); + if (kind === "wrong-user") await db.update(issueComments).set({ authorUserId: "somebody-else" }).where(eq(issueComments.id, f.comment.id)); + if (kind === "company_cap" || kind === "governed_action") await card({ effectiveResolverPolicySource: kind }); + if (["qualityReview", "toolAction", "connectionAuthorization"].includes(kind)) await card({ payload: { ...f.card.payload, [kind]: {} } }); + if (kind === "other-addressee") await card({ addresseeUserId: "somebody-else" }); + if (kind === "human-created") await card({ createdByAgentId: null, createdByUserId: "operator", sourceRunId: null }); + if (kind === "other-agent-created") { + const [other] = await db.insert(agents).values({ companyId: f.companyId, name: "Other", adapterType: "paperclip_runner" }).returning(); + await card({ createdByAgentId: other.id, sourceRunId: null }); + } + if (kind === "inherited-default") await card({ resolverPolicyProvenance: "inherited" }); + const args = kind === "review-restriction" ? { ...f.args, actor: { ...f.args.actor, resolverPolicyRestriction: "human_only" as const } } : f.args; + await expect(resolveConfirmationFromComment(db, args)).rejects.toThrow(); + expect((await readCard(f.card.id)).status).toBe("pending"); + expect(await audit(f.issueId)).toHaveLength(0); + }); + it("keeps a requested human-only card on its card when the company cap ties at human_only", async () => { + const f = await seed(false, true); + await db.update(companies).set({ interactionResolverGovernance: { request_confirmation: { cap: "human_only" } } }).where(eq(companies.id, f.companyId)); + const capped = await f.svc.create(f.issue, { kind: "request_confirmation", resolverPolicy: "human_only", + payload: { version: 1, prompt: "Approve the capped proposal?" } }, { agentId: f.agentId }); + expect(capped).toMatchObject({ effectiveResolverPolicy: "human_only", effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" }); + const [reply] = await db.insert(issueComments).values({ companyId: f.companyId, issueId: f.issueId, authorUserId: "operator", authorType: "user", + body: "Yes, approved.", createdAt: new Date(Date.now() + 1000) }).returning(); + await expect(resolveConfirmationFromComment(db, { ...f.args, interactionId: capped.id, input: { commentId: reply.id, decision: "accept" } })) + .rejects.toThrow(); + expect((await readCard(capped.id)).status).toBe("pending"); + expect(await audit(f.issueId)).toHaveLength(0); + }); it("rechecks narrowed permissions on an otherwise matching retry", async () => { const f = await seed(); await resolveConfirmationFromComment(db, f.args); diff --git a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md index 61190126d1c..c224591ad62 100644 --- a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md +++ b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md @@ -42,7 +42,7 @@ Work in this order. The tool's root fields are `idempotencyKey`, `interactionKind`, `title`, `prompt`, `continuationPolicy`, and `payload`, with optional `targetRevisionId` and `resolverPolicy` as advertised. Put `version` and `supersedeOnUserComment` inside `payload`; neither is a root argument. If validation rejects a proposal, correct its argument placement and keep the confirmation or checkbox approval kind. Switching it to a question form cannot authorize the proposed work. Wait for a successful saved card receipt before claiming you requested approval. - For ordinary first-task proposals that accept a reply in this conversation, omit the optional `resolverPolicy` and inherit the server/company default. Do not add `human_only` merely because you are asking the user to decide: `resolve-from-comment` records your interpretation as the responding agent and cannot resolve a human-only card. Preserve explicit user requirements for human-only approval, company restrictions, and governed-action requirements; omission does not override them. Never lower a saved card's policy or recreate it to bypass a denial. Restricted cards must keep their required human response controls. + For ordinary first-task proposals that accept a reply in this conversation, omit the optional `resolverPolicy` and inherit the server/company default. Do not add `human_only` merely because you are asking the user to decide: `resolve-from-comment` records your interpretation as the responding agent; on a human-only card it accepts only the bound user's verified latest reply, and only when you requested `human_only` yourself. Preserve explicit user requirements for human-only approval, company restrictions, and governed-action requirements; omission does not override them. Never lower a saved card's policy or recreate it to bypass a denial. Restricted cards must keep their required human response controls. ```json { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 9b9138819c2..e9b503df416 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -6052,6 +6052,9 @@ export function issueRoutes( payload?: unknown; }, runId: string | null, + // Candidate only: resolveConfirmationFromComment re-verifies the comment + // and re-runs this audience under its locks before any mutation. + relayedHumanUserId?: string | null, ) { const reviewRestriction = await resolvePendingReviewInteractionRestriction( issue, @@ -6081,6 +6084,7 @@ export function issueRoutes( type: "agent", agentId: actor.agentId, runId: runId || actor.runId, + relayedHumanUserId, } : { type: "user", userId: actor.actorId }, interaction, @@ -6126,6 +6130,7 @@ export function issueRoutes( res: Response, issue: Parameters[2] & { conversationAgentId?: string | null; conversationUserId?: string | null }, interactionId: string, + relayedHumanUserId?: string | null, ) { if (issue.conversationAgentId && req.actor.type === "board" && req.actor.userId !== issue.conversationUserId) { throw forbidden("Only the conversation owner can respond to chat interactions"); @@ -6150,6 +6155,7 @@ export function issueRoutes( issue, current, runId, + relayedHumanUserId, ); if (!resolutionAuthorization) return false; return { interactionSvc, current, resolutionAuthorization } as const; @@ -17455,8 +17461,12 @@ export function issueRoutes( const issue = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Issue not found"); if (!issue) return; if (req.actor.type !== "agent") throw forbidden("Conversational resolution requires the responding agent run"); + const [replyComment] = await db.select({ authorUserId: issueComments.authorUserId }).from(issueComments).where(and( + eq(issueComments.id, req.body.commentId), eq(issueComments.companyId, issue.companyId), + eq(issueComments.issueId, issue.id), eq(issueComments.authorType, "user"), + )); const authorization = await getIssueThreadInteractionResolutionAuthorization( - req, res, issue, req.params.interactionId as string, + req, res, issue, req.params.interactionId as string, replyComment?.authorUserId ?? null, ); if (!authorization) return; const actor = getActorInfo(req); diff --git a/server/src/services/confirmation-comment-resolution.ts b/server/src/services/confirmation-comment-resolution.ts index 4edf2882b08..5c510fd874c 100644 --- a/server/src/services/confirmation-comment-resolution.ts +++ b/server/src/services/confirmation-comment-resolution.ts @@ -1,6 +1,9 @@ import { and, desc, eq, isNull } from "drizzle-orm"; -import { heartbeatRuns, issueComments, issueThreadInteractions, issues, type Db } from "@paperclipai/db"; -import { resolveConfirmationFromCommentSchema, type ResolveConfirmationFromComment } from "@paperclipai/shared"; +import { companies, heartbeatRuns, issueComments, issueThreadInteractions, issues, type Db } from "@paperclipai/db"; +import { + normalizeIssueThreadInteractionResolverPolicy, resolveConfirmationFromCommentSchema, + type InteractionResolverGovernance, type IssueThreadInteractionKind, type ResolveConfirmationFromComment, +} from "@paperclipai/shared"; import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { persistActivity, publishActivity, type ActivityPublication } from "./activity-log.js"; @@ -12,8 +15,10 @@ type ResolutionActor = Parameters Promise) => { commitEffects.push(effect); } }; const interaction = input.decision === "accept" - ? (await svc.acceptInteraction(issue, card.id, { selectedOptionIds: selected }, args.actor, mutationOptions)).interaction - : await svc.rejectInteraction(issue, card.id, { reason: input.reason }, args.actor, mutationOptions); + ? (await svc.acceptInteraction(issue, card.id, { selectedOptionIds: selected }, actor, mutationOptions)).interaction + : await svc.rejectInteraction(issue, card.id, { reason: input.reason }, actor, mutationOptions); if ((interaction.kind !== "request_confirmation" && interaction.kind !== "request_checkbox_confirmation") || !interaction.result) { throw conflict("Confirmation resolution did not persist a result"); } @@ -129,7 +149,8 @@ export async function resolveConfirmationFromComment(db: Db, args: { agentId: args.actor.agentId, runId: args.actor.runId, action: `issue.thread_interaction_${expected}`, entityType: "issue", entityId: issue.id, details: { interactionId: card.id, interactionKind: card.kind, interactionStatus: expected, - responseCommentId: comment.id, responseUserId: comment.authorUserId, source: "conversation_reply" }, + responseCommentId: comment.id, responseUserId: comment.authorUserId, source: "conversation_reply", + ...(relayedHumanUserId ? { resolverPolicy: "human_only", resolution: "relayed_human_decision" } : {}) }, })).publication; return { interaction: await svc.getForIssue(issue, card.id), deduplicated: false }; }); diff --git a/server/src/services/issue-thread-interaction-resolution.test.ts b/server/src/services/issue-thread-interaction-resolution.test.ts index 94b67cec33d..6693faf9ff8 100644 --- a/server/src/services/issue-thread-interaction-resolution.test.ts +++ b/server/src/services/issue-thread-interaction-resolution.test.ts @@ -172,4 +172,38 @@ describe("issue-thread interaction resolver audience", () => { interaction: interaction({ effectiveResolverPolicy: "human_only" }), })).toBe(false); }); + + describe("relayed human decision", () => { + const relay = { ...agent, relayedHumanUserId: "user-1" }; + const humanOnly = (overrides: Record = {}) => + interaction({ effectiveResolverPolicy: "human_only", effectiveResolverPolicySource: "requested", + resolverPolicyProvenance: "explicit", ...overrides }); + + it("allows a verified reply on a card whose creator requested human_only", () => { + expect(evaluateIssueThreadInteractionResolverAudience({ actor: relay, interaction: humanOnly() })) + .toMatchObject({ allowed: true, effectiveResolverPolicy: "human_only", reason: "allow_relayed_human" }); + }); + + it.each([ + ["no relay", { actor: agent }], + ["a company cap", { interaction: humanOnly({ effectiveResolverPolicySource: "company_cap" }) }], + ["a governed-action source", { interaction: humanOnly({ effectiveResolverPolicySource: "governed_action" }) }], + ["an unknown legacy source", { interaction: humanOnly({ effectiveResolverPolicySource: null }) }], + ["a governed action", { governedAction: true }], + ["an issue-review restriction", { additionalRestriction: "human_only" as const }], + ["another addressed user", { interaction: humanOnly({ addresseeUserId: "user-2" }) }], + ["a human-created card", { interaction: humanOnly({ createdByAgentId: null, createdByUserId: "user-1" }) }], + ["another agent's card", { interaction: humanOnly({ createdByAgentId: "agent-2" }) }], + ["an inherited company default", { interaction: humanOnly({ resolverPolicyProvenance: "inherited" }) }], + ])("still denies %s", (_name, override) => { + const decision = evaluateIssueThreadInteractionResolverAudience({ actor: relay, interaction: humanOnly(), ...override }); + expect(decision).toMatchObject({ allowed: false }); + }); + + it("never widens a non-human-only card", () => { + expect(evaluateIssueThreadInteractionResolverAudience({ actor: relay, + interaction: interaction({ effectiveResolverPolicy: "not_creator" }) })) + .toMatchObject({ allowed: false, code: "interaction_creator_excluded" }); + }); + }); }); diff --git a/server/src/services/issue-thread-interaction-resolution.ts b/server/src/services/issue-thread-interaction-resolution.ts index 94a4fc23bf5..4ee3cc9830f 100644 --- a/server/src/services/issue-thread-interaction-resolution.ts +++ b/server/src/services/issue-thread-interaction-resolution.ts @@ -25,7 +25,16 @@ export type IssueThreadInteractionResolutionDenialCode = export type IssueThreadInteractionResolverActor = | { type: "user"; userId: string } - | { type: "agent"; agentId: string | null | undefined; runId: string | null | undefined } + | { + type: "agent"; + agentId: string | null | undefined; + runId: string | null | undefined; + /** + * Set only by conversational confirmation resolution after it verified + * the bound user's latest comment. It never makes the agent a human. + */ + relayedHumanUserId?: string | null; + } | { type: "system"; systemId: string }; export type IssueThreadInteractionResolverRestriction = { @@ -48,6 +57,7 @@ export type IssueThreadInteractionResolverAudienceInput = { addresseeAgentId?: string | null; addresseeUserId?: string | null; effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string; + effectiveResolverPolicySource?: string | null; resolverPolicyProvenance?: string | null; }; /** @@ -65,7 +75,8 @@ export type IssueThreadInteractionResolverAudienceDecision = | { allowed: true; effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy; - reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override"; + reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override" + | "allow_relayed_human"; } | { allowed: false; @@ -147,6 +158,31 @@ function reviewPolicyDeniedDecision( }; } +/** + * An agent may record a human-only confirmation only as the relay of a verified + * human decision, and only on a card it created with an explicit human_only + * request. Company caps (checked again at resolution by the caller), governed + * actions and binding-flow restrictions still require a direct human resolution. + */ +function relayedHumanDecisionAllowed( + input: IssueThreadInteractionResolverAudienceInput, + persistedPolicy: IssueThreadInteractionCanonicalResolverPolicy, + additionalRestriction: IssueThreadInteractionResolverRestriction | null | undefined, +) { + if (input.actor.type !== "agent") return false; + const userId = input.actor.relayedHumanUserId; + return Boolean(userId && input.actor.agentId) + && !input.governedAction + && persistedPolicy === "human_only" + // Only this agent's own explicit request; a company default, cap or a + // human-created card keeps its direct human resolution. + && input.interaction.createdByAgentId === input.actor.agentId + && input.interaction.resolverPolicyProvenance === "explicit" + && input.interaction.effectiveResolverPolicySource === "requested" + && !additionalRestriction + && (!input.interaction.addresseeUserId || input.interaction.addresseeUserId === userId); +} + /** * The single pure audience evaluator for issue-thread interaction resolution. * Resource access, run validation, containment, and current-target checks must @@ -246,6 +282,9 @@ export function evaluateIssueThreadInteractionResolverAudience( } if (effectiveResolverPolicy === "human_only") { + if (relayedHumanDecisionAllowed(input, persistedPolicy, additionalRestriction)) { + return { allowed: true, effectiveResolverPolicy, reason: "allow_relayed_human" }; + } return { allowed: false, effectiveResolverPolicy, diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index d5b0421ae08..9363a9c2ea7 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -169,6 +169,8 @@ type InteractionActor = { | null; suggestedTaskEffectsAuthorized?: boolean; resolutionDetails?: Record; + /** Set only by resolveConfirmationFromComment for its verified reply author. */ + relayedHumanUserId?: string | null; }; async function assertInteractionRunWriteAllowed(tx: Db, issue: { id: string; companyId: string }, actor: InteractionActor) { @@ -472,6 +474,7 @@ function resolverActor(actor: InteractionActor) { type: "agent" as const, agentId: actor.agentId, runId: actor.runId, + relayedHumanUserId: actor.relayedHumanUserId ?? null, }; } if (actor.userId) return { type: "user" as const, userId: actor.userId }; diff --git a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts index db2b9ada367..c6ad778443d 100644 --- a/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts +++ b/server/src/services/native-runtime/confirmation-comment-api.integration.test.ts @@ -51,11 +51,21 @@ describe("confirmation reply through the native API tool", () => { const snapshot = await f.snapshot(); expect(snapshot.activity.filter(row => row.action === "issue.thread_interaction_accepted")).toHaveLength(1); }); - it("denies human-only resolution and foreign-message provenance through the same tool", async () => { + it("relays the conversation user's reply to a self-requested human-only card, but not another user's", async () => { const { f, card, call, comment } = await seed("standard", "human_only"); - expect(card).toMatchObject({ requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit" }); - expect(await f.authority.execute(call)).toMatchObject({ status: 403 }); - expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, card.id)))[0]).toMatchObject({ status: "pending", result: null }); + expect(card).toMatchObject({ requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", + effectiveResolverPolicySource: "requested", resolverPolicyProvenance: "explicit" }); + expect(await f.authority.execute(call)).toMatchObject({ status: 200, data: { interaction: { + id: card.id, status: "accepted", result: { commentId: comment.id }, resolvedByAgentId: f.agentId, resolvedByUserId: null } } }); + const snapshot = await f.snapshot(); + expect(snapshot.activity.filter(row => row.action === "issue.thread_interaction_accepted")) + .toMatchObject([{ details: { resolverPolicy: "human_only", resolution: "relayed_human_decision" } }]); + + const intruder = await seed("standard", "human_only"); + await server.db.update(issueComments).set({ authorUserId: "somebody-else" }).where(eq(issueComments.id, intruder.comment.id)); + expect(await intruder.f.authority.execute(intruder.call)).toMatchObject({ status: 403 }); + expect((await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, intruder.card.id)))[0]) + .toMatchObject({ status: "pending", result: null }); const other = await seed(); const foreignAnswer = { ...other.call, arguments: { ...other.call.arguments, body: { ...other.call.arguments.body, commentId: comment.id } } }; expect(await other.f.authority.execute(foreignAnswer)).toMatchObject({ status: 422 }); diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 30a06b8e4ed..43cdd38f268 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -1088,7 +1088,7 @@ Rules: ### Conversational confirmation answers -To record a user's conversational answer, an eligible agent responding on this task may POST `/api/issues/{issueId}/interactions/{interactionId}/resolve-from-comment` with `{ "commentId": "", "decision": "accept" }` (or `"reject"` and `reason`). Native runners use `call_api`. Checkbox acceptance must include explicit `selectedOptionIds`; defaults alone are not consent. The result is `{ interaction, deduplicated }`, with the user message retained in `interaction.result.commentId` and the activity audit. Resolver attribution remains the responding agent/run. This does not widen permissions: `human_only`, independent-review restrictions, named addressees, and governed-action controls still apply. Only confirmation and checkbox cards are supported, not forms, secret/tool approvals, or connection authorizations. +To record a user's conversational answer, an eligible agent responding on this task may POST `/api/issues/{issueId}/interactions/{interactionId}/resolve-from-comment` with `{ "commentId": "", "decision": "accept" }` (or `"reject"` and `reason`). Native runners use `call_api`. Checkbox acceptance must include explicit `selectedOptionIds`; defaults alone are not consent. The result is `{ interaction, deduplicated }`, with the user message retained in `interaction.result.commentId` and the activity audit. Resolver attribution remains the responding agent/run. This does not widen permissions: independent-review restrictions, named addressees, and governed-action controls still apply. A `human_only` card accepts this relay only when its creator requested `human_only` itself and the comment's author is the user the run or conversation serves; company-capped, governed-action and quality-review cards still require the human to use the card. Only confirmation and checkbox cards are supported, not forms, secret/tool approvals, or connection authorizations. Read current cards and comments before interpreting the reply. Resolve the specific proposal before performing the approved work. Ask for clarification when a reply is ambiguous among multiple proposals or checkbox choices; do not approve all of them. Requested revisions are not acceptance. If the write is interrupted, retry the same card/message/decision: matching retries return `deduplicated: true` without another wake. Conflicting, stale, deleted, superseded, wrong-user, and previous-session answers fail. Do not ask the user to clear a card after their decision is saved.