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

+45 -1
View File
@@ -5,7 +5,7 @@ import { normalizeIssueIdentifier } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { activityService, normalizeActivityLimit } from "../services/activity.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess } from "./authz.js";
import { heartbeatService, issueService } from "../services/index.js";
import { accessService, heartbeatService, issueService } from "../services/index.js";
import { sanitizeRecord } from "../redaction.js";
const createActivitySchema = z.object({
@@ -21,9 +21,49 @@ const createActivitySchema = z.object({
export function activityRoutes(db: Db) {
const router = Router();
const svc = activityService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db);
const issueSvc = issueService(db);
async function assertCompanyScopeReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Activity is outside this actor's authorization boundary" });
return false;
}
async function assertIssueReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
}) {
const decision = await access.decide({
actor: req.actor,
action: "issue:read",
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
});
if (decision.allowed) return true;
res.status(403).json({ error: "Issue activity is outside this actor's authorization boundary" });
return false;
}
async function resolveIssueByRef(rawId: string) {
const identifier = normalizeIssueIdentifier(rawId);
if (identifier) {
@@ -35,6 +75,7 @@ export function activityRoutes(db: Db) {
router.get("/companies/:companyId/activity", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, companyId))) return;
const filters = {
companyId,
@@ -67,6 +108,7 @@ export function activityRoutes(db: Db) {
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.forIssue(issue.id);
res.json(result);
});
@@ -79,6 +121,7 @@ export function activityRoutes(db: Db) {
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const result = await svc.runsForIssue(issue.companyId, issue.id);
res.json(result);
});
@@ -92,6 +135,7 @@ export function activityRoutes(db: Db) {
return;
}
assertCompanyAccess(req, run.companyId);
if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return;
const result = await svc.issuesForRun(runId);
res.json(result);
});
+130 -5
View File
@@ -2,7 +2,7 @@ import { Router, type Request, type Response } from "express";
import { generateKeyPairSync, randomUUID } from "node:crypto";
import path from "node:path";
import type { Db } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable } from "@paperclipai/db";
import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable, projects as projectsTable } from "@paperclipai/db";
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
@@ -25,6 +25,7 @@ import {
wakeAgentSchema,
updateAgentSchema,
supportedEnvironmentDriversForAdapter,
LOW_TRUST_REVIEW_PRESET,
} from "@paperclipai/shared";
import {
readPaperclipSkillSyncPreference,
@@ -99,6 +100,8 @@ import {
import { getTelemetryClient } from "../telemetry.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { recoveryService } from "../services/recovery/service.js";
import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
import { readObject } from "../lib/objects.js";
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
@@ -116,6 +119,14 @@ function readLiveRunsQueryInt(value: unknown, max: number, fallback = 0) {
return Math.min(max, Math.trunc(parsed));
}
function readRunIssueId(context: Record<string, unknown> | null) {
const directIssueId = context?.issueId;
if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId;
const paperclipIssue = readObject(context?.paperclipIssue);
const nestedIssueId = paperclipIssue?.id;
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
}
export function agentRoutes(
db: Db,
options: { pluginWorkerManager?: PluginWorkerManager } = {},
@@ -191,6 +202,35 @@ export function agentRoutes(
});
}
async function decideAgentRead(req: Request, agent: { id: string; companyId: string }) {
return access.decide({
actor: req.actor,
action: "agent:read",
resource: { type: "agent", companyId: agent.companyId, agentId: agent.id },
});
}
async function assertAgentReadAllowed(req: Request, res: Response, agent: { id: string; companyId: string }) {
const decision = await decideAgentRead(req, agent);
if (decision.allowed) return true;
res.status(403).json({ error: "Agent is outside this actor's authorization boundary" });
return false;
}
async function filterAgentsForActor<T extends Record<string, unknown>>(
req: Request,
rows: T[],
fallbackCompanyId?: string,
) {
const decisions = await Promise.all(rows.map((agent) => {
const id = typeof agent.id === "string" ? agent.id : null;
const companyId = typeof agent.companyId === "string" ? agent.companyId : fallbackCompanyId ?? null;
if (!id || !companyId) return Promise.resolve({ allowed: false });
return decideAgentRead(req, { id, companyId });
}));
return rows.filter((_, index) => decisions[index]?.allowed);
}
/**
* Resolve the execution target the adapter should run its test probes against.
*
@@ -534,6 +574,69 @@ export function agentRoutes(
};
}
async function resolveAgentSelfTrustPreset(req: Request, agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
if (req.actor.type !== "agent" || req.actor.agentId !== agent.id) {
return { kind: "standard" as const };
}
const run = req.actor.type === "agent" && req.actor.runId
? await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, req.actor.runId), eq(heartbeatRuns.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
const runContext = run?.agentId === agent.id ? readObject(run.contextSnapshot) : null;
const runExecutionPolicy = readObject(runContext?.executionPolicy);
const runIssueId = readRunIssueId(runContext);
const runScopedIssue = runIssueId
? await db
.select({
companyId: issuesTable.companyId,
projectId: issuesTable.projectId,
executionPolicy: issuesTable.executionPolicy,
projectExecutionWorkspacePolicy: projectsTable.executionWorkspacePolicy,
})
.from(issuesTable)
.leftJoin(projectsTable, and(eq(projectsTable.id, issuesTable.projectId), eq(projectsTable.companyId, issuesTable.companyId)))
.where(and(eq(issuesTable.id, runIssueId), eq(issuesTable.companyId, agent.companyId)))
.then((rows) => rows[0] ?? null)
: null;
return resolveCoreTrustPreset({
companyId: agent.companyId,
agent,
project: runScopedIssue?.projectId
? {
companyId: runScopedIssue.companyId,
executionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy,
}
: null,
issue: runScopedIssue
? {
companyId: runScopedIssue.companyId,
executionPolicy: runScopedIssue.executionPolicy,
}
: null,
run: runExecutionPolicy ? { companyId: agent.companyId, executionPolicy: runExecutionPolicy } : null,
});
}
function buildLowTrustSelfView(agent: NonNullable<Awaited<ReturnType<typeof svc.getById>>>) {
return {
id: agent.id,
companyId: agent.companyId,
name: agent.name,
role: agent.role,
title: agent.title,
status: agent.status,
trustPreset: LOW_TRUST_REVIEW_PRESET,
};
}
async function applyDefaultAgentTaskAssignGrant(
companyId: string,
agentId: string,
@@ -1606,7 +1709,7 @@ export function agentRoutes(
});
return;
}
const result = await svc.list(companyId);
const result = await filterAgentsForActor(req, await svc.list(companyId));
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
if (canReadConfigs) {
res.json(result);
@@ -1681,7 +1784,7 @@ export function agentRoutes(
router.get("/companies/:companyId/org", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const tree = await svc.orgForCompany(companyId);
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
res.json(leanTree);
});
@@ -1690,7 +1793,7 @@ export function agentRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
const tree = await svc.orgForCompany(companyId);
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
const svg = renderOrgChartSvg(leanTree as unknown as OrgNode[], style);
res.setHeader("Content-Type", "image/svg+xml");
@@ -1702,7 +1805,7 @@ export function agentRoutes(
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const style = (ORG_CHART_STYLES.includes(req.query.style as OrgChartStyle) ? req.query.style : "warmth") as OrgChartStyle;
const tree = await svc.orgForCompany(companyId);
const tree = await filterAgentsForActor(req, await svc.orgForCompany(companyId), companyId);
const leanTree = tree.map((node) => toLeanOrgNode(node as Record<string, unknown>));
const png = await renderOrgChartPng(leanTree as unknown as OrgNode[], style);
res.setHeader("Content-Type", "image/png");
@@ -1727,6 +1830,15 @@ export function agentRoutes(
res.status(404).json({ error: "Agent not found" });
return;
}
const trustPreset = await resolveAgentSelfTrustPreset(req, agent);
if (trustPreset.kind === "denied") {
res.status(403).json({ error: trustPreset.detail });
return;
}
if (trustPreset.kind === "low_trust_review") {
res.json(buildLowTrustSelfView(agent));
return;
}
res.json(await buildAgentDetail(agent));
});
@@ -1796,7 +1908,19 @@ export function agentRoutes(
return;
}
assertCompanyAccess(req, agent.companyId);
if (!(await assertAgentReadAllowed(req, res, agent))) return;
const isSelf = req.actor.type === "agent" && req.actor.agentId === id;
if (isSelf) {
const trustPreset = await resolveAgentSelfTrustPreset(req, agent);
if (trustPreset.kind === "denied") {
res.status(403).json({ error: trustPreset.detail });
return;
}
if (trustPreset.kind === "low_trust_review") {
res.json(buildLowTrustSelfView(agent));
return;
}
}
const canReadSensitiveDetail = isSelf
? true
: await actorCanReadConfigurationsForCompany(req, agent.companyId);
@@ -2294,6 +2418,7 @@ export function agentRoutes(
details: {
canCreateAgents: agent.permissions?.canCreateAgents ?? false,
canAssignTasks: effectiveCanAssignTasks,
trustPreset: agent.permissions?.trustPreset ?? "standard",
},
});
+17
View File
@@ -11,6 +11,7 @@ import { validate } from "../middleware/validate.js";
import { logger } from "../middleware/logger.js";
import {
approvalService,
accessService,
heartbeatService,
issueApprovalService,
logActivity,
@@ -33,6 +34,7 @@ export function approvalRoutes(
) {
const router = Router();
const svc = approvalService(db);
const access = accessService(db);
const heartbeat = heartbeatService(db, {
pluginWorkerManager: options.pluginWorkerManager,
});
@@ -49,9 +51,21 @@ export function approvalRoutes(
return approval;
}
async function assertApprovalAccessAllowed(req: Request, res: any, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Approvals are outside this actor's authorization boundary" });
return false;
}
router.get("/companies/:companyId/approvals", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertApprovalAccessAllowed(req, res, companyId))) return;
const status = req.query.status as string | undefined;
const result = await svc.list(companyId, status);
res.json(result.map((approval) => redactApprovalPayload(approval)));
@@ -65,12 +79,14 @@ export function approvalRoutes(
return;
}
assertCompanyAccess(req, approval.companyId);
if (!(await assertApprovalAccessAllowed(req, res, approval.companyId))) return;
res.json(redactApprovalPayload(approval));
});
router.post("/companies/:companyId/approvals", validate(createApprovalSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertApprovalAccessAllowed(req, res, companyId))) return;
const rawIssueIds = req.body.issueIds;
const issueIds = Array.isArray(rawIssueIds)
? rawIssueIds.filter((value: unknown): value is string => typeof value === "string")
@@ -129,6 +145,7 @@ export function approvalRoutes(
return;
}
assertCompanyAccess(req, approval.companyId);
if (!(await assertApprovalAccessAllowed(req, res, approval.companyId))) return;
const issues = await issueApprovalsSvc.listIssuesForApproval(id);
res.json(issues);
});
+54
View File
@@ -17,6 +17,7 @@ import {
agentService,
issueService,
heartbeatService,
accessService,
logActivity,
} from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
@@ -61,6 +62,7 @@ export function costRoutes(
const companies = companyService(db);
const agents = agentService(db);
const issues = issueService(db);
const access = accessService(db);
async function resolveIssueByRef(rawId: string) {
const identifier = normalizeIssueIdentifier(rawId);
@@ -70,6 +72,45 @@ export function costRoutes(
return issues.getById(rawId);
}
async function assertCompanyCostReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Costs are outside this actor's authorization boundary" });
return false;
}
async function assertIssueCostReadAllowed(req: Parameters<typeof assertCompanyAccess>[0], res: any, issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
}) {
const decision = await access.decide({
actor: req.actor,
action: "issue:read",
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
});
if (decision.allowed) return true;
res.status(403).json({ error: "Issue costs are outside this actor's authorization boundary" });
return false;
}
router.post("/companies/:companyId/cost-events", validate(createCostEventSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@@ -132,6 +173,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const summary = await costs.summary(companyId, range);
res.json(summary);
@@ -145,6 +187,7 @@ export function costRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueCostReadAllowed(req, res, issue))) return;
const excludeRoot = req.query.excludeRoot === "true" || req.query.excludeRoot === "1";
const summary = await costs.issueTreeSummary(issue.companyId, issue.id, { excludeRoot });
res.json(summary);
@@ -153,6 +196,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/by-agent", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await costs.byAgent(companyId, range);
res.json(rows);
@@ -161,6 +205,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/by-agent-model", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await costs.byAgentModel(companyId, range);
res.json(rows);
@@ -169,6 +214,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/by-provider", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await costs.byProvider(companyId, range);
res.json(rows);
@@ -177,6 +223,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await costs.byBiller(companyId, range);
res.json(rows);
@@ -185,6 +232,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/finance-summary", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const summary = await finance.summary(companyId, range);
res.json(summary);
@@ -193,6 +241,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/finance-by-biller", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await finance.byBiller(companyId, range);
res.json(rows);
@@ -201,6 +250,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/finance-by-kind", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await finance.byKind(companyId, range);
res.json(rows);
@@ -209,6 +259,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/finance-events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const limit = parseCostLimit(req.query);
const rows = await finance.list(companyId, range, limit);
@@ -218,6 +269,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/window-spend", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const rows = await costs.windowSpend(companyId);
res.json(rows);
});
@@ -240,6 +292,7 @@ export function costRoutes(
router.get("/companies/:companyId/budgets/overview", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const overview = await budgets.overview(companyId);
res.json(overview);
});
@@ -272,6 +325,7 @@ export function costRoutes(
router.get("/companies/:companyId/costs/by-project", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertCompanyCostReadAllowed(req, res, companyId))) return;
const range = parseCostDateRange(req.query);
const rows = await costs.byProject(companyId, range);
res.json(rows);
+30 -1
View File
@@ -10,7 +10,7 @@ import {
} from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { accessService, executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
@@ -36,11 +36,35 @@ const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
const access = accessService(db);
const workspaceOperationsSvc = workspaceOperationService(db);
async function assertExecutionWorkspaceReadAllowed(req: Request, res: Response, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Execution workspaces are outside this actor's authorization boundary" });
return false;
}
async function assertRuntimeManageAllowed(req: Request, res: Response, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "runtime:manage",
resource: { type: "company", companyId },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Runtime service control is outside this actor's authorization boundary" });
return false;
}
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (!(await assertExecutionWorkspaceReadAllowed(req, res, companyId))) return;
const filters = {
projectId: req.query.projectId as string | undefined,
projectWorkspaceId: req.query.projectWorkspaceId as string | undefined,
@@ -62,6 +86,7 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, workspace.companyId);
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
res.json(workspace);
});
@@ -73,6 +98,7 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, workspace.companyId);
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
const readiness = await svc.getCloseReadiness(id);
if (!readiness) {
res.status(404).json({ error: "Execution workspace not found" });
@@ -89,6 +115,7 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, workspace.companyId);
if (!(await assertExecutionWorkspaceReadAllowed(req, res, workspace.companyId))) return;
const operations = await workspaceOperationsSvc.listForExecutionWorkspace(id);
res.json(operations);
});
@@ -107,6 +134,7 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return;
await assertCanManageExecutionWorkspaceRuntimeServices(db, req, {
companyId: existing.companyId,
@@ -447,6 +475,7 @@ export function executionWorkspaceRoutes(db: Db) {
return;
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertRuntimeManageAllowed(req, res, existing.companyId))) return;
assertNoAgentHostWorkspaceCommandMutation(
req,
collectExecutionWorkspaceCommandPaths({
+592 -29
View File
@@ -6,11 +6,15 @@ import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
documents,
executionWorkspaces,
heartbeatRuns,
issueComments,
issueDocuments,
issueExecutionDecisions,
issueRelations,
issues as issueRows,
issueWorkProducts,
projectWorkspaces,
} from "@paperclipai/db";
import {
@@ -52,6 +56,7 @@ import {
type CompanySearchResponse,
type ExecutionWorkspace,
type IssueRelationIssueSummary,
type SourceTrustMetadata,
type SuccessfulRunHandoffState,
} from "@paperclipai/shared";
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
@@ -118,12 +123,31 @@ import {
} from "../services/issue-execution-policy.js";
import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js";
import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
import {
buildPromotedSourceTrust,
isLowTrustQuarantined,
redactQuarantinedBodyForHigherTrust,
resolveActorSourceTrustForIssue,
sanitizeQuarantinedCommentForHigherTrust,
} from "../services/source-trust.js";
import {
LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH,
resolveCoreTrustPreset,
type TrustPresetResolution,
} from "../services/trust-preset-resolver.js";
const MAX_ISSUE_COMMENT_LIMIT = 500;
const updateIssueRouteSchema = updateIssueSchema.extend({
interrupt: z.boolean().optional(),
});
const promoteLowTrustOutputSchema = z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
sourceArtifactId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(8_000),
});
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type IssueRouteSnapshot = typeof issueRows.$inferSelect;
@@ -607,9 +631,23 @@ function applyActorMonitorScheduledBy(
return setIssueExecutionPolicyMonitorScheduledBy(policy, actorType === "user" ? "board" : "assignee");
}
function assertCanManageIssueMonitor(req: Request, assigneeAgentId: string | null, monitorChanged: boolean) {
async function assertCanManageIssueMonitor(
accessSvc: ReturnType<typeof accessService>,
req: Request,
companyId: string,
assigneeAgentId: string | null,
monitorChanged: boolean,
) {
if (!monitorChanged) return;
if (req.actor.type === "board") return;
const runtimeDecision = await accessSvc.decide({
actor: req.actor,
action: "runtime:manage",
resource: { type: "company", companyId },
});
if (!runtimeDecision.allowed) {
throw forbidden(runtimeDecision.explanation);
}
if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return;
throw forbidden("Only the assignee agent or a board user can manage issue monitors");
}
@@ -938,6 +976,175 @@ export function issueRoutes(
const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db);
async function sourceTrustForActorWrite(
issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown },
actor: ReturnType<typeof getActorInfo>,
) {
return resolveActorSourceTrustForIssue({ db, issue, actor });
}
async function resolveAgentTrustForIssue(
input: {
agentId: string | null | undefined;
runId?: string | null;
},
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
): Promise<TrustPresetResolution | null> {
if (!input.agentId) return null;
const [agent, run] = await Promise.all([
agentsSvc.getById(input.agentId),
input.runId
? db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(eq(heartbeatRuns.id, input.runId), eq(heartbeatRuns.companyId, companyId)))
.then((rows) => rows[0] ?? null)
: Promise.resolve(null),
]);
if (!agent || agent.companyId !== companyId) return null;
const runContext = run?.agentId === agent.id && run.contextSnapshot && typeof run.contextSnapshot === "object"
? run.contextSnapshot as Record<string, unknown>
: null;
const runExecutionPolicy = runContext?.executionPolicy && typeof runContext.executionPolicy === "object"
? runContext.executionPolicy as Record<string, unknown>
: null;
const project = issue?.projectId
? await projectsSvc.getById(issue.projectId)
: null;
return resolveCoreTrustPreset({
companyId,
agent,
project: project?.companyId === companyId ? project : null,
issue: issue
? {
companyId: issue.companyId,
executionPolicy: issue.executionPolicy,
}
: null,
run: runExecutionPolicy ? { companyId, executionPolicy: runExecutionPolicy } : null,
});
}
async function actorIsLowTrustReview(
req: Request,
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
) {
if (req.actor.type !== "agent") return false;
const resolution = await resolveAgentTrustForIssue({
agentId: req.actor.agentId,
runId: req.actor.runId,
}, companyId, issue);
if (resolution?.kind === "denied") {
throw forbidden(resolution.detail);
}
return resolution?.kind === "low_trust_review";
}
async function assertLowTrustControlPlaneDenied(
req: Request,
res: Response,
companyId: string,
issue?: { companyId: string; projectId?: string | null; executionPolicy?: unknown } | null,
) {
if (!(await actorIsLowTrustReview(req, companyId, issue))) return false;
res.status(403).json({ error: "Low-trust actors cannot use this control-plane surface" });
return true;
}
async function shouldRedactLowTrustForHeartbeatContext(
issue: { id: string; companyId: string; projectId?: string | null; executionPolicy?: unknown },
actor: ReturnType<typeof getActorInfo>,
) {
// Board users are trusted reviewers and intentionally receive raw quarantined output for promotion decisions.
if (actor.actorType !== "agent") return false;
const resolution = await resolveAgentTrustForIssue({
agentId: actor.agentId,
runId: actor.runId,
}, issue.companyId, issue);
if (resolution?.kind === "denied") {
throw forbidden(resolution.detail);
}
if (resolution?.kind === "low_trust_review") return false;
return true;
}
async function lookupLowTrustSourceArtifact(input: {
issueId: string;
artifactKind: "comment" | "document" | "work_product" | "issue";
artifactId: string;
}): Promise<SourceTrustMetadata | null> {
if (input.artifactKind === "issue") {
const row = await db
.select({
id: issueRows.id,
companyId: issueRows.companyId,
parentId: issueRows.parentId,
sourceTrust: issueRows.sourceTrust,
})
.from(issueRows)
.where(eq(issueRows.id, input.artifactId))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const sourceIssue = await db
.select({ companyId: issueRows.companyId })
.from(issueRows)
.where(eq(issueRows.id, input.issueId))
.then((rows) => rows[0] ?? null);
if (!sourceIssue || row.companyId !== sourceIssue.companyId) return null;
if (row.id !== input.issueId) {
let cursor = row.parentId;
let isDescendant = false;
for (let depth = 0; cursor && depth < LOW_TRUST_ISSUE_ANCESTRY_MAX_DEPTH; depth += 1) {
if (cursor === input.issueId) {
isDescendant = true;
break;
}
const parent = await db
.select({ id: issueRows.id, companyId: issueRows.companyId, parentId: issueRows.parentId })
.from(issueRows)
.where(eq(issueRows.id, cursor))
.then((rows) => rows[0] ?? null);
if (!parent || parent.companyId !== row.companyId) return null;
cursor = parent.parentId;
}
if (!isDescendant) return null;
}
return row?.sourceTrust ?? null;
}
if (input.artifactKind === "comment") {
const row = await db
.select({ sourceTrust: issueComments.sourceTrust })
.from(issueComments)
.where(and(eq(issueComments.id, input.artifactId), eq(issueComments.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
if (input.artifactKind === "document") {
const row = await db
.select({ sourceTrust: documents.sourceTrust })
.from(issueDocuments)
.innerJoin(documents, eq(issueDocuments.documentId, documents.id))
.where(and(eq(documents.id, input.artifactId), eq(issueDocuments.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
const row = await db
.select({ sourceTrust: issueWorkProducts.sourceTrust })
.from(issueWorkProducts)
.where(and(eq(issueWorkProducts.id, input.artifactId), eq(issueWorkProducts.issueId, input.issueId)))
.then((rows) => rows[0] ?? null);
return row?.sourceTrust ?? null;
}
async function cancelScheduledRetrySupersededByComment(input: {
scheduledRetryRunId: string | null | undefined;
issue: { id: string; companyId: string };
@@ -1479,6 +1686,63 @@ export function issueRoutes(
throw forbidden(decision.explanation);
}
async function decideIssueAccess(
req: Request,
issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
assigneeAgentId: string | null;
assigneeUserId: string | null;
status: string;
},
action: "issue:read" | "issue:mutate",
) {
return access.decide({
actor: req.actor,
action,
resource: {
type: "issue",
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
status: issue.status,
},
scope: {
issueId: issue.id,
projectId: issue.projectId,
parentIssueId: issue.parentId,
assigneeAgentId: issue.assigneeAgentId,
assigneeUserId: issue.assigneeUserId,
},
});
}
async function assertIssueReadAllowed(req: Request, res: Response, issue: Parameters<typeof decideIssueAccess>[1]) {
const decision = await decideIssueAccess(req, issue, "issue:read");
if (decision.allowed) return true;
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
return false;
}
async function filterIssuesForActor<T extends Parameters<typeof decideIssueAccess>[1]>(req: Request, rows: T[]) {
const decisions = await Promise.all(rows.map((issue) => decideIssueAccess(req, issue, "issue:read")));
return rows.filter((_, index) => decisions[index]?.allowed);
}
async function actorCanReadCompanyScope(req: Request, companyId: string) {
const decision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
return decision.allowed;
}
function requireAgentRunId(req: Request, res: Response) {
if (req.actor.type !== "agent") return null;
const runId = req.actor.runId?.trim();
@@ -1503,7 +1767,15 @@ export function issueRoutes(
async function assertAgentIssueMutationAllowed(
req: Request,
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
issue: {
id: string;
companyId: string;
projectId: string | null;
parentId: string | null;
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
},
) {
if (req.actor.type !== "agent") return true;
const actorAgentId = req.actor.agentId;
@@ -1511,6 +1783,11 @@ export function issueRoutes(
res.status(403).json({ error: "Agent authentication required" });
return false;
}
const boundaryDecision = await decideIssueAccess(req, issue, "issue:mutate");
if (!boundaryDecision.allowed) {
res.status(403).json({ error: "Issue is outside this actor's authorization boundary" });
return false;
}
if (issue.assigneeAgentId === null) {
return true;
}
@@ -1671,6 +1948,8 @@ export function issueRoutes(
res: Response,
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
) {
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return false;
if (issue.status === "cancelled") {
res.status(409).json({
error: "Cancelled issues must be restored through the dedicated restore flow",
@@ -1949,6 +2228,15 @@ export function issueRoutes(
router.get("/companies/:companyId/search", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Company search is outside this actor's authorization boundary" });
return;
}
const query = companySearchQuerySchema.parse(req.query);
const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId));
res.setHeader("X-RateLimit-Limit", String(rateLimit.limit));
@@ -2044,7 +2332,7 @@ export function issueRoutes(
}
const offset = parsedOffset ?? 0;
const result = await svc.list(companyId, {
const rawResult = await svc.list(companyId, {
attention: attention === "blocked" ? "blocked" : undefined,
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
@@ -2078,6 +2366,9 @@ export function issueRoutes(
sortField: sortField === "updated" ? "updated" : undefined,
sortDir: sortDir === "asc" || sortDir === "desc" ? sortDir : undefined,
});
const result = await actorCanReadCompanyScope(req, companyId)
? rawResult
: await filterIssuesForActor(req, rawResult);
const issueIds = result.map((issue) => issue.id);
const [handoffStates, recoveryActionByIssue] = await Promise.all([
listSuccessfulRunHandoffStates(db, companyId, issueIds),
@@ -2121,7 +2412,7 @@ export function issueRoutes(
return;
}
const count = await svc.count(companyId, {
const blockedCountFilters = {
attention: "blocked",
status: req.query.status as string | undefined,
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
@@ -2146,7 +2437,44 @@ export function issueRoutes(
includeBlockedInboxAttention: true,
hasPlanDocument,
q: req.query.q as string | undefined,
});
} as const;
if (!(await actorCanReadCompanyScope(req, companyId))) {
const trustResolution = req.actor.type === "agent"
? await resolveAgentTrustForIssue({
agentId: req.actor.agentId,
runId: req.actor.runId,
}, companyId, null)
: null;
if (trustResolution?.kind === "denied") {
throw forbidden(trustResolution.detail);
}
if (trustResolution?.kind === "low_trust_review") {
const count = await svc.count(companyId, {
...blockedCountFilters,
lowTrustBoundary: trustResolution.boundary,
});
res.json({ count });
return;
}
let offset = 0;
let visibleCount = 0;
while (true) {
const rows = await svc.list(companyId, {
...blockedCountFilters,
limit: ISSUE_LIST_MAX_LIMIT,
offset,
});
visibleCount += (await filterIssuesForActor(req, rows)).length;
if (rows.length < ISSUE_LIST_MAX_LIMIT) break;
offset += rows.length;
}
res.json({ count: visibleCount });
return;
}
const count = await svc.count(companyId, blockedCountFilters);
res.json({ count });
});
@@ -2212,6 +2540,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const wakeCommentId =
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
@@ -2264,6 +2593,17 @@ export function issueRoutes(
actor: getActorInfo(req),
activeRecoveryAction,
});
const redactLowTrust = await shouldRedactLowTrustForHeartbeatContext(issue, getActorInfo(req));
const safeWakeComment =
wakeComment && wakeComment.issueId === issue.id
? redactLowTrust
? sanitizeQuarantinedCommentForHigherTrust(wakeComment)
: wakeComment
: null;
const safeContinuationSummary =
continuationSummary && redactLowTrust
? redactQuarantinedBodyForHigherTrust(continuationSummary)
: continuationSummary;
res.json({
issue: {
@@ -2314,10 +2654,7 @@ export function issueRoutes(
}
: null,
commentCursor,
wakeComment:
wakeComment && wakeComment.issueId === issue.id
? wakeComment
: null,
wakeComment: safeWakeComment,
attachments: attachments.map((a) => ({
id: a.id,
filename: a.originalFilename,
@@ -2326,14 +2663,15 @@ export function issueRoutes(
contentPath: withContentPath(a).contentPath,
createdAt: a.createdAt,
})),
continuationSummary: continuationSummary
continuationSummary: safeContinuationSummary
? {
key: continuationSummary.key,
title: continuationSummary.title,
body: continuationSummary.body,
latestRevisionId: continuationSummary.latestRevisionId,
latestRevisionNumber: continuationSummary.latestRevisionNumber,
updatedAt: continuationSummary.updatedAt,
key: safeContinuationSummary.key,
title: safeContinuationSummary.title,
body: safeContinuationSummary.body ?? "",
latestRevisionId: safeContinuationSummary.latestRevisionId,
latestRevisionNumber: safeContinuationSummary.latestRevisionNumber,
updatedAt: safeContinuationSummary.updatedAt,
sourceTrust: safeContinuationSummary.sourceTrust ?? null,
}
: null,
currentExecutionWorkspace,
@@ -2348,6 +2686,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const [
{ project, goal },
ancestors,
@@ -2613,6 +2952,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const workProducts = await workProductsSvc.listForIssue(issue.id);
res.json(workProducts);
});
@@ -2625,6 +2965,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const docs = await documentsSvc.listIssueDocuments(issue.id, {
includeSystem: req.query.includeSystem === "true",
});
@@ -2639,6 +2980,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
if (!keyParsed.success) {
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
@@ -2899,6 +3241,7 @@ export function issueRoutes(
}
const actor = getActorInfo(req);
const sourceTrust = await sourceTrustForActorWrite(issue, actor);
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
const result = await documentsSvc.upsertIssueDocument({
issueId: issue.id,
@@ -2911,6 +3254,7 @@ export function issueRoutes(
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
createdByRunId: actor.runId ?? null,
sourceTrust,
lockedDocumentStrategy: req.actor.type === "agent" ? "create_new_document" : "conflict",
});
const doc = result.document;
@@ -3319,9 +3663,11 @@ export function issueRoutes(
assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const actor = getActorInfo(req);
const createInput = {
...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null,
sourceTrust: await sourceTrustForActorWrite(issue, actor),
};
if (requiresPaperclipAttachmentMetadata(createInput)) {
createInput.metadata = await canonicalizePaperclipArtifactMetadata({
@@ -3334,7 +3680,6 @@ export function issueRoutes(
res.status(422).json({ error: "Invalid work product payload" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@@ -3355,6 +3700,151 @@ export function issueRoutes(
res.status(201).json(product);
});
router.post("/issues/:id/low-trust/promotions", validate(promoteLowTrustOutputSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await svc.getById(id);
if (!issue) {
res.status(404).json({ error: "Issue not found" });
return;
}
assertCompanyAccess(req, issue.companyId);
if (!(await assertIssueReadAllowed(req, res, issue))) return;
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const actor = getActorInfo(req);
if (await sourceTrustForActorWrite(issue, actor)) {
res.status(403).json({ error: "Low-trust actors cannot promote quarantined output" });
return;
}
const sourceTrust = await lookupLowTrustSourceArtifact({
issueId: issue.id,
artifactKind: req.body.sourceArtifactKind,
artifactId: req.body.sourceArtifactId,
});
if (!sourceTrust) {
res.status(404).json({ error: "Low-trust source artifact not found" });
return;
}
if (!isLowTrustQuarantined(sourceTrust)) {
res.status(422).json({ error: "Source artifact is not quarantined low-trust output" });
return;
}
const promotedAt = new Date();
const promotionTrust = buildPromotedSourceTrust({
sourceIssueId: issue.id,
sourceArtifactKind: req.body.sourceArtifactKind,
sourceArtifactId: req.body.sourceArtifactId,
promotedByActorType: actor.actorType,
promotedByActorId: actor.actorId,
promotedAt,
});
const product = await db.transaction(async (tx) => {
const markPromoted = { sourceTrust: promotionTrust, updatedAt: promotedAt };
const updatedSource = await (async () => {
if (req.body.sourceArtifactKind === "issue") {
return tx
.update(issueRows)
.set(markPromoted)
.where(and(
eq(issueRows.id, req.body.sourceArtifactId),
eq(issueRows.sourceTrust, sourceTrust),
))
.returning({ id: issueRows.id });
}
if (req.body.sourceArtifactKind === "comment") {
return tx
.update(issueComments)
.set(markPromoted)
.where(and(
eq(issueComments.id, req.body.sourceArtifactId),
eq(issueComments.issueId, issue.id),
eq(issueComments.sourceTrust, sourceTrust),
))
.returning({ id: issueComments.id });
}
if (req.body.sourceArtifactKind === "document") {
return tx
.update(documents)
.set(markPromoted)
.where(and(
eq(documents.id, req.body.sourceArtifactId),
eq(documents.sourceTrust, sourceTrust),
))
.returning({ id: documents.id });
}
return tx
.update(issueWorkProducts)
.set(markPromoted)
.where(and(
eq(issueWorkProducts.id, req.body.sourceArtifactId),
eq(issueWorkProducts.issueId, issue.id),
eq(issueWorkProducts.sourceTrust, sourceTrust),
))
.returning({ id: issueWorkProducts.id });
})();
if (!updatedSource[0]) return null;
return tx
.insert(issueWorkProducts)
.values({
companyId: issue.companyId,
issueId: issue.id,
projectId: issue.projectId ?? null,
type: "artifact",
provider: "paperclip",
externalId: req.body.sourceArtifactId,
title: req.body.title,
status: "approved",
reviewState: "approved",
isPrimary: false,
healthStatus: "unknown",
summary: req.body.summary,
metadata: {
promotion: {
sourceArtifactKind: req.body.sourceArtifactKind,
sourceArtifactId: req.body.sourceArtifactId,
},
},
sourceTrust: promotionTrust,
createdByRunId: actor.runId ?? null,
})
.returning()
.then((rows) => rows[0] ?? null);
});
if (!product) {
res.status(422).json({ error: "Source artifact is not quarantined low-trust output" });
return;
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "issue.low_trust_output_promoted",
entityType: "issue",
entityId: issue.id,
details: {
sourceArtifacts: [{
artifactKind: req.body.sourceArtifactKind,
artifactId: req.body.sourceArtifactId,
}],
reviewerPrincipal: {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
},
targetIssueId: issue.id,
promotedWorkProductId: product.id,
decision: "promoted",
},
});
res.status(201).json(product);
});
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await workProductsSvc.getById(id);
@@ -3370,6 +3860,7 @@ export function issueRoutes(
}
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const actor = getActorInfo(req);
const patch = { ...req.body };
if (requiresPaperclipAttachmentMetadata(patch, existing)) {
if (patch.metadata !== undefined) {
@@ -3382,12 +3873,15 @@ export function issueRoutes(
return;
}
}
const product = await workProductsSvc.update(id, patch);
const sourceTrust = await sourceTrustForActorWrite(issue, actor);
const product = await workProductsSvc.update(id, {
...patch,
...(sourceTrust ? { sourceTrust } : {}),
});
if (!product) {
res.status(404).json({ error: "Work product not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
@@ -3585,6 +4079,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
res.json(approvals);
});
@@ -3655,7 +4150,27 @@ export function issueRoutes(
router.post("/companies/:companyId/issues", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (await assertLowTrustControlPlaneDenied(req, res, companyId, null)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (req.actor.type === "agent" && !req.body.parentId) {
const companyScopeDecision = await access.decide({
actor: req.actor,
action: "company_scope:read",
resource: { type: "company", companyId },
});
if (!companyScopeDecision.allowed) {
res.status(403).json({ error: "Low-trust agents must create child issues inside their assigned boundary" });
return;
}
}
if (req.actor.type === "agent" && req.body.parentId) {
const parent = await svc.getById(req.body.parentId);
if (!parent || parent.companyId !== companyId) {
res.status(404).json({ error: "Parent issue not found" });
return;
}
if (!(await assertIssueReadAllowed(req, res, parent))) return;
}
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
await assertCanAssignTasks(req, companyId, {
@@ -3676,10 +4191,19 @@ export function issueRoutes(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
actor.actorType,
);
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
await assertCanManageIssueMonitor(access, req, companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId,
projectId: req.body.projectId ?? null,
executionPolicy,
}, actor);
const issue = await svc.create(companyId, {
...req.body,
id: issueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
});
@@ -3760,6 +4284,8 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, parent.companyId);
if (!(await assertIssueReadAllowed(req, res, parent))) return;
if (await assertLowTrustControlPlaneDenied(req, res, parent.companyId, parent)) return;
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
@@ -3777,10 +4303,19 @@ export function issueRoutes(
normalizeIssueExecutionPolicy(req.body.executionPolicy),
actor.actorType,
);
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
await assertCanManageIssueMonitor(access, req, parent.companyId, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const issueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: issueId,
companyId: parent.companyId,
projectId: req.body.projectId ?? parent.projectId ?? null,
executionPolicy,
}, actor);
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
...req.body,
id: issueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorAgentId: actor.agentId,
@@ -3881,21 +4416,31 @@ export function issueRoutes(
}
const actor = getActorInfo(req);
const normalizedChildren = req.body.children.map((child: typeof req.body.children[number]) => {
const normalizedChildren = [];
for (const child of req.body.children as Array<typeof req.body.children[number]>) {
const executionPolicy = applyActorMonitorScheduledBy(
normalizeIssueExecutionPolicy(child.executionPolicy),
actor.actorType,
);
assertCanManageIssueMonitor(req, child.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
return {
...child,
await assertCanManageIssueMonitor(access, req, sourceIssue.companyId, child.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
const childIssueId = randomUUID();
const sourceTrust = await sourceTrustForActorWrite({
id: childIssueId,
companyId: sourceIssue.companyId,
projectId: child.projectId ?? sourceIssue.projectId ?? null,
executionPolicy,
}, actor);
normalizedChildren.push({
...child,
id: childIssueId,
executionPolicy,
...(sourceTrust ? { sourceTrust } : {}),
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
actorAgentId: actor.agentId,
actorUserId: actor.actorType === "user" ? actor.actorId : null,
};
});
});
}
const result = await svc.decomposeAcceptedPlan(sourceIssue.id, {
acceptedPlanRevisionId: req.body.acceptedPlanRevisionId,
@@ -3997,7 +4542,7 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
assertCanManageIssueMonitor(req, issue.assigneeAgentId, true);
await assertCanManageIssueMonitor(access, req, issue.companyId, issue.assigneeAgentId, true);
const actor = getActorInfo(req);
await heartbeat.triggerIssueMonitor(issue.id, {
@@ -4087,6 +4632,14 @@ export function issueRoutes(
res.status(400).json({ error: "Follow-up intent requires a comment" });
return;
}
if (
(reopenRequested === true ||
resumeRequested === true ||
Array.isArray(req.body.blockedByIssueIds)) &&
await assertLowTrustControlPlaneDenied(req, res, existing.companyId, existing)
) {
return;
}
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
@@ -4232,7 +4785,13 @@ export function issueRoutes(
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
}
const monitorChanged = monitorPoliciesEqual(previousExecutionPolicy, nextExecutionPolicy) === false;
assertCanManageIssueMonitor(req, existing.assigneeAgentId, req.body.executionPolicy !== undefined && monitorChanged);
await assertCanManageIssueMonitor(
access,
req,
existing.companyId,
existing.assigneeAgentId,
req.body.executionPolicy !== undefined && monitorChanged,
);
const transition = applyIssueExecutionPolicyTransition({
issue: existing,
@@ -4725,6 +5284,8 @@ export function issueRoutes(
agentId: actor.agentId ?? undefined,
userId: actor.actorType === "user" ? actor.actorId : undefined,
runId: actor.runId,
}, {
sourceTrust: await sourceTrustForActorWrite(issue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
@@ -5294,6 +5855,7 @@ export function issueRoutes(
assertCompanyAccess(req, issue.companyId);
if (req.actor.type === "agent") {
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (await assertLowTrustControlPlaneDenied(req, res, issue.companyId, issue)) return;
} else {
assertBoard(req);
}
@@ -5980,6 +6542,7 @@ export function issueRoutes(
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
presentation: req.body.presentation ?? null,
metadata: req.body.metadata ?? null,
sourceTrust: await sourceTrustForActorWrite(currentIssue, actor),
});
await issueReferencesSvc.syncComment(comment.id);
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
+19
View File
@@ -416,6 +416,10 @@ const responses = {
description: "Not found",
content: { "application/json": { schema: ErrorSchema } },
},
unprocessable: {
description: "Unprocessable entity",
content: { "application/json": { schema: ErrorSchema } },
},
serverError: {
description: "Internal server error",
content: { "application/json": { schema: ErrorSchema } },
@@ -593,6 +597,7 @@ const CREATED_OPERATIONS = new Set([
"POST /api/issues/{id}/documents/{key}/annotations",
"POST /api/issues/{id}/documents/{key}/annotations/{threadId}/comments",
"POST /api/issues/{id}/work-products",
"POST /api/issues/{id}/low-trust/promotions",
"POST /api/issues/{id}/approvals",
"POST /api/companies/{companyId}/issues",
"POST /api/issues/{id}/children",
@@ -4238,6 +4243,20 @@ registerCurrentRoute({
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound },
});
registerCurrentRoute({
method: "post",
path: "/api/issues/{id}/low-trust/promotions",
tags: ["issues"],
summary: "Promote quarantined low-trust output",
body: z.object({
sourceArtifactKind: z.enum(["comment", "document", "work_product", "issue"]),
sourceArtifactId: z.string().uuid(),
title: z.string().trim().min(1).max(200),
summary: z.string().trim().min(1).max(8_000),
}),
responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registerCurrentRoute({
method: "patch",
path: "/api/issues/{id}/documents/{key}/annotations/{threadId}",
+26 -2
View File
@@ -13,7 +13,7 @@ import {
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { trackProjectCreated } from "@paperclipai/shared/telemetry";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { accessService, projectService, logActivity, workspaceOperationService } from "../services/index.js";
import { conflict, forbidden } from "../errors.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
import {
@@ -41,6 +41,7 @@ const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]);
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
const access = accessService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
@@ -88,6 +89,28 @@ export function projectRoutes(db: Db) {
return resolved.project?.id ?? rawId;
}
async function assertProjectReadAllowed(req: Request, res: Response, project: { id: string; companyId: string }) {
const decision = await access.decide({
actor: req.actor,
action: "project:read",
resource: { type: "project", companyId: project.companyId, projectId: project.id },
});
if (decision.allowed) return true;
res.status(403).json({ error: "Project is outside this actor's authorization boundary" });
return false;
}
async function filterProjectsForActor<T extends { id: string; companyId: string }>(req: Request, rows: T[]) {
const decisions = await Promise.all(rows.map((project) =>
access.decide({
actor: req.actor,
action: "project:read",
resource: { type: "project", companyId: project.companyId, projectId: project.id },
})
));
return rows.filter((_, index) => decisions[index]?.allowed);
}
router.param("id", async (req, _res, next, rawId) => {
try {
req.params.id = await normalizeProjectReference(req, rawId);
@@ -101,7 +124,7 @@ export function projectRoutes(db: Db) {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
const result = await svc.list(companyId);
res.json(result);
res.json(await filterProjectsForActor(req, result));
});
router.get("/projects/:id", async (req, res) => {
@@ -112,6 +135,7 @@ export function projectRoutes(db: Db) {
return;
}
assertCompanyAccess(req, project.companyId);
if (!(await assertProjectReadAllowed(req, res, project))) return;
res.json(project);
});
@@ -1,9 +1,14 @@
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { agents, issues } from "@paperclipai/db";
import { agents, heartbeatRuns, issues, projects } from "@paperclipai/db";
import { isUuidLike } from "@paperclipai/shared";
import type { Request } from "express";
import { forbidden } from "../errors.js";
import { assertCompanyAccess } from "./authz.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { isLowTrustRuntimeManagementAllowed } from "../services/low-trust-runtime-containment.js";
import { resolveCoreTrustPreset, type TrustPresetResolution } from "../services/trust-preset-resolver.js";
import { readObject } from "../lib/objects.js";
const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"backlog",
@@ -13,6 +18,14 @@ const WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES: string[] = [
"blocked",
];
function readRunIssueId(context: Record<string, unknown> | null) {
const directIssueId = context?.issueId;
if (typeof directIssueId === "string" && isUuidLike(directIssueId)) return directIssueId;
const paperclipIssue = readObject(context?.paperclipIssue);
const nestedIssueId = paperclipIssue?.id;
return typeof nestedIssueId === "string" && isUuidLike(nestedIssueId) ? nestedIssueId : null;
}
async function listReportingSubtreeAgentIds(db: Db, companyId: string, actorAgentId: string) {
const companyAgents = await db
.select({
@@ -65,6 +78,7 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
id: agents.id,
companyId: agents.companyId,
role: agents.role,
permissions: agents.permissions,
})
.from(agents)
.where(eq(agents.id, req.actor.agentId))
@@ -74,11 +88,62 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
throw forbidden("Agent key cannot access another company");
}
if (actorAgent.role === "ceo") {
const actorRun = req.actor.runId
? await db
.select({
companyId: heartbeatRuns.companyId,
agentId: heartbeatRuns.agentId,
contextSnapshot: heartbeatRuns.contextSnapshot,
})
.from(heartbeatRuns)
.where(and(
eq(heartbeatRuns.id, req.actor.runId),
eq(heartbeatRuns.companyId, input.companyId),
eq(heartbeatRuns.agentId, actorAgent.id),
))
.then((rows) => rows[0] ?? null)
: null;
const runContext = readObject(actorRun?.contextSnapshot);
const runExecutionPolicy = readObject(runContext?.executionPolicy);
const actorRuntimeTrust = assertLowTrustCanManageRuntimeForActor({
companyId: input.companyId,
actorAgent,
runExecutionPolicy,
});
if (actorAgent.role === "ceo" && actorRuntimeTrust.kind === "standard") {
return;
}
const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id);
const runIssueId = readRunIssueId(runContext);
const runScopedIssue = runIssueId
? await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
executionPolicy: issues.executionPolicy,
projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(issues)
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId)))
.where(and(
eq(issues.id, runIssueId),
eq(issues.companyId, input.companyId),
))
.then((rows) => rows[0] ?? null)
: null;
if (runScopedIssue) {
assertLowTrustCanManageRuntimeForIssue({
actorAgent,
issue: runScopedIssue,
projectExecutionWorkspacePolicy: runScopedIssue.projectExecutionWorkspacePolicy,
runExecutionPolicy,
});
}
const workspaceScopeConditions = [
input.projectWorkspaceId ? eq(issues.projectWorkspaceId, input.projectWorkspaceId) : null,
input.executionWorkspaceId ? eq(issues.executionWorkspaceId, input.executionWorkspaceId) : null,
@@ -89,27 +154,149 @@ async function assertAgentCanManageRuntimeServicesForWorkspace(
throw forbidden("Missing permission to manage workspace runtime services");
}
const linkedIssue = await db
.select({ id: issues.id })
const workspaceScopeCondition = workspaceScopeConditions.length === 1
? workspaceScopeConditions[0]!
: or(...workspaceScopeConditions);
const linkedScopeIssues = await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
executionPolicy: issues.executionPolicy,
projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(issues)
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId)))
.where(and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES),
workspaceScopeCondition,
));
for (const linkedScopeIssue of linkedScopeIssues) {
assertLowTrustCanManageRuntimeForIssue({
actorAgent,
issue: linkedScopeIssue,
projectExecutionWorkspacePolicy: linkedScopeIssue.projectExecutionWorkspacePolicy,
runExecutionPolicy,
});
}
if (actorAgent.role === "ceo") {
return;
}
const eligibleAgentIds = await listReportingSubtreeAgentIds(db, input.companyId, actorAgent.id);
const linkedIssue = await db
.select({
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
executionPolicy: issues.executionPolicy,
projectExecutionWorkspacePolicy: projects.executionWorkspacePolicy,
})
.from(issues)
.leftJoin(projects, and(eq(projects.id, issues.projectId), eq(projects.companyId, issues.companyId)))
.where(and(
eq(issues.companyId, input.companyId),
isNull(issues.hiddenAt),
inArray(issues.status, WORKSPACE_RUNTIME_ELIGIBLE_ISSUE_STATUSES),
inArray(issues.assigneeAgentId, eligibleAgentIds),
workspaceScopeConditions.length === 1
? workspaceScopeConditions[0]!
: or(...workspaceScopeConditions),
workspaceScopeCondition,
))
.then((rows) => rows[0] ?? null);
if (linkedIssue) {
assertLowTrustCanManageRuntimeForIssue({
actorAgent,
issue: linkedIssue,
projectExecutionWorkspacePolicy: linkedIssue.projectExecutionWorkspacePolicy,
runExecutionPolicy,
});
return;
}
throw forbidden("Missing permission to manage workspace runtime services");
}
function assertLowTrustCanManageRuntimeForActor(input: {
companyId: string;
actorAgent: {
id: string;
companyId: string;
permissions: unknown;
};
runExecutionPolicy?: unknown;
}): TrustPresetResolution {
const resolution = resolveCoreTrustPreset({
companyId: input.companyId,
agent: {
companyId: input.actorAgent.companyId,
permissions: input.actorAgent.permissions,
},
run: input.runExecutionPolicy
? {
companyId: input.companyId,
executionPolicy: input.runExecutionPolicy,
}
: null,
});
if (resolution.kind === "denied") {
throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`);
}
if (resolution.kind !== "low_trust_review") return resolution;
if (isLowTrustRuntimeManagementAllowed(resolution)) return resolution;
throw forbidden("Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage");
}
function assertLowTrustCanManageRuntimeForIssue(input: {
actorAgent: {
id: string;
companyId: string;
permissions: unknown;
};
issue: {
id: string;
companyId: string;
projectId: string | null;
executionPolicy: unknown;
};
projectExecutionWorkspacePolicy: unknown;
runExecutionPolicy?: unknown;
}) {
const resolution = resolveCoreTrustPreset({
companyId: input.issue.companyId,
agent: {
companyId: input.actorAgent.companyId,
permissions: input.actorAgent.permissions,
},
project: input.issue.projectId
? {
companyId: input.issue.companyId,
executionWorkspacePolicy: parseProjectExecutionWorkspacePolicy(input.projectExecutionWorkspacePolicy),
}
: null,
issue: {
companyId: input.issue.companyId,
executionPolicy: input.issue.executionPolicy,
},
run: input.runExecutionPolicy
? {
companyId: input.issue.companyId,
executionPolicy: input.runExecutionPolicy,
}
: null,
});
if (resolution.kind === "denied") {
throw forbidden(`Low-trust runtime service access denied: ${resolution.detail}`);
}
if (resolution.kind !== "low_trust_review") return;
if (isLowTrustRuntimeManagementAllowed(resolution)) return;
throw forbidden("Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage");
}
export async function assertCanManageProjectWorkspaceRuntimeServices(
db: Db,
req: Request,