mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 09:07:46 +02:00
## 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>
108 lines
4.2 KiB
TypeScript
108 lines
4.2 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { issues } from "@paperclipai/db";
|
|
import { unprocessable } from "../errors.js";
|
|
import type { TrustPresetResolution } from "./trust-preset-resolver.js";
|
|
import {
|
|
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
|
|
isIssueWithinLowTrustBoundary,
|
|
} from "./trust-preset-resolver.js";
|
|
|
|
export const LOW_TRUST_RUNTIME_MANAGEMENT_TOOL_CLASS = "runtime.manage";
|
|
|
|
export function isLowTrustRuntimeManagementAllowed(resolution: TrustPresetResolution) {
|
|
return resolution.kind === "low_trust_review" &&
|
|
(resolution.boundary.allowedToolClasses ?? []).includes(LOW_TRUST_RUNTIME_MANAGEMENT_TOOL_CLASS);
|
|
}
|
|
|
|
async function issueIdIsDescendantOf(db: Db, issueId: string, rootIssueId: string, companyId: string) {
|
|
let cursor: string | null = issueId;
|
|
// Keep the runtime preflight aligned with authorization while bounding DB work.
|
|
for (let depth = 0; cursor && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth += 1) {
|
|
if (cursor === rootIssueId) return true;
|
|
const row: { id: string; companyId: string; parentId: string | null } | null = await db
|
|
.select({ id: issues.id, companyId: issues.companyId, parentId: issues.parentId })
|
|
.from(issues)
|
|
.where(eq(issues.id, cursor))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row || row.companyId !== companyId) return false;
|
|
cursor = row.parentId;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function workspaceIssueWithinLowTrustBoundary(input: {
|
|
db?: Db;
|
|
boundary: Extract<TrustPresetResolution, { kind: "low_trust_review" }>["boundary"];
|
|
issue: { companyId: string; id?: string | null; projectId?: string | null };
|
|
}) {
|
|
if (isIssueWithinLowTrustBoundary(input.boundary, input.issue)) return true;
|
|
if (!input.db || !input.issue.id || !input.boundary.rootIssueId) return false;
|
|
return issueIdIsDescendantOf(input.db, input.issue.id, input.boundary.rootIssueId, input.boundary.companyId);
|
|
}
|
|
|
|
export async function assertLowTrustWorkspaceIsolation(input: {
|
|
db?: Db;
|
|
resolution: TrustPresetResolution;
|
|
isolatedWorkspacesEnabled: boolean;
|
|
effectiveExecutionWorkspaceMode: string | null | undefined;
|
|
selectedEnvironmentDriver: string | null | undefined;
|
|
issue: { companyId: string; id?: string | null; projectId?: string | null } | null;
|
|
}) {
|
|
if (input.resolution.kind === "denied") {
|
|
throw unprocessable(input.resolution.detail, {
|
|
code: input.resolution.reason,
|
|
source: input.resolution.source,
|
|
});
|
|
}
|
|
if (input.resolution.kind !== "low_trust_review") return;
|
|
|
|
if (!input.isolatedWorkspacesEnabled) {
|
|
throw unprocessable("Low-trust execution requires isolated workspaces to be enabled.", {
|
|
code: "low_trust_isolation_unavailable",
|
|
});
|
|
}
|
|
if (input.effectiveExecutionWorkspaceMode !== "isolated_workspace") {
|
|
throw unprocessable("Low-trust execution requires an isolated execution workspace.", {
|
|
code: "low_trust_requires_isolated_workspace",
|
|
});
|
|
}
|
|
if (
|
|
!input.issue ||
|
|
!(input.resolution.humanDirectedIssueId && input.issue.companyId === input.resolution.boundary.companyId &&
|
|
input.issue.id === input.resolution.humanDirectedIssueId) &&
|
|
!(await workspaceIssueWithinLowTrustBoundary({
|
|
db: input.db,
|
|
boundary: input.resolution.boundary,
|
|
issue: input.issue,
|
|
}))
|
|
) {
|
|
throw unprocessable("Low-trust execution issue is outside the active trust boundary.", {
|
|
code: "low_trust_boundary_mismatch",
|
|
});
|
|
}
|
|
if (input.selectedEnvironmentDriver !== "sandbox") {
|
|
throw unprocessable("Low-trust execution requires a sandbox environment driver.", {
|
|
code: "low_trust_requires_sandbox_environment",
|
|
});
|
|
}
|
|
}
|
|
|
|
export function assertLowTrustRuntimeServicesAllowed(input: {
|
|
resolution: TrustPresetResolution;
|
|
runtimeServiceCount: number;
|
|
}) {
|
|
if (input.resolution.kind === "denied") {
|
|
throw unprocessable(input.resolution.detail, {
|
|
code: input.resolution.reason,
|
|
source: input.resolution.source,
|
|
});
|
|
}
|
|
if (input.resolution.kind !== "low_trust_review") return;
|
|
if (input.runtimeServiceCount === 0) return;
|
|
if (isLowTrustRuntimeManagementAllowed(input.resolution)) return;
|
|
throw unprocessable("Low-trust execution cannot start runtime services unless the boundary grants runtime.manage.", {
|
|
code: "low_trust_runtime_services_denied",
|
|
});
|
|
}
|