mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
Add low-trust review containment (#7530)
## Thinking Path > - Paperclip is a control plane for AI-agent companies, so execution policy and trust boundaries are part of the product's safety contract. > - Low-trust review work needs narrower authority than normal same-company agents because hostile PRs, comments, attachments, and generated output can carry prompt-injection payloads. > - The current V1 shape gives trusted workers broad company context, which is useful for normal execution but too permissive for a reviewer assigned to hostile content. > - This branch adds a `low_trust_review` preset, source-trust tagging, route-level containment, and quarantine handling so low-trust output does not automatically flow into higher-trust wake context. > - The branch has been rebased onto current `origin/master`, and the low-trust migration was renumbered to `0097_low_trust_source_trust.sql` to avoid collisions with existing `0091` through `0096` migrations. > - Greptile feedback was addressed by tightening low-trust detection, preserving project-level trust policy checks, fixing issue-kind promotion lookup, removing duplicate post-lease isolation assertion, documenting fail-closed source-trust behavior, bounding ancestry checks, enforcing runtime issue context for CEOs, awaiting accepted-plan monitor authorization, and making low-trust issue source-trust tagging atomic. > - The benefit is a first production slice of deny-by-default review containment with regression coverage for the main control-plane pivot surfaces. Fixes #7531. ## What Changed - Added shared trust-policy types and validators, plus database/source-trust fields for issues, comments, documents, and work products. - Implemented server enforcement for low-trust issue scope, agent self-view redaction, secret/plugin/runtime denial paths, promotion checks, and quarantined continuation/wake context. - Added focused low-trust regression tests for resolver behavior, source trust, route authorization, heartbeat preflight ordering, runtime containment, and quarantine redaction. - Added board UI affordances for selecting/reviewing the low-trust preset and surfacing source-trust badges in relevant issue views. - Added `doc/LOW-TRUST-PRESETS.md`, updated `doc/SPEC-implementation.md`, and committed the low-trust review contract plan under `doc/plans/`. - Rebasing note: the original `0097_low_trust_source_trust.sql` migration was renamed to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied the old-numbered migration are not broken by the renumbered migration. ## Verification - Rebased branch onto current `origin/master` and force-pushed with lease to `origin/PAP-10211-low-trust-agent` at head `2719f31e3`. - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Resolved upstream UI/comment conflicts by preserving deleted-comment tombstone behavior and low-trust source-trust badges/metadata. - Renumbered the low-trust source-trust migration to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied an old-numbered copy are not broken. - `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm exec vitest run server/src/__tests__/source-trust.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts ui/src/lib/trust-policy-ui.test.ts ui/src/components/TrustPresetSection.test.tsx` - `pnpm run typecheck:build-gaps` - `git diff --check` - GitHub checks pass on head `2719f31e3`: build, typecheck/release registry, general tests, serialized server suites, e2e, canary, verify, policy/review, Socket, and Snyk. - Greptile Review passes with Confidence Score 5/5 and zero unresolved Greptile review threads. - No design screenshots/images were added because the task explicitly says not to add them unless they are specifically part of the work. ## Risks - Medium risk: this touches shared trust-policy contracts, server authorization paths, heartbeat context generation, migration metadata, and UI preset controls. - Low-trust containment is intentionally deny-by-default; legitimate future review workflows may need explicit allowlisted exceptions. - Plugin/runtime/security surfaces are broad, so regression tests cover the current known routes but future integrations must route through the same containment layer. - The PR is ready for review; GitHub checks are green and Greptile is 5/5. > 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, GPT-5 coding agent, tool-enabled shell and GitHub CLI workflow. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI changes are covered by focused tests; no screenshots were added per task instruction not to add design images unless specifically required - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [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
b2a33d0184
commit
dbebf30c89
87 files changed
+6522
-121
No files matched your search
@@ -547,7 +547,7 @@ export function agentService(db: Db) {
|
||||
return existing ? { agent: existing, activated: false } : null;
|
||||
},
|
||||
|
||||
updatePermissions: async (id: string, permissions: { canCreateAgents: boolean }) => {
|
||||
updatePermissions: async (id: string, permissions: Record<string, unknown> & { canCreateAgents: boolean }) => {
|
||||
const existing = await getById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
agents,
|
||||
companyMemberships,
|
||||
heartbeatRuns,
|
||||
instanceUserRoles,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { PermissionKey, PrincipalType } from "@paperclipai/shared";
|
||||
import { LOW_TRUST_REVIEW_PRESET, type LowTrustBoundary } from "@paperclipai/shared";
|
||||
import {
|
||||
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
|
||||
isIssueWithinLowTrustBoundary,
|
||||
resolveCoreTrustPreset,
|
||||
type TrustPresetResolution,
|
||||
} from "./trust-preset-resolver.js";
|
||||
|
||||
export type AuthorizationActor =
|
||||
{
|
||||
@@ -19,6 +27,7 @@ export type AuthorizationActor =
|
||||
isInstanceAdmin?: boolean;
|
||||
agentId?: string | null;
|
||||
companyId?: string | null;
|
||||
runId?: string | null;
|
||||
source?:
|
||||
| "local_implicit"
|
||||
| "session"
|
||||
@@ -33,11 +42,19 @@ export type AuthorizationAction =
|
||||
| PermissionKey
|
||||
| "agent_config:read"
|
||||
| "agent_config:update"
|
||||
| "issue:mutate";
|
||||
| "agent:read"
|
||||
| "agent:wake"
|
||||
| "company_scope:read"
|
||||
| "issue:mutate"
|
||||
| "issue:read"
|
||||
| "project:read"
|
||||
| "runtime:manage"
|
||||
| "secrets:read";
|
||||
|
||||
export type AuthorizationResource =
|
||||
| { type: "company"; companyId: string }
|
||||
| { type: "agent"; companyId: string; agentId?: string | null }
|
||||
| { type: "project"; companyId: string; projectId?: string | null }
|
||||
| {
|
||||
type: "issue";
|
||||
companyId: string;
|
||||
@@ -54,6 +71,7 @@ export type AuthorizationDecision = {
|
||||
action: AuthorizationAction;
|
||||
explanation: string;
|
||||
reason:
|
||||
| "allow_low_trust_boundary"
|
||||
| "allow_local_board"
|
||||
| "allow_instance_admin"
|
||||
| "allow_explicit_grant"
|
||||
@@ -67,6 +85,7 @@ export type AuthorizationDecision = {
|
||||
| "deny_missing_membership"
|
||||
| "deny_missing_grant"
|
||||
| "deny_policy_restricted"
|
||||
| "deny_low_trust_boundary"
|
||||
| "deny_scope"
|
||||
| "deny_unsupported_action";
|
||||
grant?: {
|
||||
@@ -87,14 +106,25 @@ function companyIdForResource(resource: AuthorizationResource) {
|
||||
|
||||
function permissionForAction(action: AuthorizationAction): PermissionKey | null {
|
||||
if (action === "agent_config:read" || action === "agent_config:update") return "agents:create";
|
||||
if (
|
||||
action === "agent:read" ||
|
||||
action === "agent:wake" ||
|
||||
action === "company_scope:read" ||
|
||||
action === "issue:read" ||
|
||||
action === "project:read" ||
|
||||
action === "runtime:manage" ||
|
||||
action === "secrets:read"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (action === "issue:mutate") return null;
|
||||
return action;
|
||||
}
|
||||
|
||||
function canCreateAgentsLegacy(agent: { role: string; permissions: Record<string, unknown> | null | undefined }) {
|
||||
function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) {
|
||||
if (agent.role === "ceo") return true;
|
||||
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
||||
return Boolean(agent.permissions.canCreateAgents);
|
||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
||||
}
|
||||
|
||||
function scopeValueList(value: unknown): string[] {
|
||||
@@ -153,6 +183,30 @@ type AssignmentPolicyEffect =
|
||||
| { kind: "unknown"; explanation: string };
|
||||
|
||||
type AgentHierarchyRow = { id: string; reportsTo: string | null };
|
||||
type LowTrustBoundaryWithCompany = LowTrustBoundary & { companyId: string };
|
||||
type AgentAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
role: string;
|
||||
status: string;
|
||||
reportsTo: string | null;
|
||||
permissions: Record<string, unknown> | null | undefined;
|
||||
};
|
||||
type ProjectAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
executionWorkspacePolicy: unknown;
|
||||
};
|
||||
type IssueAuthorizationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
parentId: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
status: string;
|
||||
executionPolicy: unknown;
|
||||
};
|
||||
|
||||
function evaluateAuthorizationPolicyForAssignment(
|
||||
policy: Record<string, unknown> | null | undefined,
|
||||
@@ -453,7 +507,7 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAgent(agentId: string) {
|
||||
async function loadAgent(agentId: string): Promise<AgentAuthorizationRow | null> {
|
||||
return db
|
||||
.select({
|
||||
id: agents.id,
|
||||
@@ -468,6 +522,54 @@ export function authorizationService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function loadProject(projectId: string): Promise<ProjectAuthorizationRow | null> {
|
||||
return db
|
||||
.select({
|
||||
id: projects.id,
|
||||
companyId: projects.companyId,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function loadIssue(issueId: string): Promise<IssueAuthorizationRow | null> {
|
||||
return db
|
||||
.select({
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
projectId: issues.projectId,
|
||||
parentId: issues.parentId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
status: issues.status,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function loadRunPolicy(runId: string | null | undefined, companyId: string, agentId: string) {
|
||||
if (!runId) return null;
|
||||
const row = await db
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
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;
|
||||
return isPlainRecord(context?.executionPolicy)
|
||||
? { companyId: row.companyId, executionPolicy: context.executionPolicy }
|
||||
: null;
|
||||
}
|
||||
|
||||
async function loadProjectAuthorizationPolicy(companyId: string, projectId: string) {
|
||||
const row = await db
|
||||
.select({ executionWorkspacePolicy: projects.executionWorkspacePolicy })
|
||||
@@ -486,6 +588,201 @@ export function authorizationService(db: Db) {
|
||||
return readPolicyObject(row?.executionPolicy, "authorizationPolicy");
|
||||
}
|
||||
|
||||
async function loadResourceContext(resource: AuthorizationResource) {
|
||||
const issue = resource.type === "issue" && resource.issueId ? await loadIssue(resource.issueId) : null;
|
||||
const projectId =
|
||||
resource.type === "issue"
|
||||
? issue?.projectId ?? resource.projectId ?? null
|
||||
: resource.type === "project"
|
||||
? resource.projectId ?? null
|
||||
: null;
|
||||
const project = projectId ? await loadProject(projectId) : null;
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
async function resolveActorTrust(input: {
|
||||
actorAgent: AgentAuthorizationRow;
|
||||
actor: AuthorizationActor;
|
||||
companyId: string;
|
||||
resource: AuthorizationResource;
|
||||
}): Promise<TrustPresetResolution> {
|
||||
const { issue, project } = await loadResourceContext(input.resource);
|
||||
const run = await loadRunPolicy(input.actor.runId, input.companyId, input.actorAgent.id);
|
||||
return resolveCoreTrustPreset({
|
||||
companyId: input.companyId,
|
||||
agent: input.actorAgent,
|
||||
project,
|
||||
issue,
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
async function issueIdIsDescendantOf(issueId: string, rootIssueId: string, companyId: string) {
|
||||
const rows = await db.execute(sql`
|
||||
WITH RECURSIVE ancestors(id, parent_id, depth) AS (
|
||||
SELECT id, parent_id, 0
|
||||
FROM issues
|
||||
WHERE company_id = ${companyId}
|
||||
AND id = ${issueId}
|
||||
UNION ALL
|
||||
SELECT parent.id, parent.parent_id, ancestors.depth + 1
|
||||
FROM issues parent
|
||||
JOIN ancestors ON parent.id = ancestors.parent_id
|
||||
WHERE parent.company_id = ${companyId}
|
||||
AND ancestors.depth < ${LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH - 1}
|
||||
)
|
||||
SELECT EXISTS(SELECT 1 FROM ancestors WHERE id = ${rootIssueId}) AS is_descendant
|
||||
`);
|
||||
const first = Array.isArray(rows) ? rows[0] : null;
|
||||
return Boolean(
|
||||
first &&
|
||||
typeof first === "object" &&
|
||||
(first as Record<string, unknown>).is_descendant === true,
|
||||
);
|
||||
}
|
||||
|
||||
async function issueResourceWithinLowTrustBoundary(
|
||||
boundary: LowTrustBoundaryWithCompany,
|
||||
resource: Extract<AuthorizationResource, { type: "issue" }>,
|
||||
) {
|
||||
const issue = resource.issueId ? await loadIssue(resource.issueId) : null;
|
||||
const candidate = {
|
||||
companyId: resource.companyId,
|
||||
id: issue?.id ?? resource.issueId ?? null,
|
||||
projectId: issue?.projectId ?? resource.projectId ?? null,
|
||||
};
|
||||
if (isIssueWithinLowTrustBoundary(boundary, candidate)) return true;
|
||||
if (candidate.id && boundary.rootIssueId) {
|
||||
return issueIdIsDescendantOf(candidate.id, boundary.rootIssueId, boundary.companyId);
|
||||
}
|
||||
if (!resource.parentIssueId) return false;
|
||||
const parent = await loadIssue(resource.parentIssueId);
|
||||
if (!parent) return false;
|
||||
if (
|
||||
isIssueWithinLowTrustBoundary(boundary, {
|
||||
companyId: parent.companyId,
|
||||
id: parent.id,
|
||||
projectId: parent.projectId,
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return boundary.rootIssueId
|
||||
? issueIdIsDescendantOf(parent.id, boundary.rootIssueId, boundary.companyId)
|
||||
: false;
|
||||
}
|
||||
|
||||
async function projectWithinLowTrustBoundary(
|
||||
boundary: LowTrustBoundaryWithCompany,
|
||||
projectId: string | null | undefined,
|
||||
) {
|
||||
if (!projectId) return false;
|
||||
if (boundary.projectIds?.includes(projectId)) return true;
|
||||
if (!boundary.rootIssueId) return false;
|
||||
const rootIssue = await loadIssue(boundary.rootIssueId);
|
||||
return rootIssue?.companyId === boundary.companyId && rootIssue.projectId === projectId;
|
||||
}
|
||||
|
||||
function agentWithinLowTrustBoundary(
|
||||
boundary: LowTrustBoundaryWithCompany,
|
||||
actorAgentId: string,
|
||||
targetAgentId: string | null | undefined,
|
||||
) {
|
||||
if (!targetAgentId) return false;
|
||||
return targetAgentId === actorAgentId || Boolean(boundary.allowedAgentIds?.includes(targetAgentId));
|
||||
}
|
||||
|
||||
async function decideLowTrustAccess(input: {
|
||||
actorAgentId: string;
|
||||
action: AuthorizationAction;
|
||||
resource: AuthorizationResource;
|
||||
resolution: TrustPresetResolution;
|
||||
}): Promise<AuthorizationDecision | null> {
|
||||
if (input.resolution.kind === "standard") return null;
|
||||
if (input.resolution.kind === "denied") {
|
||||
return deny({
|
||||
action: input.action,
|
||||
reason: "deny_policy_restricted",
|
||||
explanation: input.resolution.detail,
|
||||
});
|
||||
}
|
||||
|
||||
const boundary = input.resolution.boundary;
|
||||
const lowTrustDeny = (explanation: string) =>
|
||||
deny({
|
||||
action: input.action,
|
||||
reason: "deny_low_trust_boundary",
|
||||
explanation,
|
||||
});
|
||||
const lowTrustAllow = (explanation: string) =>
|
||||
allow({
|
||||
action: input.action,
|
||||
reason: "allow_low_trust_boundary",
|
||||
explanation,
|
||||
});
|
||||
|
||||
if (
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
return lowTrustDeny(
|
||||
`${LOW_TRUST_REVIEW_PRESET} agents cannot use company-wide or privileged ${input.action} APIs by default.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (input.action === "agent:read" || input.action === "agent:wake") {
|
||||
if (input.resource.type !== "agent") {
|
||||
return lowTrustDeny("Low-trust agent action is missing an agent resource.");
|
||||
}
|
||||
return agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.agentId)
|
||||
? lowTrustAllow("Allowed inside the low-trust agent boundary.")
|
||||
: lowTrustDeny("Agent is outside this low-trust boundary.");
|
||||
}
|
||||
|
||||
if (input.action === "project:read") {
|
||||
const projectId =
|
||||
input.resource.type === "issue"
|
||||
? input.resource.projectId
|
||||
: input.resource.type === "project"
|
||||
? input.resource.projectId
|
||||
: null;
|
||||
return await projectWithinLowTrustBoundary(boundary, projectId)
|
||||
? lowTrustAllow("Allowed inside the low-trust project boundary.")
|
||||
: lowTrustDeny("Project is outside this low-trust boundary.");
|
||||
}
|
||||
|
||||
if (input.action === "issue:read" || input.action === "issue:mutate") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return lowTrustDeny("Low-trust issue access is missing an issue resource.");
|
||||
}
|
||||
return await issueResourceWithinLowTrustBoundary(boundary, input.resource)
|
||||
? lowTrustAllow("Allowed inside the low-trust issue boundary.")
|
||||
: lowTrustDeny("Issue is outside this low-trust boundary.");
|
||||
}
|
||||
|
||||
if (input.action === "tasks:assign") {
|
||||
if (input.resource.type !== "issue") {
|
||||
return lowTrustDeny("Low-trust task assignment is missing an issue resource.");
|
||||
}
|
||||
if (!(await issueResourceWithinLowTrustBoundary(boundary, input.resource))) {
|
||||
return lowTrustDeny("Task target is outside this low-trust boundary.");
|
||||
}
|
||||
if (input.resource.assigneeUserId) {
|
||||
return lowTrustDeny("Low-trust agents cannot assign work to board users.");
|
||||
}
|
||||
if (
|
||||
input.resource.assigneeAgentId &&
|
||||
!agentWithinLowTrustBoundary(boundary, input.actorAgentId, input.resource.assigneeAgentId)
|
||||
) {
|
||||
return lowTrustDeny("Assignee agent is outside this low-trust boundary.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function assignmentTargetIsInCompany(resource: AuthorizationResource) {
|
||||
if (resource.type !== "issue") return true;
|
||||
if (resource.assigneeAgentId) {
|
||||
@@ -709,6 +1006,55 @@ export function authorizationService(db: Db) {
|
||||
});
|
||||
}
|
||||
|
||||
const lowTrustDecision = await decideLowTrustAccess({
|
||||
actorAgentId,
|
||||
action: input.action,
|
||||
resource: input.resource,
|
||||
resolution: await resolveActorTrust({
|
||||
actorAgent,
|
||||
actor: input.actor,
|
||||
companyId,
|
||||
resource: input.resource,
|
||||
}),
|
||||
});
|
||||
if (lowTrustDecision) {
|
||||
if (!lowTrustDecision.allowed) return lowTrustDecision;
|
||||
if (
|
||||
input.action === "agent:read" ||
|
||||
input.action === "agent:wake" ||
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "issue:read" ||
|
||||
input.action === "project:read" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
return lowTrustDecision;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
input.action === "agent:read" ||
|
||||
input.action === "company_scope:read" ||
|
||||
input.action === "issue:read" ||
|
||||
input.action === "project:read" ||
|
||||
input.action === "runtime:manage" ||
|
||||
input.action === "secrets:read"
|
||||
) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_company_agent",
|
||||
explanation: "Allowed by standard same-company agent visibility.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "agent:wake" && input.resource.type === "agent" && input.resource.agentId === actorAgentId) {
|
||||
return allow({
|
||||
action: input.action,
|
||||
reason: "allow_self",
|
||||
explanation: "Allowed because the actor is waking itself.",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === "tasks:assign") {
|
||||
if (!isSimpleAssignableAgentStatus(actorAgent.status)) {
|
||||
return deny({
|
||||
|
||||
@@ -57,6 +57,7 @@ function mapIssueDocumentRow(
|
||||
lockedAt: Date | null;
|
||||
lockedByAgentId: string | null;
|
||||
lockedByUserId: string | null;
|
||||
sourceTrust: typeof documents.$inferSelect.sourceTrust;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
@@ -79,6 +80,7 @@ function mapIssueDocumentRow(
|
||||
lockedAt: row.lockedAt,
|
||||
lockedByAgentId: row.lockedByAgentId,
|
||||
lockedByUserId: row.lockedByUserId,
|
||||
sourceTrust: row.sourceTrust ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -101,6 +103,7 @@ const issueDocumentSelect = {
|
||||
lockedAt: documents.lockedAt,
|
||||
lockedByAgentId: documents.lockedByAgentId,
|
||||
lockedByUserId: documents.lockedByUserId,
|
||||
sourceTrust: documents.sourceTrust,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
};
|
||||
@@ -202,6 +205,7 @@ export function documentService(db: Db) {
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
createdByRunId?: string | null;
|
||||
sourceTrust?: typeof documents.$inferInsert.sourceTrust;
|
||||
lockedDocumentStrategy?: "conflict" | "create_new_document";
|
||||
}) => {
|
||||
const key = normalizeDocumentKey(input.key);
|
||||
@@ -235,6 +239,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: documents.lockedAt,
|
||||
lockedByAgentId: documents.lockedByAgentId,
|
||||
lockedByUserId: documents.lockedByUserId,
|
||||
sourceTrust: documents.sourceTrust,
|
||||
createdAt: documents.createdAt,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
@@ -268,6 +273,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: null,
|
||||
lockedByAgentId: null,
|
||||
lockedByUserId: null,
|
||||
sourceTrust: input.sourceTrust ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -327,6 +333,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: null,
|
||||
lockedByAgentId: null,
|
||||
lockedByUserId: null,
|
||||
sourceTrust: document.sourceTrust ?? null,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
@@ -379,6 +386,7 @@ export function documentService(db: Db) {
|
||||
latestRevisionNumber: nextRevisionNumber,
|
||||
updatedByAgentId: input.createdByAgentId ?? null,
|
||||
updatedByUserId: input.createdByUserId ?? null,
|
||||
sourceTrust: input.sourceTrust ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(documents.id, existing.id));
|
||||
@@ -402,6 +410,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: existing.lockedAt,
|
||||
lockedByAgentId: existing.lockedByAgentId,
|
||||
lockedByUserId: existing.lockedByUserId,
|
||||
sourceTrust: input.sourceTrust ?? null,
|
||||
updatedAt: now,
|
||||
},
|
||||
};
|
||||
@@ -427,6 +436,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: null,
|
||||
lockedByAgentId: null,
|
||||
lockedByUserId: null,
|
||||
sourceTrust: input.sourceTrust ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
@@ -482,6 +492,7 @@ export function documentService(db: Db) {
|
||||
lockedAt: document.lockedAt,
|
||||
lockedByAgentId: document.lockedByAgentId,
|
||||
lockedByUserId: document.lockedByUserId,
|
||||
sourceTrust: document.sourceTrust ?? null,
|
||||
createdAt: document.createdAt,
|
||||
updatedAt: document.updatedAt,
|
||||
},
|
||||
|
||||
@@ -76,6 +76,9 @@ export function parseProjectExecutionWorkspacePolicy(raw: unknown): ProjectExecu
|
||||
...(parsed.cleanupPolicy && typeof parsed.cleanupPolicy === "object" && !Array.isArray(parsed.cleanupPolicy)
|
||||
? { cleanupPolicy: { ...(parsed.cleanupPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
...(parsed.authorizationPolicy && typeof parsed.authorizationPolicy === "object" && !Array.isArray(parsed.authorizationPolicy)
|
||||
? { authorizationPolicy: { ...(parsed.authorizationPolicy as Record<string, unknown>) } }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
AGENT_DEFAULT_MAX_CONCURRENT_RUNS,
|
||||
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
||||
MODEL_PROFILE_KEYS,
|
||||
envBindingSchema,
|
||||
isEnvironmentDriverSupportedForAdapter,
|
||||
type BillingType,
|
||||
type EnvironmentLeaseStatus,
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type ModelProfileKey,
|
||||
type RoutineRevisionSnapshotV1,
|
||||
type RunLivenessState,
|
||||
type SourceTrustMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
agents,
|
||||
@@ -153,6 +155,10 @@ import {
|
||||
import { recoveryService } from "./recovery/service.js";
|
||||
import { productivityReviewService } from "./productivity-review.js";
|
||||
import { withAgentStartLock } from "./agent-start-lock.js";
|
||||
import {
|
||||
redactQuarantinedBodyForHigherTrust,
|
||||
sanitizeQuarantinedCommentForHigherTrust,
|
||||
} from "./source-trust.js";
|
||||
import {
|
||||
redactCurrentUserText,
|
||||
redactCurrentUserValue,
|
||||
@@ -173,6 +179,11 @@ import { environmentService } from "./environments.js";
|
||||
import { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import { environmentRunOrchestrator } from "./environment-run-orchestrator.js";
|
||||
import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js";
|
||||
import {
|
||||
assertLowTrustRuntimeServicesAllowed,
|
||||
assertLowTrustWorkspaceIsolation,
|
||||
} from "./low-trust-runtime-containment.js";
|
||||
import { resolveCoreTrustPreset, type TrustPresetResolution } from "./trust-preset-resolver.js";
|
||||
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
||||
|
||||
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
|
||||
@@ -336,6 +347,9 @@ type RuntimeConfigSecretResolver = Pick<
|
||||
"resolveAdapterConfigForRuntime" | "resolveEnvBindings"
|
||||
>;
|
||||
|
||||
const LOW_TRUST_SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
|
||||
function isPaperclipRuntimeEnvKey(key: string) {
|
||||
return key.startsWith("PAPERCLIP_");
|
||||
}
|
||||
@@ -356,6 +370,24 @@ function stripPaperclipRuntimeEnvFromAdapterConfig(config: Record<string, unknow
|
||||
};
|
||||
}
|
||||
|
||||
function assertLowTrustEnvConfigAllowed(envValue: unknown, source: string) {
|
||||
const record = stripPaperclipRuntimeEnvBindings(envValue);
|
||||
if (!record) return;
|
||||
for (const [key, rawBinding] of Object.entries(record)) {
|
||||
const parsed = envBindingSchema.safeParse(rawBinding);
|
||||
if (!parsed.success) continue;
|
||||
const binding = parsed.data;
|
||||
const isPlainBinding =
|
||||
typeof binding === "string" ||
|
||||
(typeof binding === "object" && binding !== null && binding.type === "plain");
|
||||
if (isPlainBinding && LOW_TRUST_SENSITIVE_ENV_KEY_RE.test(key)) {
|
||||
throw new HttpError(422, `Low-trust execution cannot use inline sensitive env value ${source}.${key}`, {
|
||||
code: "low_trust_inline_sensitive_env_denied",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveExecutionRunAdapterConfig(input: {
|
||||
companyId: string;
|
||||
agentId?: string | null;
|
||||
@@ -367,10 +399,19 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
projectEnv: unknown;
|
||||
routineEnv?: unknown;
|
||||
secretsSvc: RuntimeConfigSecretResolver;
|
||||
trustPreset?: TrustPresetResolution;
|
||||
}) {
|
||||
const executionRunConfig = stripPaperclipRuntimeEnvFromAdapterConfig(input.executionRunConfig);
|
||||
const projectEnv = stripPaperclipRuntimeEnvBindings(input.projectEnv);
|
||||
const routineEnv = stripPaperclipRuntimeEnvBindings(input.routineEnv);
|
||||
const lowTrustAllowedBindingIds = input.trustPreset?.kind === "low_trust_review"
|
||||
? input.trustPreset.boundary.allowedSecretBindingIds ?? []
|
||||
: undefined;
|
||||
if (input.trustPreset?.kind === "low_trust_review") {
|
||||
assertLowTrustEnvConfigAllowed(executionRunConfig.env, "agent.env");
|
||||
assertLowTrustEnvConfigAllowed(projectEnv, "project.env");
|
||||
assertLowTrustEnvConfigAllowed(routineEnv, "routine.env");
|
||||
}
|
||||
const { config: resolvedConfig, secretKeys, manifest } = await input.secretsSvc.resolveAdapterConfigForRuntime(
|
||||
input.companyId,
|
||||
executionRunConfig,
|
||||
@@ -382,6 +423,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
actorId: input.agentId,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
@@ -397,6 +439,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
actorId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
@@ -422,6 +465,7 @@ export async function resolveExecutionRunAdapterConfig(input: {
|
||||
actorId: input.agentId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
...(lowTrustAllowedBindingIds !== undefined ? { allowedBindingIds: lowTrustAllowedBindingIds } : {}),
|
||||
}
|
||||
: undefined,
|
||||
)
|
||||
@@ -710,6 +754,79 @@ function buildExecutionWorkspaceConfigSnapshot(
|
||||
return hasSnapshot ? snapshot : null;
|
||||
}
|
||||
|
||||
export function stripHostWorkspaceProvisionForLowTrustSandbox(input: {
|
||||
config: Record<string, unknown>;
|
||||
trustPreset: TrustPresetResolution;
|
||||
selectedEnvironmentDriver: string | null | undefined;
|
||||
}): Record<string, unknown> {
|
||||
if (input.trustPreset.kind !== "low_trust_review") return input.config;
|
||||
if (input.selectedEnvironmentDriver !== "sandbox") return input.config;
|
||||
|
||||
const workspaceStrategy = parseObject(input.config.workspaceStrategy);
|
||||
if (typeof workspaceStrategy.provisionCommand !== "string") return input.config;
|
||||
|
||||
const nextWorkspaceStrategy = { ...workspaceStrategy };
|
||||
delete nextWorkspaceStrategy.provisionCommand;
|
||||
|
||||
return {
|
||||
...input.config,
|
||||
workspaceStrategy: nextWorkspaceStrategy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function preflightLowTrustWorkspaceIsolation(input: {
|
||||
db?: Db;
|
||||
trustPreset: TrustPresetResolution;
|
||||
isolatedWorkspacesEnabled: boolean;
|
||||
effectiveExecutionWorkspaceMode: string | null | undefined;
|
||||
issue: { companyId: string; id?: string | null; projectId?: string | null } | null;
|
||||
resolveSelectedEnvironmentDriver: () => Promise<string | null | undefined>;
|
||||
}): Promise<string | null> {
|
||||
if (input.trustPreset.kind !== "denied" && input.trustPreset.kind !== "low_trust_review") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectedEnvironmentDriver =
|
||||
input.trustPreset.kind === "low_trust_review"
|
||||
? await input.resolveSelectedEnvironmentDriver()
|
||||
: null;
|
||||
|
||||
await assertLowTrustWorkspaceIsolation({
|
||||
db: input.db,
|
||||
resolution: input.trustPreset,
|
||||
isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled,
|
||||
effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode,
|
||||
selectedEnvironmentDriver,
|
||||
issue: input.issue,
|
||||
});
|
||||
|
||||
return selectedEnvironmentDriver ?? null;
|
||||
}
|
||||
|
||||
export async function resolveWorkspaceAfterLowTrustPreflight<TWorkspace>(input: {
|
||||
db?: Db;
|
||||
trustPreset: TrustPresetResolution;
|
||||
isolatedWorkspacesEnabled: boolean;
|
||||
effectiveExecutionWorkspaceMode: string | null | undefined;
|
||||
issue: { companyId: string; id?: string | null; projectId?: string | null } | null;
|
||||
resolveSelectedEnvironmentDriver: () => Promise<string | null | undefined>;
|
||||
resolveWorkspace: () => Promise<TWorkspace>;
|
||||
}): Promise<{ selectedEnvironmentDriver: string | null; workspace: TWorkspace }> {
|
||||
const selectedEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({
|
||||
db: input.db,
|
||||
trustPreset: input.trustPreset,
|
||||
isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled,
|
||||
effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode,
|
||||
issue: input.issue,
|
||||
resolveSelectedEnvironmentDriver: input.resolveSelectedEnvironmentDriver,
|
||||
});
|
||||
|
||||
return {
|
||||
selectedEnvironmentDriver,
|
||||
workspace: await input.resolveWorkspace(),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null {
|
||||
const trimmed = repoUrl?.trim() ?? "";
|
||||
if (!trimmed) return null;
|
||||
@@ -2050,6 +2167,7 @@ export async function buildPaperclipWakePayload(input: {
|
||||
key: string;
|
||||
title: string | null;
|
||||
body: string;
|
||||
sourceTrust?: SourceTrustMetadata | null;
|
||||
updatedAt: Date;
|
||||
}
|
||||
| null;
|
||||
@@ -2061,8 +2179,11 @@ export async function buildPaperclipWakePayload(input: {
|
||||
status: string;
|
||||
priority: string;
|
||||
workMode: string;
|
||||
projectId?: string | null;
|
||||
executionPolicy?: unknown;
|
||||
}
|
||||
| null;
|
||||
exposeLowTrustRaw?: boolean;
|
||||
}) {
|
||||
const executionStage = parseObject(input.contextSnapshot.executionStage);
|
||||
const commentIds = extractWakeCommentIds(input.contextSnapshot);
|
||||
@@ -2105,6 +2226,7 @@ export async function buildPaperclipWakePayload(input: {
|
||||
deletedByAgentId: issueComments.deletedByAgentId,
|
||||
deletedByUserId: issueComments.deletedByUserId,
|
||||
deletedByRunId: issueComments.deletedByRunId,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
createdAt: issueComments.createdAt,
|
||||
})
|
||||
.from(issueComments)
|
||||
@@ -2120,6 +2242,10 @@ export async function buildPaperclipWakePayload(input: {
|
||||
let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS;
|
||||
let truncated = false;
|
||||
let missingCommentCount = 0;
|
||||
const safeContinuationSummary =
|
||||
continuationSummary && !input.exposeLowTrustRaw
|
||||
? redactQuarantinedBodyForHigherTrust(continuationSummary)
|
||||
: continuationSummary;
|
||||
|
||||
for (const commentId of commentIds) {
|
||||
const row = commentsById.get(commentId);
|
||||
@@ -2134,7 +2260,8 @@ export async function buildPaperclipWakePayload(input: {
|
||||
}
|
||||
|
||||
const deletedAt = row.deletedAt ?? null;
|
||||
const fullBody = deletedAt ? "" : row.body;
|
||||
const safeRow = deletedAt || input.exposeLowTrustRaw ? row : sanitizeQuarantinedCommentForHigherTrust(row);
|
||||
const fullBody = deletedAt ? "" : safeRow.body;
|
||||
const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars);
|
||||
if (allowedBodyChars <= 0) {
|
||||
truncated = true;
|
||||
@@ -2152,13 +2279,14 @@ export async function buildPaperclipWakePayload(input: {
|
||||
authorType: row.authorType ?? (row.authorAgentId ? "agent" : row.authorUserId ? "user" : "system"),
|
||||
body,
|
||||
bodyTruncated,
|
||||
presentation: deletedAt ? null : row.presentation ?? null,
|
||||
metadata: deletedAt ? null : row.metadata ?? null,
|
||||
presentation: deletedAt ? null : safeRow.presentation ?? null,
|
||||
metadata: deletedAt ? null : safeRow.metadata ?? null,
|
||||
deletedAt: deletedAt ? deletedAt.toISOString() : null,
|
||||
deletedByType: deletedAt ? row.deletedByType ?? null : null,
|
||||
deletedByAgentId: deletedAt ? row.deletedByAgentId ?? null : null,
|
||||
deletedByUserId: deletedAt ? row.deletedByUserId ?? null : null,
|
||||
deletedByRunId: deletedAt ? row.deletedByRunId ?? null : null,
|
||||
sourceTrust: row.sourceTrust ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
author: row.authorAgentId
|
||||
? { type: "agent", id: row.authorAgentId }
|
||||
@@ -2261,16 +2389,17 @@ export async function buildPaperclipWakePayload(input: {
|
||||
? input.contextSnapshot.unresolvedBlockerSummaries
|
||||
: [],
|
||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||
continuationSummary: continuationSummary
|
||||
continuationSummary: safeContinuationSummary
|
||||
? {
|
||||
key: continuationSummary.key,
|
||||
title: continuationSummary.title,
|
||||
key: safeContinuationSummary.key,
|
||||
title: safeContinuationSummary.title,
|
||||
body:
|
||||
continuationSummary.body.length > 4_000
|
||||
? continuationSummary.body.slice(0, 4_000)
|
||||
: continuationSummary.body,
|
||||
bodyTruncated: continuationSummary.body.length > 4_000,
|
||||
updatedAt: continuationSummary.updatedAt.toISOString(),
|
||||
safeContinuationSummary.body.length > 4_000
|
||||
? safeContinuationSummary.body.slice(0, 4_000)
|
||||
: safeContinuationSummary.body,
|
||||
bodyTruncated: safeContinuationSummary.body.length > 4_000,
|
||||
sourceTrust: safeContinuationSummary.sourceTrust ?? null,
|
||||
updatedAt: safeContinuationSummary.updatedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
commentIds,
|
||||
@@ -2768,6 +2897,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionPolicy: issues.executionPolicy,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
originKind: issues.originKind,
|
||||
originId: issues.originId,
|
||||
@@ -7248,6 +7378,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
deletedByAgentId: issueComments.deletedByAgentId,
|
||||
deletedByUserId: issueComments.deletedByUserId,
|
||||
deletedByRunId: issueComments.deletedByRunId,
|
||||
sourceTrust: issueComments.sourceTrust,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(and(
|
||||
@@ -7326,6 +7457,25 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
parseProjectExecutionWorkspacePolicy(projectContext?.executionWorkspacePolicy),
|
||||
isolatedWorkspacesEnabled,
|
||||
);
|
||||
const trustPreset = resolveCoreTrustPreset({
|
||||
companyId: agent.companyId,
|
||||
agent: {
|
||||
companyId: agent.companyId,
|
||||
permissions: agent.permissions,
|
||||
},
|
||||
project: projectContext
|
||||
? {
|
||||
companyId: agent.companyId,
|
||||
executionWorkspacePolicy: projectExecutionWorkspacePolicy,
|
||||
}
|
||||
: null,
|
||||
issue: issueContext
|
||||
? {
|
||||
companyId: agent.companyId,
|
||||
executionPolicy: issueContext.executionPolicy,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
const taskSession = taskKey
|
||||
? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey)
|
||||
: null;
|
||||
@@ -7351,17 +7501,15 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null),
|
||||
);
|
||||
const config = parseObject(agent.adapterConfig);
|
||||
const requestedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||
const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({
|
||||
projectPolicy: projectExecutionWorkspacePolicy,
|
||||
issueSettings: issueExecutionWorkspaceSettings,
|
||||
legacyUseProjectWorkspace: issueAssigneeOverrides?.useProjectWorkspace ?? null,
|
||||
});
|
||||
const resolvedWorkspace = await resolveWorkspaceForRun(
|
||||
agent,
|
||||
context,
|
||||
previousSessionParams,
|
||||
{ useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
|
||||
);
|
||||
const requestedExecutionWorkspaceMode =
|
||||
trustPreset.kind === "low_trust_review" && resolvedExecutionWorkspaceMode === "shared_workspace"
|
||||
? "isolated_workspace"
|
||||
: resolvedExecutionWorkspaceMode;
|
||||
const issueRef = issueContext
|
||||
? {
|
||||
id: issueContext.id,
|
||||
@@ -7380,12 +7528,22 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
const continuationSummary = issueRef
|
||||
? await getIssueContinuationSummaryDocument(db, issueRef.id)
|
||||
: null;
|
||||
const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
|
||||
const safeContinuationSummary =
|
||||
continuationSummary && !exposeLowTrustRaw
|
||||
? redactQuarantinedBodyForHigherTrust(continuationSummary)
|
||||
: continuationSummary;
|
||||
const safeWakeCommentContext =
|
||||
wakeCommentContext && !exposeLowTrustRaw
|
||||
? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
|
||||
: wakeCommentContext;
|
||||
if (continuationSummary) {
|
||||
context.paperclipContinuationSummary = {
|
||||
key: continuationSummary.key,
|
||||
title: continuationSummary.title,
|
||||
body: continuationSummary.body,
|
||||
updatedAt: continuationSummary.updatedAt.toISOString(),
|
||||
key: safeContinuationSummary!.key,
|
||||
title: safeContinuationSummary!.title,
|
||||
body: safeContinuationSummary!.body,
|
||||
sourceTrust: safeContinuationSummary!.sourceTrust ?? null,
|
||||
updatedAt: safeContinuationSummary!.updatedAt.toISOString(),
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipContinuationSummary;
|
||||
@@ -7403,8 +7561,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
workMode: issueRef.workMode,
|
||||
projectId: issueRef.projectId,
|
||||
executionPolicy: issueContext?.executionPolicy ?? null,
|
||||
}
|
||||
: null,
|
||||
exposeLowTrustRaw,
|
||||
});
|
||||
if (paperclipWakePayload) {
|
||||
context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
|
||||
@@ -7421,7 +7582,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
wakeComment: wakeCommentContext,
|
||||
wakeComment: safeWakeCommentContext,
|
||||
interaction: {
|
||||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
@@ -7442,7 +7603,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
delete context.paperclipIssue;
|
||||
}
|
||||
if (wakeCommentContext) {
|
||||
context.paperclipWakeComment = wakeCommentContext;
|
||||
context.paperclipWakeComment = safeWakeCommentContext;
|
||||
} else {
|
||||
delete context.paperclipWakeComment;
|
||||
}
|
||||
@@ -7504,6 +7665,37 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
? persistedExecutionWorkspaceMode
|
||||
: requestedExecutionWorkspaceMode;
|
||||
const selectedEnvironmentId = environmentResolution.environmentId;
|
||||
const {
|
||||
selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver,
|
||||
workspace: resolvedWorkspace,
|
||||
} = await resolveWorkspaceAfterLowTrustPreflight({
|
||||
db,
|
||||
trustPreset,
|
||||
isolatedWorkspacesEnabled,
|
||||
effectiveExecutionWorkspaceMode,
|
||||
issue: issueRef
|
||||
? {
|
||||
companyId: agent.companyId,
|
||||
id: issueRef.id,
|
||||
projectId: issueRef.projectId,
|
||||
}
|
||||
: null,
|
||||
resolveSelectedEnvironmentDriver: async () => {
|
||||
const preflightEnvironment = await envOrchestrator.resolveEnvironment({
|
||||
companyId: agent.companyId,
|
||||
selectedEnvironmentId,
|
||||
defaultEnvironmentId: defaultEnvironment.id,
|
||||
});
|
||||
return preflightEnvironment.driver;
|
||||
},
|
||||
resolveWorkspace: () =>
|
||||
resolveWorkspaceForRun(
|
||||
agent,
|
||||
context,
|
||||
previousSessionParams,
|
||||
{ useProjectWorkspace: requestedExecutionWorkspaceMode !== "agent_default" },
|
||||
),
|
||||
});
|
||||
const workspaceManagedConfig = shouldReuseExisting
|
||||
? { ...config }
|
||||
: buildExecutionWorkspaceAdapterConfig({
|
||||
@@ -7567,6 +7759,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
projectEnv: projectContext?.env ?? null,
|
||||
routineEnv: routineEnvContext.env,
|
||||
secretsSvc,
|
||||
trustPreset,
|
||||
});
|
||||
if (secretManifest.length > 0) {
|
||||
context.paperclipSecrets = {
|
||||
@@ -7589,6 +7782,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
...effectiveResolvedConfig,
|
||||
paperclipRuntimeSkills: runtimeSkillEntries,
|
||||
};
|
||||
const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({
|
||||
config: runtimeConfig,
|
||||
trustPreset,
|
||||
selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver,
|
||||
});
|
||||
const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({
|
||||
companyId: agent.companyId,
|
||||
heartbeatRunId: run.id,
|
||||
@@ -7637,7 +7835,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
: null;
|
||||
const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({
|
||||
base: executionWorkspaceBase,
|
||||
config: runtimeConfig,
|
||||
config: hostExecutionWorkspaceConfig,
|
||||
issue: issueRef,
|
||||
agent: {
|
||||
id: agent.id,
|
||||
@@ -7902,6 +8100,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
||||
)
|
||||
: [];
|
||||
})();
|
||||
assertLowTrustRuntimeServicesAllowed({
|
||||
resolution: trustPreset,
|
||||
runtimeServiceCount: runtimeServiceIntents.length,
|
||||
});
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
context.paperclipRuntimeServiceIntents = runtimeServiceIntents;
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { documents, issueDocuments, issues } from "@paperclipai/db";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "@paperclipai/shared";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, type SourceTrustMetadata } from "@paperclipai/shared";
|
||||
import { documentService } from "./documents.js";
|
||||
|
||||
export { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY };
|
||||
@@ -44,6 +44,7 @@ export type IssueContinuationSummaryDocument = {
|
||||
body: string;
|
||||
latestRevisionId: string | null;
|
||||
latestRevisionNumber: number;
|
||||
sourceTrust: SourceTrustMetadata | null;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@@ -218,6 +219,7 @@ export async function getIssueContinuationSummaryDocument(
|
||||
body: documents.latestBody,
|
||||
latestRevisionId: documents.latestRevisionId,
|
||||
latestRevisionNumber: documents.latestRevisionNumber,
|
||||
sourceTrust: documents.sourceTrust,
|
||||
updatedAt: documents.updatedAt,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
@@ -232,6 +234,7 @@ export async function getIssueContinuationSummaryDocument(
|
||||
body: row.body,
|
||||
latestRevisionId: row.latestRevisionId,
|
||||
latestRevisionNumber: row.latestRevisionNumber,
|
||||
sourceTrust: row.sourceTrust ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -387,13 +387,18 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol
|
||||
}
|
||||
: null;
|
||||
|
||||
if (stages.length === 0 && !monitor) return null;
|
||||
const reviewPreset = parsed.data.reviewPreset;
|
||||
const authorizationPolicy = parsed.data.authorizationPolicy;
|
||||
|
||||
if (stages.length === 0 && !monitor && !reviewPreset && !authorizationPolicy) return null;
|
||||
|
||||
return {
|
||||
mode: parsed.data.mode ?? "normal",
|
||||
commentRequired: true,
|
||||
stages,
|
||||
...(monitor ? { monitor } : {}),
|
||||
...(reviewPreset ? { reviewPreset } : {}),
|
||||
...(authorizationPolicy ? { authorizationPolicy } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
IssueProductivityReview,
|
||||
IssueProductivityReviewTrigger,
|
||||
IssueRelationIssueSummary,
|
||||
LowTrustBoundary,
|
||||
SuccessfulRunHandoffState,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -244,6 +245,7 @@ export interface IssueFilters {
|
||||
includeBlockedBy?: boolean;
|
||||
includeBlockedInboxAttention?: boolean;
|
||||
hasPlanDocument?: boolean;
|
||||
lowTrustBoundary?: LowTrustBoundary & { companyId: string };
|
||||
q?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
@@ -1181,6 +1183,39 @@ const PRODUCTIVITY_REVIEW_TRIGGERS: readonly IssueProductivityReviewTrigger[] =
|
||||
"long_active_duration",
|
||||
"high_churn",
|
||||
];
|
||||
|
||||
function lowTrustBoundaryIssueCondition(
|
||||
companyId: string,
|
||||
boundary: (LowTrustBoundary & { companyId: string }) | null | undefined,
|
||||
) {
|
||||
if (!boundary || boundary.companyId !== companyId) return null;
|
||||
const clauses: SQL[] = [];
|
||||
const issueIds = [...new Set(boundary.issueIds ?? [])];
|
||||
const projectIds = [...new Set(boundary.projectIds ?? [])];
|
||||
if (issueIds.length > 0) clauses.push(inArray(issues.id, issueIds));
|
||||
if (projectIds.length > 0) clauses.push(inArray(issues.projectId, projectIds));
|
||||
if (boundary.rootIssueId) {
|
||||
clauses.push(sql<boolean>`
|
||||
${issues.id} IN (
|
||||
WITH RECURSIVE descendants(id) AS (
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
AND ${issues.id} = ${boundary.rootIssueId}
|
||||
UNION
|
||||
SELECT ${issues.id}
|
||||
FROM ${issues}
|
||||
JOIN descendants ON ${issues.parentId} = descendants.id
|
||||
WHERE ${issues.companyId} = ${companyId}
|
||||
)
|
||||
SELECT id FROM descendants
|
||||
)
|
||||
`);
|
||||
}
|
||||
if (clauses.length === 0) return sql<boolean>`false`;
|
||||
return or(...clauses);
|
||||
}
|
||||
|
||||
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
@@ -1934,6 +1969,7 @@ const issueListSelect = {
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
executionWorkspaceSettings: sql<null>`null`,
|
||||
sourceTrust: issues.sourceTrust,
|
||||
startedAt: issues.startedAt,
|
||||
completedAt: issues.completedAt,
|
||||
cancelledAt: issues.cancelledAt,
|
||||
@@ -2873,6 +2909,8 @@ async function blockedInboxIssueConditions(
|
||||
)
|
||||
`);
|
||||
}
|
||||
const lowTrustCondition = lowTrustBoundaryIssueCondition(companyId, filters?.lowTrustBoundary);
|
||||
if (lowTrustCondition) conditions.push(lowTrustCondition);
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((status) => status.trim()).filter(Boolean);
|
||||
if (statuses.length > 0) {
|
||||
@@ -3840,6 +3878,8 @@ export function issueService(db: Db) {
|
||||
)
|
||||
`);
|
||||
}
|
||||
const lowTrustCondition = lowTrustBoundaryIssueCondition(companyId, filters?.lowTrustBoundary);
|
||||
if (lowTrustCondition) conditions.push(lowTrustCondition);
|
||||
if (filters?.status) {
|
||||
const statuses = filters.status.split(",").map((s) => s.trim());
|
||||
conditions.push(statuses.length === 1 ? eq(issues.status, statuses[0]) : inArray(issues.status, statuses));
|
||||
@@ -5805,6 +5845,7 @@ export function issueService(db: Db) {
|
||||
authorType?: IssueCommentAuthorType | null;
|
||||
presentation?: IssueCommentPresentation | null;
|
||||
metadata?: IssueCommentMetadata | null;
|
||||
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
|
||||
createdAt?: Date | string | null;
|
||||
},
|
||||
) => {
|
||||
@@ -5839,6 +5880,7 @@ export function issueService(db: Db) {
|
||||
body: redactedBody,
|
||||
presentation,
|
||||
metadata,
|
||||
sourceTrust: options?.sourceTrust ?? null,
|
||||
...(createdAt && !Number.isNaN(createdAt.getTime()) ? { createdAt } : {}),
|
||||
})
|
||||
.returning();
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
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 ||
|
||||
!(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",
|
||||
});
|
||||
}
|
||||
@@ -181,12 +181,14 @@ type SecretConsumerContext = {
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
pluginId?: string | null;
|
||||
allowedBindingIds?: string[] | null;
|
||||
};
|
||||
|
||||
export type RuntimeSecretManifestEntry = {
|
||||
configPath: string;
|
||||
envKey: string | null;
|
||||
secretId: string;
|
||||
bindingId?: string | null;
|
||||
secretKey: string;
|
||||
version: number;
|
||||
provider: SecretProvider;
|
||||
@@ -358,7 +360,7 @@ export function secretService(db: Db) {
|
||||
secretId: string,
|
||||
context: SecretConsumerContext | undefined,
|
||||
) {
|
||||
if (!context) return;
|
||||
if (!context) return null;
|
||||
if (!context.configPath) {
|
||||
throw unprocessable("Secret resolution requires a binding config path", { code: "binding_missing" });
|
||||
}
|
||||
@@ -375,6 +377,16 @@ export function secretService(db: Db) {
|
||||
{ code: "binding_missing" },
|
||||
);
|
||||
}
|
||||
if (
|
||||
Array.isArray(context.allowedBindingIds) &&
|
||||
!context.allowedBindingIds.includes(binding.id)
|
||||
) {
|
||||
throw unprocessable(
|
||||
"Secret binding is outside the active low-trust boundary",
|
||||
{ code: "binding_not_allowed" },
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
async function recordAccessEvent(input: {
|
||||
@@ -565,7 +577,7 @@ export function secretService(db: Db) {
|
||||
if (secret.status !== "active") {
|
||||
throw unprocessable("Secret is not active", { code: "secret_inactive" });
|
||||
}
|
||||
await assertBindingContext(companyId, secret.id, context);
|
||||
const binding = await assertBindingContext(companyId, secret.id, context);
|
||||
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
|
||||
if (!versionRow) throw new HttpError(404, "Secret version not found", { code: "version_missing" });
|
||||
if (versionRow.status === "disabled" || versionRow.status === "destroyed" || versionRow.revokedAt) {
|
||||
@@ -610,6 +622,7 @@ export function secretService(db: Db) {
|
||||
configPath: configPath ?? "",
|
||||
envKey: configPath?.startsWith("env.") ? configPath.slice("env.".length) : null,
|
||||
secretId: secret.id,
|
||||
bindingId: binding?.id ?? null,
|
||||
secretKey: secret.key,
|
||||
version: resolvedVersion,
|
||||
provider: providerId,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, heartbeatRuns, projects } from "@paperclipai/db";
|
||||
import {
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
type SourceTrustMetadata,
|
||||
} from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
import { resolveCoreTrustPreset } from "./trust-preset-resolver.js";
|
||||
|
||||
export const LOW_TRUST_QUARANTINED_BODY =
|
||||
"[Quarantined low-trust output omitted from higher-trust agent context. A trusted reviewer can inspect and promote a sanitized artifact.]";
|
||||
|
||||
export type SourceTrustActor = {
|
||||
actorType: "agent" | "user";
|
||||
actorId: string;
|
||||
agentId: string | null;
|
||||
runId: string | null;
|
||||
};
|
||||
|
||||
export type SourceTrustIssueContext = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
projectId?: string | null;
|
||||
executionPolicy?: unknown;
|
||||
};
|
||||
|
||||
export function isLowTrustQuarantined(sourceTrust: SourceTrustMetadata | null | undefined): boolean {
|
||||
return sourceTrust?.preset === LOW_TRUST_REVIEW_PRESET && sourceTrust.disposition === "quarantined";
|
||||
}
|
||||
|
||||
export function redactQuarantinedBodyForHigherTrust<T extends { body?: string | null; sourceTrust?: SourceTrustMetadata | null }>(
|
||||
value: T,
|
||||
): T {
|
||||
if (!isLowTrustQuarantined(value.sourceTrust)) return value;
|
||||
return {
|
||||
...value,
|
||||
body: LOW_TRUST_QUARANTINED_BODY,
|
||||
} as T;
|
||||
}
|
||||
|
||||
export function sanitizeQuarantinedCommentForHigherTrust<
|
||||
T extends {
|
||||
body: string;
|
||||
presentation?: unknown;
|
||||
metadata?: unknown;
|
||||
sourceTrust?: SourceTrustMetadata | null;
|
||||
},
|
||||
>(comment: T): T {
|
||||
if (!isLowTrustQuarantined(comment.sourceTrust)) return comment;
|
||||
return {
|
||||
...comment,
|
||||
body: LOW_TRUST_QUARANTINED_BODY,
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLowTrustSourceTrust(input: {
|
||||
issueId: string;
|
||||
runId?: string | null;
|
||||
agentId?: string | null;
|
||||
}): SourceTrustMetadata {
|
||||
return {
|
||||
preset: LOW_TRUST_REVIEW_PRESET,
|
||||
disposition: "quarantined",
|
||||
sourceIssueId: input.issueId,
|
||||
sourceRunId: input.runId ?? null,
|
||||
sourceAgentId: input.agentId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPromotedSourceTrust(input: {
|
||||
sourceIssueId: string;
|
||||
sourceArtifactKind: "comment" | "document" | "work_product" | "issue";
|
||||
sourceArtifactId: string;
|
||||
promotedByActorType: "agent" | "user" | "system";
|
||||
promotedByActorId: string;
|
||||
promotedAt?: Date;
|
||||
}): SourceTrustMetadata {
|
||||
return {
|
||||
preset: LOW_TRUST_REVIEW_PRESET,
|
||||
disposition: "promoted",
|
||||
sourceIssueId: input.sourceIssueId,
|
||||
promotedFrom: {
|
||||
artifactKind: input.sourceArtifactKind,
|
||||
artifactId: input.sourceArtifactId,
|
||||
issueId: input.sourceIssueId,
|
||||
},
|
||||
promotedByActorType: input.promotedByActorType,
|
||||
promotedByActorId: input.promotedByActorId,
|
||||
promotedAt: (input.promotedAt ?? new Date()).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveActorSourceTrustForIssue(input: {
|
||||
db: Db;
|
||||
issue: SourceTrustIssueContext;
|
||||
actor: SourceTrustActor;
|
||||
}): Promise<SourceTrustMetadata | null> {
|
||||
if (input.actor.actorType !== "agent" || !input.actor.agentId) return null;
|
||||
|
||||
const [agent, project, run] = await Promise.all([
|
||||
input.db
|
||||
.select({
|
||||
companyId: agents.companyId,
|
||||
permissions: agents.permissions,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.id, input.actor.agentId), eq(agents.companyId, input.issue.companyId)))
|
||||
.then((rows) => rows[0] ?? null),
|
||||
input.issue.projectId
|
||||
? input.db
|
||||
.select({
|
||||
companyId: projects.companyId,
|
||||
executionWorkspacePolicy: projects.executionWorkspacePolicy,
|
||||
})
|
||||
.from(projects)
|
||||
.where(and(eq(projects.id, input.issue.projectId), eq(projects.companyId, input.issue.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
input.actor.runId
|
||||
? input.db
|
||||
.select({
|
||||
companyId: heartbeatRuns.companyId,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, input.actor.runId), eq(heartbeatRuns.companyId, input.issue.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (input.actor.runId && (!run || run.agentId !== input.actor.agentId)) {
|
||||
// Fail closed: an unknown or mismatched run cannot prove higher trust, so tag the write as quarantined.
|
||||
return buildLowTrustSourceTrust({
|
||||
issueId: input.issue.id,
|
||||
runId: input.actor.runId,
|
||||
agentId: input.actor.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
const runContext = readObject(run?.contextSnapshot);
|
||||
const runExecutionPolicy = readObject(runContext?.executionPolicy);
|
||||
|
||||
const resolution = resolveCoreTrustPreset({
|
||||
companyId: input.issue.companyId,
|
||||
agent,
|
||||
project,
|
||||
issue: {
|
||||
companyId: input.issue.companyId,
|
||||
executionPolicy: input.issue.executionPolicy,
|
||||
},
|
||||
run: run
|
||||
? {
|
||||
companyId: run.companyId,
|
||||
executionPolicy: runExecutionPolicy,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
if (resolution.kind === "denied") {
|
||||
throw forbidden(resolution.detail);
|
||||
}
|
||||
if (resolution.kind !== "low_trust_review") return null;
|
||||
return buildLowTrustSourceTrust({
|
||||
issueId: input.issue.id,
|
||||
runId: input.actor.runId,
|
||||
agentId: input.actor.agentId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import {
|
||||
DEFAULT_TRUST_PRESET,
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
type LowTrustBoundary,
|
||||
type TrustPreset,
|
||||
lowTrustBoundarySchema,
|
||||
lowTrustReviewPresetPolicySchema,
|
||||
trustAuthorizationPolicySchema,
|
||||
trustPresetSchema,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export const LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH = 12;
|
||||
|
||||
export type TrustPresetPolicySource = "agent" | "project" | "issue" | "run";
|
||||
|
||||
export type ResolveCoreTrustPresetInput = {
|
||||
companyId: string;
|
||||
agent?: {
|
||||
companyId?: string | null;
|
||||
permissions?: unknown;
|
||||
} | null;
|
||||
project?: {
|
||||
companyId?: string | null;
|
||||
executionWorkspacePolicy?: unknown;
|
||||
} | null;
|
||||
issue?: {
|
||||
companyId?: string | null;
|
||||
executionPolicy?: unknown;
|
||||
} | null;
|
||||
run?: {
|
||||
companyId?: string | null;
|
||||
executionPolicy?: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TrustPresetDenyReason =
|
||||
| "unsupported_trust_preset"
|
||||
| "invalid_authorization_policy"
|
||||
| "invalid_low_trust_boundary"
|
||||
| "cross_company_boundary"
|
||||
| "conflicting_low_trust_boundary"
|
||||
| "missing_low_trust_boundary_scope";
|
||||
|
||||
export type TrustPresetResolution =
|
||||
| {
|
||||
kind: "standard";
|
||||
preset: typeof DEFAULT_TRUST_PRESET;
|
||||
boundary: null;
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>;
|
||||
}
|
||||
| {
|
||||
kind: "low_trust_review";
|
||||
preset: typeof LOW_TRUST_REVIEW_PRESET;
|
||||
boundary: LowTrustBoundary & { companyId: string };
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>;
|
||||
}
|
||||
| {
|
||||
kind: "denied";
|
||||
reason: TrustPresetDenyReason;
|
||||
source: TrustPresetPolicySource | null;
|
||||
detail: string;
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>;
|
||||
};
|
||||
|
||||
type ParsedPolicySource = {
|
||||
source: TrustPresetPolicySource;
|
||||
companyId: string | null;
|
||||
rawPolicy: JsonRecord | null;
|
||||
authorizationPolicy: JsonRecord | null;
|
||||
trustPreset: TrustPreset | null;
|
||||
boundary: LowTrustBoundary | null;
|
||||
impliesLowTrust: boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): JsonRecord | null {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? value as JsonRecord
|
||||
: null;
|
||||
}
|
||||
|
||||
function deny(
|
||||
reason: TrustPresetDenyReason,
|
||||
source: TrustPresetPolicySource | null,
|
||||
detail: string,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): TrustPresetResolution {
|
||||
return { kind: "denied", reason, source, detail, sourcePresets };
|
||||
}
|
||||
|
||||
function isTrustPresetResolution(value: unknown): value is TrustPresetResolution {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const kind = (value as { kind?: unknown }).kind;
|
||||
return kind === "standard" || kind === "low_trust_review" || kind === "denied";
|
||||
}
|
||||
|
||||
function parsePreset(
|
||||
value: unknown,
|
||||
source: TrustPresetPolicySource,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): TrustPresetResolution | TrustPreset | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const parsed = trustPresetSchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return deny(
|
||||
"unsupported_trust_preset",
|
||||
source,
|
||||
`Unsupported trust preset in ${source} policy.`,
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseReviewPresetId(
|
||||
value: unknown,
|
||||
source: TrustPresetPolicySource,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): TrustPresetResolution | TrustPreset | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const parsed = lowTrustReviewPresetPolicySchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return deny(
|
||||
"unsupported_trust_preset",
|
||||
source,
|
||||
`Unsupported review preset in ${source} policy.`,
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
return parsed.data.id;
|
||||
}
|
||||
|
||||
function parseAuthorizationPolicy(
|
||||
value: unknown,
|
||||
source: TrustPresetPolicySource,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): TrustPresetResolution | JsonRecord | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const parsed = trustAuthorizationPolicySchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return deny(
|
||||
"invalid_authorization_policy",
|
||||
source,
|
||||
`Invalid authorization policy in ${source} policy.`,
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
return parsed.data as JsonRecord;
|
||||
}
|
||||
|
||||
function parseBoundary(
|
||||
value: unknown,
|
||||
source: TrustPresetPolicySource,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): TrustPresetResolution | LowTrustBoundary | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
const parsed = lowTrustBoundarySchema.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
return deny(
|
||||
"invalid_low_trust_boundary",
|
||||
source,
|
||||
`Invalid low-trust boundary in ${source} policy.`,
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseSource(
|
||||
source: TrustPresetPolicySource,
|
||||
companyId: string | null | undefined,
|
||||
rawPolicy: JsonRecord | null,
|
||||
authorizationPolicyInput: unknown,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): ParsedPolicySource | TrustPresetResolution {
|
||||
const topPreset = parsePreset(rawPolicy?.trustPreset, source, sourcePresets);
|
||||
if (isTrustPresetResolution(topPreset)) return topPreset;
|
||||
|
||||
const topReviewPreset = parseReviewPresetId(rawPolicy?.reviewPreset, source, sourcePresets);
|
||||
if (isTrustPresetResolution(topReviewPreset)) return topReviewPreset;
|
||||
|
||||
const authorizationPolicy = parseAuthorizationPolicy(authorizationPolicyInput, source, sourcePresets);
|
||||
if (isTrustPresetResolution(authorizationPolicy)) return authorizationPolicy;
|
||||
|
||||
const authPreset = parsePreset(authorizationPolicy?.trustPreset, source, sourcePresets);
|
||||
if (isTrustPresetResolution(authPreset)) return authPreset;
|
||||
|
||||
const authReviewPreset = parseReviewPresetId(authorizationPolicy?.reviewPreset, source, sourcePresets);
|
||||
if (isTrustPresetResolution(authReviewPreset)) return authReviewPreset;
|
||||
|
||||
const boundary = parseBoundary(authorizationPolicy?.trustBoundary, source, sourcePresets);
|
||||
if (isTrustPresetResolution(boundary)) return boundary;
|
||||
|
||||
const trustPreset = topPreset ?? topReviewPreset ?? authPreset ?? authReviewPreset;
|
||||
if (trustPreset) sourcePresets[source] = trustPreset;
|
||||
|
||||
return {
|
||||
source,
|
||||
companyId: companyId ?? null,
|
||||
rawPolicy,
|
||||
authorizationPolicy,
|
||||
trustPreset,
|
||||
boundary,
|
||||
impliesLowTrust: trustPreset === LOW_TRUST_REVIEW_PRESET || Boolean(boundary),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSet(values: readonly string[] | undefined): string[] | undefined {
|
||||
if (values === undefined) return undefined;
|
||||
return [...new Set(values)].sort();
|
||||
}
|
||||
|
||||
function intersectSets(left: string[] | undefined, right: string[] | undefined): string[] | undefined {
|
||||
const normalizedRight = normalizeSet(right);
|
||||
if (normalizedRight === undefined) return left;
|
||||
const normalizedLeft = normalizeSet(left);
|
||||
if (normalizedLeft === undefined) return normalizedRight;
|
||||
const rightSet = new Set(normalizedRight);
|
||||
return normalizedLeft.filter((value) => rightSet.has(value));
|
||||
}
|
||||
|
||||
function mergeBoundary(
|
||||
current: (LowTrustBoundary & { companyId: string }) | null,
|
||||
next: LowTrustBoundary,
|
||||
companyId: string,
|
||||
source: TrustPresetPolicySource,
|
||||
sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>>,
|
||||
): (LowTrustBoundary & { companyId: string }) | TrustPresetResolution {
|
||||
if (next.companyId && next.companyId !== companyId) {
|
||||
return deny(
|
||||
"cross_company_boundary",
|
||||
source,
|
||||
"Low-trust boundary refers to a different company.",
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
|
||||
const base = current ?? { mode: LOW_TRUST_REVIEW_PRESET, companyId };
|
||||
if (base.rootIssueId && next.rootIssueId && base.rootIssueId !== next.rootIssueId) {
|
||||
return deny(
|
||||
"conflicting_low_trust_boundary",
|
||||
source,
|
||||
"Low-trust boundary root issue scopes do not overlap.",
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
projectIds: intersectSets(base.projectIds, next.projectIds),
|
||||
rootIssueId: base.rootIssueId ?? next.rootIssueId,
|
||||
issueIds: intersectSets(base.issueIds, next.issueIds),
|
||||
allowedAgentIds: intersectSets(base.allowedAgentIds, next.allowedAgentIds),
|
||||
allowedSecretBindingIds: intersectSets(base.allowedSecretBindingIds, next.allowedSecretBindingIds),
|
||||
allowedToolClasses: intersectSets(base.allowedToolClasses, next.allowedToolClasses),
|
||||
outputPromotionTarget: next.outputPromotionTarget ?? base.outputPromotionTarget,
|
||||
};
|
||||
}
|
||||
|
||||
function hasBoundaryScope(boundary: LowTrustBoundary): boolean {
|
||||
return Boolean(boundary.rootIssueId)
|
||||
|| Boolean(boundary.projectIds?.length)
|
||||
|| Boolean(boundary.issueIds?.length);
|
||||
}
|
||||
|
||||
export function resolveCoreTrustPreset(input: ResolveCoreTrustPresetInput): TrustPresetResolution {
|
||||
const sourcePresets: Partial<Record<TrustPresetPolicySource, TrustPreset>> = {};
|
||||
const sources: ParsedPolicySource[] = [];
|
||||
|
||||
const agentPermissions = asRecord(input.agent?.permissions);
|
||||
const agent = parseSource("agent", input.agent?.companyId, agentPermissions, agentPermissions?.authorizationPolicy, sourcePresets);
|
||||
if ("kind" in agent) return agent;
|
||||
sources.push(agent);
|
||||
|
||||
const projectPolicy = asRecord(input.project?.executionWorkspacePolicy);
|
||||
const project = parseSource("project", input.project?.companyId, projectPolicy, projectPolicy?.authorizationPolicy, sourcePresets);
|
||||
if ("kind" in project) return project;
|
||||
sources.push(project);
|
||||
|
||||
const issuePolicy = asRecord(input.issue?.executionPolicy);
|
||||
const issue = parseSource("issue", input.issue?.companyId, issuePolicy, issuePolicy?.authorizationPolicy, sourcePresets);
|
||||
if ("kind" in issue) return issue;
|
||||
sources.push(issue);
|
||||
|
||||
const runPolicy = asRecord(input.run?.executionPolicy);
|
||||
const run = parseSource("run", input.run?.companyId, runPolicy, runPolicy?.authorizationPolicy, sourcePresets);
|
||||
if ("kind" in run) return run;
|
||||
sources.push(run);
|
||||
|
||||
for (const source of sources) {
|
||||
if (source.companyId && source.companyId !== input.companyId) {
|
||||
return deny(
|
||||
"cross_company_boundary",
|
||||
source.source,
|
||||
"Policy source belongs to a different company.",
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePreset = sources.some((source) => source.impliesLowTrust)
|
||||
? LOW_TRUST_REVIEW_PRESET
|
||||
: DEFAULT_TRUST_PRESET;
|
||||
|
||||
if (effectivePreset === DEFAULT_TRUST_PRESET) {
|
||||
return {
|
||||
kind: "standard",
|
||||
preset: DEFAULT_TRUST_PRESET,
|
||||
boundary: null,
|
||||
sourcePresets,
|
||||
};
|
||||
}
|
||||
|
||||
let boundary: (LowTrustBoundary & { companyId: string }) | null = null;
|
||||
for (const source of sources) {
|
||||
if (!source.boundary) continue;
|
||||
const merged = mergeBoundary(boundary, source.boundary, input.companyId, source.source, sourcePresets);
|
||||
if (isTrustPresetResolution(merged)) return merged;
|
||||
boundary = merged;
|
||||
}
|
||||
|
||||
if (!boundary || !hasBoundaryScope(boundary)) {
|
||||
return deny(
|
||||
"missing_low_trust_boundary_scope",
|
||||
null,
|
||||
"Low-trust review requires a concrete project, root issue, or issue-id boundary.",
|
||||
sourcePresets,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "low_trust_review",
|
||||
preset: LOW_TRUST_REVIEW_PRESET,
|
||||
boundary,
|
||||
sourcePresets,
|
||||
};
|
||||
}
|
||||
|
||||
export function isIssueWithinLowTrustBoundary(
|
||||
boundary: LowTrustBoundary & { companyId: string },
|
||||
issue: { companyId: string; id?: string | null; projectId?: string | null },
|
||||
): boolean {
|
||||
if (issue.companyId !== boundary.companyId) return false;
|
||||
if (issue.id && issue.id === boundary.rootIssueId) return true;
|
||||
if (issue.id && boundary.issueIds?.includes(issue.id)) return true;
|
||||
if (issue.projectId && boundary.projectIds?.includes(issue.projectId)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ function toIssueWorkProduct(row: IssueWorkProductRow): IssueWorkProduct {
|
||||
healthStatus: row.healthStatus as IssueWorkProduct["healthStatus"],
|
||||
summary: row.summary ?? null,
|
||||
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
|
||||
sourceTrust: row.sourceTrust ?? null,
|
||||
createdByRunId: row.createdByRunId ?? null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
|
||||
Reference in new issue
Block a user