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:
DottaandPaperclip authored and GitHub committed 2026-06-05 16:48:02 -05:00
1 parent b2a33d0184
commit dbebf30c89
87 files changed
+6522 -121

No files matched your search

+1 -1
View File
@@ -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;
+351 -5
View File
@@ -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({
+11
View File
@@ -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>) } }
: {}),
};
}
+227 -25
View File
@@ -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 } : {}),
};
}
+42
View File
@@ -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",
});
}
+15 -2
View File
@@ -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,
+173
View File
@@ -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;
}
+1
View File
@@ -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,