mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 20:46:28 +01:00
Add low-trust review containment (#7530)
## Thinking Path > - Paperclip is a control plane for AI-agent companies, so execution policy and trust boundaries are part of the product's safety contract. > - Low-trust review work needs narrower authority than normal same-company agents because hostile PRs, comments, attachments, and generated output can carry prompt-injection payloads. > - The current V1 shape gives trusted workers broad company context, which is useful for normal execution but too permissive for a reviewer assigned to hostile content. > - This branch adds a `low_trust_review` preset, source-trust tagging, route-level containment, and quarantine handling so low-trust output does not automatically flow into higher-trust wake context. > - The branch has been rebased onto current `origin/master`, and the low-trust migration was renumbered to `0097_low_trust_source_trust.sql` to avoid collisions with existing `0091` through `0096` migrations. > - Greptile feedback was addressed by tightening low-trust detection, preserving project-level trust policy checks, fixing issue-kind promotion lookup, removing duplicate post-lease isolation assertion, documenting fail-closed source-trust behavior, bounding ancestry checks, enforcing runtime issue context for CEOs, awaiting accepted-plan monitor authorization, and making low-trust issue source-trust tagging atomic. > - The benefit is a first production slice of deny-by-default review containment with regression coverage for the main control-plane pivot surfaces. Fixes #7531. ## What Changed - Added shared trust-policy types and validators, plus database/source-trust fields for issues, comments, documents, and work products. - Implemented server enforcement for low-trust issue scope, agent self-view redaction, secret/plugin/runtime denial paths, promotion checks, and quarantined continuation/wake context. - Added focused low-trust regression tests for resolver behavior, source trust, route authorization, heartbeat preflight ordering, runtime containment, and quarantine redaction. - Added board UI affordances for selecting/reviewing the low-trust preset and surfacing source-trust badges in relevant issue views. - Added `doc/LOW-TRUST-PRESETS.md`, updated `doc/SPEC-implementation.md`, and committed the low-trust review contract plan under `doc/plans/`. - Rebasing note: the original `0097_low_trust_source_trust.sql` migration was renamed to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied the old-numbered migration are not broken by the renumbered migration. ## Verification - Rebased branch onto current `origin/master` and force-pushed with lease to `origin/PAP-10211-low-trust-agent` at head `2719f31e3`. - Confirmed the PR diff does not include `pnpm-lock.yaml` or `.github/workflows` changes. - Resolved upstream UI/comment conflicts by preserving deleted-comment tombstone behavior and low-trust source-trust badges/metadata. - Renumbered the low-trust source-trust migration to `0097_low_trust_source_trust.sql`; the SQL uses `ADD COLUMN IF NOT EXISTS` so users who already applied an old-numbered copy are not broken. - `pnpm exec vitest run ui/src/lib/issue-chat-messages.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts` - `pnpm exec vitest run server/src/__tests__/source-trust.test.ts server/src/__tests__/workspace-runtime-service-authz.test.ts ui/src/lib/trust-policy-ui.test.ts ui/src/components/TrustPresetSection.test.tsx` - `pnpm run typecheck:build-gaps` - `git diff --check` - GitHub checks pass on head `2719f31e3`: build, typecheck/release registry, general tests, serialized server suites, e2e, canary, verify, policy/review, Socket, and Snyk. - Greptile Review passes with Confidence Score 5/5 and zero unresolved Greptile review threads. - No design screenshots/images were added because the task explicitly says not to add them unless they are specifically part of the work. ## Risks - Medium risk: this touches shared trust-policy contracts, server authorization paths, heartbeat context generation, migration metadata, and UI preset controls. - Low-trust containment is intentionally deny-by-default; legitimate future review workflows may need explicit allowlisted exceptions. - Plugin/runtime/security surfaces are broad, so regression tests cover the current known routes but future integrations must route through the same containment layer. - The PR is ready for review; GitHub checks are green and Greptile is 5/5. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled shell and GitHub CLI workflow. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI changes are covered by focused tests; no screenshots were added per task instruction not to add design images unless specifically required - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b2a33d0184
commit
dbebf30c89
87 files changed
+6522
-121
No files matched your search
@@ -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
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user