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.