Files
PaperClipAI/server/src/services/human-directed-work.ts
T
DottaandPaperclip 0829d94af2 fix(auth): derive low-trust human direction from existing execution records (#14775)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Low-trust review contains work that may include hostile input.
> - Its default intake boundary currently blocks direct human chat and
tasks outside that boundary.
> - Human direction should authorize the assigned work while preserving
containment.
> - Existing conversations and execution requests already identify
direct human instructions.
> - This pull request derives exact-task authority from those records
and the current assignee.
> - The agent can perform that work without gaining access to unrelated
tasks or privileged tools.

## Linked Issues or Issue Description

**What happened?**

A low-trust agent with a project boundary rejects its owner's direct
Agent Chat before provider execution. Human-assigned tasks outside that
project fail the same check.

**Expected behavior**

An authorized human can talk to the agent or assign it a task. The exact
task runs with its existing sandbox, credential, and tool restrictions.

**Steps to reproduce**

1. Enable Agent Chat and isolated workspaces. Configure a sandbox agent
with low-trust review scoped to an intake project.
2. Send the agent a direct board chat message, or assign it a
projectless task.
3. Observe `low_trust_boundary_mismatch` before execution.

Related: #14766 adds private task directories for repo-free low-trust
execution. It is now merged into master and included in the branch base,
so CI and staging verify the combined behavior.

## What Changed

- Derive owner-chat access from existing conversation identity.
- Derive exact-task access from the existing human requester and
server-owned request origin, including coalesced requests. Plugin and
external sender attribution do not authorize work.
- Follow existing `retryOfRunId` database links for automatic
continuations, checking company, agent, and task throughout; cancelled
ancestors cannot grant authority.
- Require a live run and current assignment. Preserve sandbox,
credential, privileged-tool, responsible-user, and quarantined-output
checks.
- Retain board backlog assignments in existing request records without
starting execution. Reassignment cancels old human requests in the
common service transaction, including plugin writes; late settlement
cannot revive them.
- Add real database and HTTP coverage for request provenance, retry
ancestry, cancelled runs, concurrent reassignment, spoofing, and
containment. Document the rule.
- Preserve legacy board assignment requests through their existing
source, reason, and human requester.
- Use the existing wrapped-error helper for concurrent chat-question
idempotency; a deterministic race test reproduces the CI failure before
the fix and passes after it.
- No new schema, migrations, or user-identity fields. Existing requester
columns hold attribution.

## Verification

- Passed the focused database, policy-retention, HTTP, and reassignment
tests locally. The HTTP test creates a task through the real board route
and checks the resulting persisted wakeup before exercising agent reads,
comments, mutations, and review handoff.
- Database tests hold a reassignment transaction open to verify coherent
authorization before and after commit, with a two-connection pool. They
cover retries, coalesced requests, cancelled ancestry, invalid
cross-company/agent/task links, cycles, and forged attribution.
- Full local `pnpm -r typecheck` and `pnpm build` passed on the final
commit (`d107c26df`). [Latest-head
CI](https://github.com/paperclipai/paperclip/actions/runs/36815589542)
passed: 54 successful checks, two expected skips, including all eight
browser-test shards. Greptile is 5/5 on this exact commit with no
unresolved threads. Local tests were targeted; the full test suite ran
through CI’s test matrix.
- The revised HTTP suite passed all 13 tests; database authorization
tests passed all 11, including legacy compatibility and late watchdog
settlement; the backlog route contract passed all 3 tests. Another 102
tests covering durable chat admission, wake queues, and Cursor execution
passed.
- All 90 interaction-service tests passed with both create calls
deliberately held until their optimistic reads complete, forcing
duplicate-key recovery. That forced race failed before switching to the
shared wrapped-error helper.
- Previous staging proof covered owner chat and projectless task
persistence. The simplified revision has not been redeployed; that
earlier proof is not claimed for the new implementation.

## Risks

- This is an authorization change: only the live run's exact task
qualifies, and normal responsible-user restrictions still apply.
- Existing request and retry records are authoritative. Merely naming a
responsible/originating user or an external connector sender does not
qualify.
- Reassignment invalidates existing human request records
transactionally. A cancelled run or request cannot regain authority when
the task is assigned back.
- Ordinary task exceptions require server-owned origin or the legacy
board assignment source/reason/actor combination. Existing owner chats
use conversation identity.

## Model Used

OpenAI GPT-6 in Codex, with reasoning, repository tools, code execution,
and browser testing. The runtime does not expose a more specific model
ID or 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>
2026-10-01 09:45:18 -05:00

95 lines
5.5 KiB
TypeScript

import { and, eq, sql } from "drizzle-orm";
import { agentWakeupRequests, issues } from "@paperclipai/db";
import type { Db } from "@paperclipai/db";
import type { TrustPresetResolution } from "./trust-preset-resolver.js";
/**
* Derive exact-task authority from existing server-owned execution records.
* Responsible-user attribution alone is insufficient: it is also inherited by
* agent-created work. Never read authority or retry ancestry from tool payloads.
*/
export async function withHumanDirectedWork(
db: Pick<Db, "execute">,
resolution: TrustPresetResolution,
input: { companyId: string; agentId: string; runId: string },
): Promise<TrustPresetResolution> {
if (resolution.kind !== "low_trust_review" ||
resolution.boundary.companyId !== input.companyId) return resolution;
// One snapshot binds the live run, current assignee, and authenticated wake.
// Reassignment cancels the old request records in the assignment transaction.
// Neither those records nor cancelled executions can lend authority to retries.
// UNION deduplicates rows so a malformed retry cycle terminates without a cap.
const [issue] = await db.execute<{ id: string }>(sql`
WITH RECURSIVE current_task AS (
SELECT i.id, i.conversation_agent_id, i.conversation_user_id, r.id AS run_id
FROM heartbeat_runs r
JOIN issues i ON i.id::text = coalesce(r.context_snapshot->>'issueId', r.context_snapshot->>'taskId')
AND i.company_id = r.company_id AND i.assignee_agent_id = r.agent_id
WHERE r.id = ${input.runId}::uuid AND r.company_id = ${input.companyId}::uuid
AND r.agent_id = ${input.agentId}::uuid AND r.status = 'running'
), task_runs AS (
SELECT r.id, r.retry_of_run_id
FROM heartbeat_runs r JOIN current_task t ON t.run_id = r.id
UNION
SELECT parent.id, parent.retry_of_run_id
FROM heartbeat_runs parent JOIN task_runs child ON parent.id = child.retry_of_run_id
JOIN current_task t ON t.id::text = coalesce(parent.context_snapshot->>'issueId', parent.context_snapshot->>'taskId')
WHERE parent.company_id = ${input.companyId}::uuid AND parent.agent_id = ${input.agentId}::uuid
AND parent.status <> 'cancelled'
)
SELECT t.id FROM current_task t
WHERE (t.conversation_agent_id = ${input.agentId}::uuid AND t.conversation_user_id IS NOT NULL)
OR EXISTS (
SELECT 1 FROM agent_wakeup_requests w
WHERE (w.run_id IN (SELECT id FROM task_runs) OR
(w.run_id IS NULL AND w.reason = 'issue_human_assignment' AND w.status = 'completed'))
AND w.company_id = ${input.companyId}::uuid AND w.agent_id = ${input.agentId}::uuid
AND w.requested_by_actor_type = 'user' AND nullif(trim(w.requested_by_actor_id), '') IS NOT NULL
AND w.status NOT IN ('skipped', 'cancelled')
-- The source is copied from server-owned context at admission, never
-- from caller payloads. Plugins can attribute a user but use plugin: sources.
AND (w.payload->'_paperclipWakeContext'->>'source' LIKE 'issue.%'
-- Before origin snapshots were retained, board assignment requests
-- already had this closed source/reason/actor combination. Plugin
-- assignment wakes are system-attributed; plugin human comments are
-- automation wakes. External connector receipts are excluded below.
OR (NOT (w.payload ? '_paperclipWakeContext')
AND w.source = 'assignment' AND w.reason = 'issue_assigned'))
AND coalesce(w.payload->>'issueId', w.payload->>'taskId',
w.payload->'_paperclipWakeContext'->>'issueId', w.payload->'_paperclipWakeContext'->>'taskId') = t.id::text
-- Connector sender attribution can also use a user id. Its durable
-- inbound receipts are not authenticated board instructions.
AND (w.idempotency_key IS NULL OR w.idempotency_key NOT LIKE 'chat-inbound:%')
)
`);
return issue ? { ...resolution, humanDirectedIssueId: issue.id } : resolution;
}
/** Preserve a board assignment made before a task is runnable, without starting it.
* Uses the existing requester columns, not another user identity or grant table.
* Call only with the authenticated HTTP actor and the committed write result.
*/
export async function retainBacklogHumanAssignment(
db: Db,
issue: { id: string; companyId: string; assigneeAgentId: string | null; status: string; statusVersion: number },
actor: { actorType: string; actorId: string },
) {
if (actor.actorType !== "user" || !actor.actorId.trim() || issue.status !== "backlog" || !issue.assigneeAgentId) return;
await db.transaction(async (tx) => {
// An assignment that changed (even A -> B -> A) before this callback cannot
// revive stale direction. Reassignment and this receipt share the issue lock.
const [current] = await tx.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, issue.id), eq(issues.companyId, issue.companyId),
eq(issues.assigneeAgentId, issue.assigneeAgentId!), eq(issues.statusVersion, issue.statusVersion),
)).for("update");
if (!current) return;
await tx.insert(agentWakeupRequests).values({
companyId: issue.companyId, agentId: issue.assigneeAgentId!, source: "assignment",
reason: "issue_human_assignment", status: "completed", finishedAt: new Date(),
requestedByActorType: "user", requestedByActorId: actor.actorId,
payload: { issueId: issue.id, _paperclipWakeContext: { source: "issue.backlog_assignment" } },
});
});
}