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

@@ -19,6 +19,10 @@ const mockIssueService = vi.hoisted(() => ({
getByIdentifier: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
vi.mock("../services/activity.js", () => ({
activityService: () => mockActivityService,
normalizeActivityLimit: (limit: number | undefined) => {
@@ -28,6 +32,7 @@ vi.mock("../services/activity.js", () => ({
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
issueService: () => mockIssueService,
heartbeatService: () => mockHeartbeatService,
}));
@@ -92,6 +97,13 @@ describe.sequential("activity routes", () => {
for (const mock of Object.values(mockActivityService)) mock.mockReset();
for (const mock of Object.values(mockHeartbeatService)) mock.mockReset();
for (const mock of Object.values(mockIssueService)) mock.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
});
it("limits company activity lists by default", async () => {
@@ -2,6 +2,7 @@ import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
vi.mock("acpx/runtime", () => ({
createAcpRuntime: vi.fn(),
@@ -396,6 +397,11 @@ describe.sequential("agent permission routes", () => {
it("redacts agent detail for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: input.action === "agent:read",
reason: input.action === "agent:read" ? "allow_test_read" : "deny_missing_grant",
explanation: input.action === "agent:read" ? "Allowed by test read grant." : "Missing test grant.",
}));
const app = await createApp({
type: "board",
@@ -412,8 +418,54 @@ describe.sequential("agent permission routes", () => {
expect(res.body.runtimeConfig).toEqual({});
}, 20_000);
it("keeps board agent detail unredacted for low-trust agents", async () => {
mockAgentService.getById.mockResolvedValue({
...baseAgent,
permissions: {
...baseAgent.permissions,
trustPreset: LOW_TRUST_REVIEW_PRESET,
},
adapterConfig: {
command: "pnpm agent:run",
env: { PAPERCLIP_API_KEY: "secret-test-key" },
},
runtimeConfig: {
modelProfiles: {
default: { enabled: true, adapterConfig: { model: "openai/gpt-5.4-mini" } },
},
},
});
const app = await createApp({
type: "board",
userId: "board-user",
source: "local_implicit",
isInstanceAdmin: true,
companyIds: [companyId],
});
const res = await requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}`));
expect(res.status).toBe(200);
expect(res.body.adapterConfig).toMatchObject({
command: "pnpm agent:run",
env: { PAPERCLIP_API_KEY: "secret-test-key" },
});
expect(res.body.runtimeConfig).toMatchObject({
modelProfiles: {
default: { enabled: true, adapterConfig: { model: "openai/gpt-5.4-mini" } },
},
});
expect(res.body.permissions).toMatchObject({ trustPreset: LOW_TRUST_REVIEW_PRESET });
}, 20_000);
it("redacts company agent list for authenticated company members without agent admin permission", async () => {
mockAccessService.canUser.mockResolvedValue(false);
mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({
allowed: input.action === "agent:read",
reason: input.action === "agent:read" ? "allow_test_read" : "deny_missing_grant",
explanation: input.action === "agent:read" ? "Allowed by test read grant." : "Missing test grant.",
}));
const app = await createApp({
type: "board",
@@ -28,9 +28,13 @@ const mockSecretService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
heartbeatService: () => mockHeartbeatService,
issueApprovalService: () => mockIssueApprovalService,
@@ -107,6 +111,13 @@ describe("approval routes idempotent retries", () => {
mockIssueApprovalService.linkManyForApproval.mockReset();
mockSecretService.normalizeHireApprovalPayloadForPersistence.mockReset();
mockLogActivity.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockHeartbeatService.wakeup.mockResolvedValue({ id: "wake-1" });
mockIssueApprovalService.listIssuesForApproval.mockResolvedValue([{ id: "issue-1" }]);
mockLogActivity.mockResolvedValue(undefined);
@@ -6,9 +6,11 @@ import {
companyMemberships,
createDb,
instanceUserRoles,
issues,
principalPermissionGrants,
projects,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -61,6 +63,33 @@ async function createProject(db: ReturnType<typeof createDb>, companyId: string,
.then((rows) => rows[0]!);
}
async function createIssue(
db: ReturnType<typeof createDb>,
companyId: string,
input: {
id?: string;
title?: string;
projectId?: string | null;
parentId?: string | null;
assigneeAgentId?: string | null;
} = {},
) {
return db
.insert(issues)
.values({
id: input.id ?? randomUUID(),
companyId,
title: input.title ?? `Issue ${randomUUID()}`,
status: "todo",
priority: "medium",
projectId: input.projectId ?? null,
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
})
.returning()
.then((rows) => rows[0]!);
}
async function grantAgentPermission(
db: ReturnType<typeof createDb>,
companyId: string,
@@ -98,6 +127,7 @@ describeEmbeddedPostgres("authorization service", () => {
await db.delete(principalPermissionGrants);
await db.delete(companyMemberships);
await db.delete(instanceUserRoles);
await db.delete(issues);
await db.delete(agents);
await db.delete(projects);
await db.delete(companies);
@@ -218,6 +248,141 @@ describeEmbeddedPostgres("authorization service", () => {
expect(decision.explanation).toContain("simple mode");
});
it("limits low-trust issue reads to the configured project and root issue boundary", async () => {
const company = await createCompany(db, "LowTrustIssueReads");
const project = await createProject(db, company.id, "Allowed");
const otherProject = await createProject(db, company.id, "Denied");
const rootIssueId = randomUUID();
const actorAgent = await createAgent(db, company.id, {
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [project.id],
rootIssueId,
},
},
},
});
const rootIssue = await createIssue(db, company.id, {
id: rootIssueId,
projectId: project.id,
assigneeAgentId: actorAgent.id,
});
const childIssue = await createIssue(db, company.id, {
projectId: project.id,
parentId: rootIssue.id,
});
const unrelatedIssue = await createIssue(db, company.id, {
projectId: otherProject.id,
});
const authorization = authorizationService(db);
const actor = { type: "agent" as const, agentId: actorAgent.id, companyId: company.id, source: "agent_key" as const };
const rootDecision = await authorization.decide({
actor,
action: "issue:read",
resource: {
type: "issue",
companyId: company.id,
issueId: rootIssue.id,
projectId: rootIssue.projectId,
parentIssueId: rootIssue.parentId,
assigneeAgentId: rootIssue.assigneeAgentId,
status: rootIssue.status,
},
});
const childDecision = await authorization.decide({
actor,
action: "issue:read",
resource: {
type: "issue",
companyId: company.id,
issueId: childIssue.id,
projectId: childIssue.projectId,
parentIssueId: childIssue.parentId,
status: childIssue.status,
},
});
const unrelatedDecision = await authorization.decide({
actor,
action: "issue:read",
resource: {
type: "issue",
companyId: company.id,
issueId: unrelatedIssue.id,
projectId: unrelatedIssue.projectId,
parentIssueId: unrelatedIssue.parentId,
status: unrelatedIssue.status,
},
});
expect(rootDecision).toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" });
expect(childDecision).toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" });
expect(unrelatedDecision).toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
});
it("blocks low-trust project, agent, company-wide, and outside-boundary assignment access", async () => {
const company = await createCompany(db, "LowTrustOtherResources");
const project = await createProject(db, company.id, "Allowed");
const otherProject = await createProject(db, company.id, "Denied");
const collaborator = await createAgent(db, company.id);
const higherTrustAgent = await createAgent(db, company.id, { role: "cto" });
const actorAgent = await createAgent(db, company.id, {
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [project.id],
allowedAgentIds: [collaborator.id],
},
},
},
});
const authorization = authorizationService(db);
const actor = { type: "agent" as const, agentId: actorAgent.id, companyId: company.id, source: "agent_key" as const };
await expect(authorization.decide({
actor,
action: "project:read",
resource: { type: "project", companyId: company.id, projectId: project.id },
})).resolves.toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "project:read",
resource: { type: "project", companyId: company.id, projectId: otherProject.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "agent:read",
resource: { type: "agent", companyId: company.id, agentId: collaborator.id },
})).resolves.toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "agent:read",
resource: { type: "agent", companyId: company.id, agentId: higherTrustAgent.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "company_scope:read",
resource: { type: "company", companyId: company.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
await expect(authorization.decide({
actor,
action: "tasks:assign",
resource: {
type: "issue",
companyId: company.id,
projectId: project.id,
assigneeAgentId: higherTrustAgent.id,
},
scope: { projectId: project.id, assigneeAgentId: higherTrustAgent.id },
})).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" });
});
it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => {
const company = await createCompany(db, "ProtectedAssignment");
const actorAgent = await createAgent(db, company.id, { role: "engineer" });
@@ -24,10 +24,11 @@ describe("company search route rate limiting", () => {
const app = express();
app.use((req, _res, next) => {
req.actor = {
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
type: "board",
userId: "user-1",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: true,
};
next();
});
@@ -104,9 +104,13 @@ const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
resolveIncident: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
budgetService: () => mockBudgetService,
costService: () => mockCostService,
financeService: () => mockFinanceService,
@@ -167,6 +171,13 @@ beforeEach(() => {
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockCompanyService.update.mockResolvedValue({
id: "company-1",
name: "Paperclip",
@@ -108,7 +108,16 @@ const annotationComment = {
function registerModuleMocks() {
vi.doMock("../services/index.js", () => ({
accessService: () => ({ canUser: vi.fn(), hasPermission: vi.fn(async () => false) }),
accessService: () => ({
canUser: vi.fn(),
decide: vi.fn(async (input: { action?: string }) => ({
allowed: true,
action: input.action,
reason: "allow_test",
explanation: "Allowed by test mock.",
})),
hasPermission: vi.fn(async () => false),
}),
agentService: () => ({ getById: vi.fn(), list: vi.fn(async () => []) }),
companyService: () => ({ getById: vi.fn(async () => ({ id: companyId, attachmentMaxBytes: 10_000_000 })) }),
documentAnnotationService: () => mockAnnotationService,
@@ -86,6 +86,23 @@ describe("execution workspace policy helpers", () => {
});
});
it("preserves project authorization policy for trust-preset resolution", () => {
expect(parseProjectExecutionWorkspacePolicy({
enabled: true,
authorizationPolicy: {
trustBoundary: {
mode: "low_trust_review",
projectIds: ["33333333-3333-4333-8333-333333333333"],
},
},
})?.authorizationPolicy).toEqual({
trustBoundary: {
mode: "low_trust_review",
projectIds: ["33333333-3333-4333-8333-333333333333"],
},
});
});
it("clears managed workspace strategy when issue opts out to project primary or agent default", () => {
const baseConfig = {
workspaceStrategy: { type: "git_worktree", branchTemplate: "{{issue.identifier}}" },
@@ -17,9 +17,13 @@ const mockWorkspaceOperationService = vi.hoisted(() => ({
createRecorder: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
workspaceOperationService: () => mockWorkspaceOperationService,
@@ -46,6 +50,12 @@ function createApp(companyIds = ["company-1"]) {
describe.sequential("execution workspace routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockExecutionWorkspaceService.list.mockResolvedValue([]);
mockExecutionWorkspaceService.listSummaries.mockResolvedValue([
{
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { buildSkillMentionHref } from "@paperclipai/shared";
import {
LOW_TRUST_REVIEW_PRESET,
applyRunScopedMentionedSkillKeys,
extractMentionedSkillIdsFromSources,
resolveExecutionRunAdapterConfig,
@@ -189,6 +190,87 @@ describe("resolveExecutionRunAdapterConfig", () => {
expect(result.secretManifest).toEqual([]);
expect(resolveEnvBindings).not.toHaveBeenCalled();
});
it("passes low-trust allowed secret binding ids into all runtime secret contexts", async () => {
const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({
config: { env: {} },
secretKeys: new Set<string>(),
manifest: [],
});
const resolveEnvBindings = vi.fn().mockResolvedValue({
env: {},
secretKeys: new Set<string>(),
manifest: [],
});
await resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
heartbeatRunId: "run-1",
projectId: "project-1",
routineId: "routine-1",
executionRunConfig: { env: {} },
projectEnv: { PROJECT_FLAG: "plain" },
routineEnv: { ROUTINE_FLAG: "plain" },
trustPreset: {
kind: "low_trust_review",
preset: LOW_TRUST_REVIEW_PRESET,
boundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: "company-1",
issueIds: ["issue-1"],
allowedSecretBindingIds: ["binding-1"],
},
sourcePresets: {},
},
secretsSvc: {
resolveAdapterConfigForRuntime,
resolveEnvBindings,
} as any,
});
expect(resolveAdapterConfigForRuntime.mock.calls[0]?.[2]).toMatchObject({
allowedBindingIds: ["binding-1"],
});
expect(resolveEnvBindings.mock.calls[0]?.[2]).toMatchObject({
allowedBindingIds: ["binding-1"],
});
expect(resolveEnvBindings.mock.calls[1]?.[2]).toMatchObject({
allowedBindingIds: ["binding-1"],
});
});
it("rejects inline sensitive env values for low-trust runs", async () => {
await expect(resolveExecutionRunAdapterConfig({
companyId: "company-1",
agentId: "agent-1",
issueId: "issue-1",
executionRunConfig: {
env: {
OPENAI_API_KEY: "inline-secret",
},
},
projectEnv: null,
trustPreset: {
kind: "low_trust_review",
preset: LOW_TRUST_REVIEW_PRESET,
boundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: "company-1",
issueIds: ["issue-1"],
},
sourcePresets: {},
},
secretsSvc: {
resolveAdapterConfigForRuntime: vi.fn(),
resolveEnvBindings: vi.fn(),
} as any,
})).rejects.toMatchObject({
status: 422,
details: { code: "low_trust_inline_sensitive_env_denied" },
});
});
});
describe("extractMentionedSkillIdsFromSources", () => {
@@ -11,14 +11,18 @@ import {
formatRuntimeWorkspaceWarningLog,
mergeExecutionWorkspaceMetadataForPersistence,
mergeCoalescedContextSnapshot,
preflightLowTrustWorkspaceIsolation,
prioritizeProjectWorkspaceCandidatesForRun,
parseSessionCompactionPolicy,
resolveNextSessionState,
resolveWorkspaceAfterLowTrustPreflight,
resolveRuntimeSessionParamsForWorkspace,
stripHostWorkspaceProvisionForLowTrustSandbox,
stripWorkspaceRuntimeFromExecutionRunConfig,
shouldResetTaskSessionForWake,
type ResolvedWorkspaceForRun,
} from "../services/heartbeat.ts";
import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts";
function buildResolvedWorkspace(overrides: Partial<ResolvedWorkspaceForRun> = {}): ResolvedWorkspaceForRun {
return {
@@ -85,6 +89,214 @@ const truncatingHermesSessionCodec = {
},
};
function lowTrustResolution(): TrustPresetResolution {
return {
kind: "low_trust_review",
preset: "low_trust_review",
boundary: {
mode: "low_trust_review",
companyId: "company-1",
rootIssueId: "issue-1",
},
sourcePresets: { agent: "low_trust_review" },
};
}
function standardTrustResolution(): TrustPresetResolution {
return {
kind: "standard",
preset: "standard",
boundary: null,
sourcePresets: {},
};
}
function buildIssueAncestryDb(rows: Array<{ id: string; companyId: string; parentId: string | null }>) {
const queue = [...rows];
return {
select: () => ({
from: () => ({
where: () => {
const row = queue.shift();
return Promise.resolve(row ? [row] : []);
},
}),
}),
};
}
describe("stripHostWorkspaceProvisionForLowTrustSandbox", () => {
it("removes only the host-side provision command for sandbox-backed low-trust runs", () => {
const config = {
workspaceStrategy: {
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
provisionCommand: "bash ./scripts/provision-worktree.sh",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
},
workspaceRuntime: {
services: [{ name: "web" }],
},
};
const result = stripHostWorkspaceProvisionForLowTrustSandbox({
config,
trustPreset: lowTrustResolution(),
selectedEnvironmentDriver: "sandbox",
});
expect(result).not.toBe(config);
expect(result.workspaceStrategy).toEqual({
type: "git_worktree",
branchTemplate: "{{issue.identifier}}-{{slug}}",
teardownCommand: "bash ./scripts/teardown-worktree.sh",
});
expect(result.workspaceRuntime).toBe(config.workspaceRuntime);
expect(config.workspaceStrategy.provisionCommand).toBe("bash ./scripts/provision-worktree.sh");
});
it("preserves provision commands for standard-trust runs", () => {
const config = {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
};
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
config,
trustPreset: standardTrustResolution(),
selectedEnvironmentDriver: "sandbox",
})).toBe(config);
});
it("preserves provision commands when a low-trust run is not sandbox-backed", () => {
const config = {
workspaceStrategy: {
type: "git_worktree",
provisionCommand: "bash ./scripts/provision-worktree.sh",
},
};
expect(stripHostWorkspaceProvisionForLowTrustSandbox({
config,
trustPreset: lowTrustResolution(),
selectedEnvironmentDriver: "local",
})).toBe(config);
});
});
describe("preflightLowTrustWorkspaceIsolation", () => {
it("fails non-sandbox low-trust runs before the caller reaches host workspace side effects", async () => {
let hostWorkspaceSideEffectReached = false;
await expect((async () => {
await preflightLowTrustWorkspaceIsolation({
trustPreset: lowTrustResolution(),
isolatedWorkspacesEnabled: true,
effectiveExecutionWorkspaceMode: "isolated_workspace",
issue: {
companyId: "company-1",
id: "issue-1",
projectId: "project-1",
},
resolveSelectedEnvironmentDriver: async () => "local",
});
hostWorkspaceSideEffectReached = true;
})()).rejects.toMatchObject({
status: 422,
details: expect.objectContaining({
code: "low_trust_requires_sandbox_environment",
}),
});
expect(hostWorkspaceSideEffectReached).toBe(false);
});
it("returns the sandbox driver for sandbox-backed low-trust runs", async () => {
await expect(preflightLowTrustWorkspaceIsolation({
trustPreset: lowTrustResolution(),
isolatedWorkspacesEnabled: true,
effectiveExecutionWorkspaceMode: "isolated_workspace",
issue: {
companyId: "company-1",
id: "issue-1",
projectId: "project-1",
},
resolveSelectedEnvironmentDriver: async () => "sandbox",
})).resolves.toBe("sandbox");
});
it("allows child issues inside a rootIssueId low-trust boundary during workspace preflight", async () => {
await expect(preflightLowTrustWorkspaceIsolation({
db: buildIssueAncestryDb([
{ id: "issue-child", companyId: "company-1", parentId: "issue-1" },
{ id: "issue-1", companyId: "company-1", parentId: null },
]) as any,
trustPreset: lowTrustResolution(),
isolatedWorkspacesEnabled: true,
effectiveExecutionWorkspaceMode: "isolated_workspace",
issue: {
companyId: "company-1",
id: "issue-child",
projectId: null,
},
resolveSelectedEnvironmentDriver: async () => "sandbox",
})).resolves.toBe("sandbox");
});
});
describe("resolveWorkspaceAfterLowTrustPreflight", () => {
it("fails non-sandbox low-trust runs before resolving workspaces", async () => {
let workspaceResolverReached = false;
await expect(resolveWorkspaceAfterLowTrustPreflight({
trustPreset: lowTrustResolution(),
isolatedWorkspacesEnabled: true,
effectiveExecutionWorkspaceMode: "isolated_workspace",
issue: {
companyId: "company-1",
id: "issue-1",
projectId: "project-1",
},
resolveSelectedEnvironmentDriver: async () => "local",
resolveWorkspace: async () => {
workspaceResolverReached = true;
return buildResolvedWorkspace();
},
})).rejects.toMatchObject({
status: 422,
details: expect.objectContaining({
code: "low_trust_requires_sandbox_environment",
}),
});
expect(workspaceResolverReached).toBe(false);
});
it("preserves standard-trust workspace resolution", async () => {
const workspace = buildResolvedWorkspace({ cwd: "/tmp/standard-workspace" });
await expect(resolveWorkspaceAfterLowTrustPreflight({
trustPreset: standardTrustResolution(),
isolatedWorkspacesEnabled: false,
effectiveExecutionWorkspaceMode: "shared_workspace",
issue: {
companyId: "company-1",
id: "issue-1",
projectId: "project-1",
},
resolveSelectedEnvironmentDriver: async () => {
throw new Error("standard trust should not inspect the environment driver");
},
resolveWorkspace: async () => workspace,
})).resolves.toEqual({
selectedEnvironmentDriver: null,
workspace,
});
});
});
describe("resolveRuntimeSessionParamsForWorkspace", () => {
it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => {
const agentId = "agent-123";
@@ -20,6 +20,7 @@ const mockIssueService = vi.hoisted(() => ({
getById: vi.fn(),
getRelationSummaries: vi.fn(),
getWakeableParentAfterChildCompletion: vi.fn(),
list: vi.fn(),
listAttachments: vi.fn(),
listWakeableBlockedDependents: vi.fn(),
remove: vi.fn(),
@@ -68,6 +69,7 @@ const mockIssueThreadInteractionService = vi.hoisted(() => ({
}));
const mockIssueRecoveryActionService = vi.hoisted(() => ({
getActiveForIssue: vi.fn(async () => null),
listActiveForIssues: vi.fn(async () => new Map()),
resolveActiveForIssue: vi.fn(async () => null),
}));
const mockHeartbeatService = vi.hoisted(() => ({
@@ -114,8 +116,11 @@ function registerRouteMocks() {
}));
vi.doMock("../services/index.js", () => ({
ISSUE_LIST_DEFAULT_LIMIT: 100,
ISSUE_LIST_MAX_LIMIT: 500,
accessService: () => mockAccessService,
agentService: () => mockAgentService,
clampIssueListLimit: (value: number) => Math.min(Math.max(value, 1), 500),
companyService: () => mockCompanyService,
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => mockDocumentService,
@@ -194,26 +199,47 @@ function makeAgent(id: string, overrides: Record<string, unknown> = {}) {
};
}
function createRunContextDb(contextSnapshot: Record<string, unknown> = {}) {
function createRunContextDb(
contextSnapshot: Record<string, unknown> = {},
runAgentId: string = ownerAgentId,
runId: string = ownerRunId,
) {
return {
transaction: async (callback: (tx: Record<string, never>) => Promise<unknown>) => callback({}),
select: vi.fn(() => ({
select: vi.fn((selection: Record<string, unknown> = {}) => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
then: async (resolve: (rows: unknown[]) => unknown) =>
resolve([{
id: ownerRunId,
companyId,
agentId: ownerAgentId,
contextSnapshot,
}]),
orderBy: vi.fn(async () => []),
then: async (resolve: (rows: unknown[]) => unknown) => {
const keys = Object.keys(selection);
if (keys.includes("entityId")) {
return resolve([]);
}
if (keys.includes("contextSnapshot")) {
return resolve([{
id: runId,
companyId,
agentId: runAgentId,
contextSnapshot,
}]);
}
if (keys.includes("permissions")) {
return resolve([{ id: runAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]);
}
return resolve([{ id: runAgentId, companyId, permissions: {}, role: "engineer", reportsTo: null }]);
},
})),
})),
})),
};
}
async function createApp(actor: Record<string, unknown>, db: unknown = createRunContextDb()) {
async function createApp(actor: Record<string, unknown>, db?: unknown) {
const routeDb = db ?? createRunContextDb(
{},
typeof actor.agentId === "string" ? actor.agentId : ownerAgentId,
typeof actor.runId === "string" ? actor.runId : ownerRunId,
);
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
@@ -224,7 +250,7 @@ async function createApp(actor: Record<string, unknown>, db: unknown = createRun
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes(db as any, mockStorageService as any));
app.use("/api", issueRoutes(routeDb as any, mockStorageService as any));
app.use(errorHandler);
return app;
}
@@ -280,10 +306,26 @@ describe("agent issue mutation checkout ownership", () => {
mockAccessService.canUser.mockReset();
mockAccessService.decide.mockReset();
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:assign",
allowed:
input.action === "tasks:assign" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read",
action: input.action,
reason: input.action === "tasks:assign" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:assign" ? "Allowed by test assignment default." : "Missing permission.",
reason:
input.action === "tasks:assign" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation:
input.action === "tasks:assign" ||
input.action === "issue:read" ||
input.action === "issue:mutate" ||
input.action === "company_scope:read"
? "Allowed by test default."
: "Missing permission.",
}));
mockAccessService.hasPermission.mockReset();
mockAgentService.getById.mockReset();
@@ -299,10 +341,13 @@ describe("agent issue mutation checkout ownership", () => {
mockIssueService.getById.mockReset();
mockIssueService.getRelationSummaries.mockReset();
mockIssueService.getWakeableParentAfterChildCompletion.mockReset();
mockIssueService.list.mockReset();
mockIssueService.listAttachments.mockReset();
mockIssueService.listWakeableBlockedDependents.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockReset();
mockIssueRecoveryActionService.getActiveForIssue.mockResolvedValue(null);
mockIssueRecoveryActionService.listActiveForIssues.mockReset();
mockIssueRecoveryActionService.listActiveForIssues.mockResolvedValue(new Map());
mockIssueRecoveryActionService.resolveActiveForIssue.mockReset();
mockIssueRecoveryActionService.resolveActiveForIssue.mockResolvedValue({
id: recoveryActionId,
@@ -370,6 +415,7 @@ describe("agent issue mutation checkout ownership", () => {
mockCompanyService.getById.mockResolvedValue({ id: companyId, issuePrefix: "PAP" });
mockIssueService.getById.mockResolvedValue(makeIssue());
mockIssueService.getByIdentifier.mockResolvedValue(null);
mockIssueService.list.mockResolvedValue([makeIssue()]);
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
...makeIssue({
@@ -476,6 +522,41 @@ describe("agent issue mutation checkout ownership", () => {
mockStorageService.deleteObject.mockResolvedValue(undefined);
});
it("uses the company-scope fast path on the issue list route", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => {
if (input.action === "company_scope:read") {
return {
allowed: true,
action: input.action,
reason: "allow_explicit_grant",
explanation: "Allowed by test company scope.",
};
}
if (input.action === "issue:read") {
throw new Error("issue:read should not be evaluated for company-scope readers");
}
return {
allowed: true,
action: input.action,
reason: "allow_test_default",
explanation: "Allowed by test default.",
};
});
const app = await createApp(boardActor());
const res = await request(app).get(`/api/companies/${companyId}/issues`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([expect.objectContaining({ id: issueId })]);
expect(mockAccessService.decide).toHaveBeenCalledWith(expect.objectContaining({
action: "company_scope:read",
resource: { type: "company", companyId },
}));
expect(mockAccessService.decide).not.toHaveBeenCalledWith(expect.objectContaining({
action: "issue:read",
}));
});
it.each([
["patch", (app: express.Express) => request(app).patch(`/api/issues/${issueId}`).send({ title: "Blocked" })],
["delete", (app: express.Express) => request(app).delete(`/api/issues/${issueId}`)],
@@ -486,6 +567,16 @@ describe("agent issue mutation checkout ownership", () => {
request(app).put(`/api/issues/${issueId}/documents/plan`).send({ format: "markdown", body: "# blocked" }),
],
["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })],
[
"low-trust promotion",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({
sourceArtifactKind: "comment",
sourceArtifactId: recoveryActionId,
title: "Promoted artifact",
summary: "Sanitized output",
}),
],
[
"attachment upload",
(app: express.Express) =>
@@ -503,6 +594,7 @@ describe("agent issue mutation checkout ownership", () => {
expect(mockIssueService.update).not.toHaveBeenCalled();
expect(mockIssueService.addComment).not.toHaveBeenCalled();
expect(mockDocumentService.upsertIssueDocument).not.toHaveBeenCalled();
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
expect(mockWorkProductService.update).not.toHaveBeenCalled();
expect(mockStorageService.putFile).not.toHaveBeenCalled();
expect(mockStorageService.deleteObject).not.toHaveBeenCalled();
@@ -542,6 +634,16 @@ describe("agent issue mutation checkout ownership", () => {
],
["work product update", (app: express.Express) => request(app).patch("/api/work-products/product-1").send({ title: "Blocked" })],
["work product delete", (app: express.Express) => request(app).delete("/api/work-products/product-1")],
[
"low-trust promotion",
(app: express.Express) =>
request(app).post(`/api/issues/${issueId}/low-trust/promotions`).send({
sourceArtifactKind: "comment",
sourceArtifactId: recoveryActionId,
title: "Promoted artifact",
summary: "Sanitized output",
}),
],
[
"attachment upload",
(app: express.Express) =>
@@ -693,10 +795,18 @@ describe("agent issue mutation checkout ownership", () => {
it("allows agents with the active-checkout management grant to mutate active checkouts", async () => {
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "tasks:manage_active_checkouts",
allowed: input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts",
action: input.action,
reason: input.action === "tasks:manage_active_checkouts" ? "allow_explicit_grant" : "deny_missing_grant",
explanation: input.action === "tasks:manage_active_checkouts" ? "Allowed by checkout management grant." : "Missing permission.",
reason:
input.action === "issue:mutate" || input.action === "tasks:manage_active_checkouts"
? "allow_explicit_grant"
: "deny_missing_grant",
explanation:
input.action === "tasks:manage_active_checkouts"
? "Allowed by checkout management grant."
: input.action === "issue:mutate"
? "Allowed by test boundary default."
: "Missing permission.",
}));
const res = await request(await createApp(peerActor())).patch(`/api/issues/${issueId}`).send({ title: "Managed update" });
@@ -846,6 +956,15 @@ describe("agent issue mutation checkout ownership", () => {
reason: "deny_policy_restricted",
explanation: "Target agent requires approval before task assignment.",
}));
decide.mockImplementation(async (input: { action: string }) => ({
allowed: input.action === "issue:mutate",
action: input.action,
reason: input.action === "issue:mutate" ? "allow_self" : "deny_policy_restricted",
explanation:
input.action === "issue:mutate"
? "Allowed because the actor owns the assigned issue."
: "Target agent requires approval before task assignment.",
}));
(mockAccessService as any).decide = decide;
mockIssueService.getById.mockResolvedValue(makeIssue({ assigneeAgentId: ownerAgentId }));
mockAgentService.resolveByReference.mockResolvedValue({
@@ -41,7 +41,11 @@ const mockTx = vi.hoisted(() => ({
insert: mockTxInsert,
}));
const mockDbSelectOrderBy = vi.hoisted(() => vi.fn(async () => []));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({ orderBy: mockDbSelectOrderBy })));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
orderBy: mockDbSelectOrderBy,
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([]).then(onFulfilled, onRejected),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
@@ -259,7 +263,11 @@ describe.sequential("issue comment reopen routes", () => {
mockTxInsertValues.mockResolvedValue(undefined);
mockTxInsert.mockImplementation(() => ({ values: mockTxInsertValues }));
mockDbSelectOrderBy.mockResolvedValue([]);
mockDbSelectWhere.mockImplementation(() => ({ orderBy: mockDbSelectOrderBy }));
mockDbSelectWhere.mockImplementation(() => ({
orderBy: mockDbSelectOrderBy,
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([]).then(onFulfilled, onRejected),
}));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDb.transaction.mockImplementation(async (fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx));
@@ -744,6 +752,7 @@ describe.sequential("issue comment reopen routes", () => {
authorType: "user",
presentation: { kind: "system_notice", tone: "warning", detailsDefaultOpen: false },
metadata,
sourceTrust: null,
},
);
});
@@ -17,6 +17,7 @@ const mockDocumentsService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -208,6 +209,12 @@ describe("issue document revision routes", () => {
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "issue:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockIssueService.getById.mockResolvedValue({
id: issueId,
companyId,
@@ -29,6 +29,20 @@ const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
hasPermission: vi.fn(async () => false),
}));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: null,
permissions: null,
}]).then(onFulfilled, onRejected),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockIssueThreadInteractionService = vi.hoisted(() => ({
@@ -46,7 +60,11 @@ function registerModuleMocks() {
}),
accessService: () => mockAccessService,
agentService: () => ({
getById: vi.fn(async () => null),
getById: vi.fn(async (agentId: string) => ({
id: agentId,
companyId: "company-1",
permissions: null,
})),
}),
documentAnnotationService: () => ({ remapOpenThreadsForDocument: async () => [] }),
documentService: () => ({}),
@@ -131,7 +149,7 @@ async function createApp(actor?: TestActor) {
};
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -152,6 +170,17 @@ describe("issue execution policy routes", () => {
mockIssueThreadInteractionService.listForIssue.mockResolvedValue([]);
mockIssueThreadInteractionService.expireRequestConfirmationsSupersededByComment.mockResolvedValue([]);
mockIssueApprovalService.listApprovalsForIssue.mockResolvedValue([]);
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{
companyId: "company-1",
agentId: "33333333-3333-4333-8333-333333333333",
contextSnapshot: null,
permissions: null,
}]).then(onFulfilled, onRejected),
}));
mockIssueService.createChild.mockResolvedValue({
issue: {
id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
@@ -165,7 +194,14 @@ describe("issue execution policy routes", () => {
mockAccessService.decide.mockImplementation(async (input: { actor?: { type?: string; source?: string }; action?: string }) => {
const allowed = input.actor?.type === "board" && input.actor.source === "local_implicit"
? true
: Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
: input.actor?.type === "agent" && [
"company_scope:read",
"issue:read",
"issue:mutate",
"runtime:manage",
].includes(input.action ?? "")
? true
: Boolean(await mockAccessService.canUser() || await mockAccessService.hasPermission());
return {
allowed,
action: input.action,
@@ -72,14 +72,14 @@ describeEmbeddedPostgres("issue scheduled retry routes", () => {
return app;
}
function boardActor(companyId: string): Express.Request["actor"] {
function boardActor(companyId: string, source: "session" | "local_implicit" = "session"): Express.Request["actor"] {
return {
type: "board",
userId: "board-user",
companyIds: [companyId],
memberships: [{ companyId, membershipRole: "admin", status: "active" }],
isInstanceAdmin: false,
source: "session",
source,
};
}
@@ -208,7 +208,7 @@ describeEmbeddedPostgres("issue scheduled retry routes", () => {
it("surfaces the current scheduled retry in the issue read model", async () => {
const { companyId, issueId, agentId, sourceRunId, retryRunId, scheduledRetryAt } = await seedIssueWithRetry();
const res = await request(createApp(boardActor(companyId))).get(`/api/issues/${issueId}`);
const res = await request(createApp(boardActor(companyId, "local_implicit"))).get(`/api/issues/${issueId}`);
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body.scheduledRetry).toMatchObject({
@@ -15,6 +15,15 @@ const mockAgentService = vi.hoisted(() => ({
const mockTrackAgentTaskCompleted = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", permissions: null }]).then(onFulfilled, onRejected),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
}));
function registerModuleMocks() {
vi.doMock("@paperclipai/shared/telemetry", () => ({
@@ -32,6 +41,12 @@ function registerModuleMocks() {
}),
accessService: () => ({
canUser: vi.fn(),
decide: vi.fn(async () => ({
allowed: true,
action: "issue:mutate",
reason: "allow_test",
explanation: "Allowed by test mock.",
})),
hasPermission: vi.fn(),
}),
agentService: () => mockAgentService,
@@ -102,7 +117,7 @@ async function createApp(actor: Record<string, unknown>) {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -126,6 +141,12 @@ describe("issue telemetry routes", () => {
...makeIssue("todo"),
...patch,
}));
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", permissions: null }]).then(onFulfilled, onRejected),
}));
});
it("emits task-completed telemetry with the agent role, adapter type, and model", async () => {
@@ -26,6 +26,18 @@ const mockHeartbeatService = vi.hoisted(() => ({
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: CREATED_AGENT_ID, contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
}));
vi.mock("@paperclipai/shared/telemetry", () => ({
trackAgentTaskCompleted: vi.fn(),
@@ -52,7 +64,7 @@ function registerModuleMocks() {
hasPermission: vi.fn(async () => true),
}),
agentService: () => ({
getById: vi.fn(async () => null),
getById: vi.fn(async () => ({ id: CREATED_AGENT_ID, companyId: "company-1", permissions: null })),
resolveByReference: vi.fn(async (_companyId: string, raw: string) => ({
ambiguous: false,
agent: { id: raw },
@@ -148,7 +160,7 @@ async function createApp(actor: Record<string, unknown> = {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -294,6 +306,15 @@ describe.sequential("issue thread interaction routes", () => {
updatedAt: "2026-04-20T12:05:00.000Z",
resolvedAt: "2026-04-20T12:05:00.000Z",
});
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: CREATED_AGENT_ID, contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
}));
});
it("lists and creates board-authored interactions", async () => {
@@ -17,6 +17,7 @@ const mockIssueService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -52,6 +53,19 @@ const mockRoutineService = vi.hoisted(() => ({
syncRunStatusForIssue: vi.fn(),
}));
const mockDbSelectWhere = vi.hoisted(() => vi.fn(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: "agent-1", contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
})));
const mockDbSelectFrom = vi.hoisted(() => vi.fn(() => ({ where: mockDbSelectWhere })));
const mockDbSelect = vi.hoisted(() => vi.fn(() => ({ from: mockDbSelectFrom })));
const mockDb = vi.hoisted(() => ({
select: mockDbSelect,
}));
function registerRouteMocks() {
vi.doMock("../services/access.js", () => ({
accessService: () => mockAccessService,
@@ -144,7 +158,7 @@ async function createApp(actor: Record<string, unknown>) {
(req as any).actor = actor;
next();
});
app.use("/api", issueRoutes({} as any, {} as any));
app.use("/api", issueRoutes(mockDb as any, {} as any));
app.use(errorHandler);
return app;
}
@@ -200,8 +214,18 @@ describe("issue workspace command authorization", () => {
mockIssueService.assertCheckoutOwner.mockResolvedValue({ adoptedFromRunId: null });
mockIssueService.update.mockResolvedValue(makeIssue());
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockAccessService.hasPermission.mockResolvedValue(true);
mockAgentService.getById.mockResolvedValue(null);
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
permissions: null,
});
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockFeedbackService.listIssueVotesForUser.mockResolvedValue([]);
mockFeedbackService.saveIssueVote.mockResolvedValue({
@@ -224,6 +248,15 @@ describe("issue workspace command authorization", () => {
mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1"]);
mockLogActivity.mockResolvedValue(undefined);
mockRoutineService.syncRunStatusForIssue.mockResolvedValue(undefined);
mockDbSelect.mockImplementation(() => ({ from: mockDbSelectFrom }));
mockDbSelectFrom.mockImplementation(() => ({ where: mockDbSelectWhere }));
mockDbSelectWhere.mockImplementation(() => ({
then: (onFulfilled: (rows: unknown[]) => unknown, onRejected?: (reason: unknown) => unknown) =>
Promise.resolve([{ companyId: "company-1", agentId: "agent-1", contextSnapshot: null }]).then(
onFulfilled,
onRejected,
),
}));
});
it("rejects agent callers that create issue workspace provision commands", async () => {
@@ -38,6 +38,7 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
decide: vi.fn(),
hasPermission: vi.fn(),
}));
@@ -187,6 +188,12 @@ const projectGoal = {
describe.sequential("issue goal context routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "issue:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockIssueService.getById.mockResolvedValue(legacyProjectLinkedIssue);
mockIssueService.getAncestors.mockResolvedValue([]);
mockIssueService.getRelationSummaries.mockResolvedValue({ blockedBy: [], blocks: [] });
File diff suppressed because it is too large. Load diff
@@ -18,6 +18,9 @@ const mockGoalService = vi.hoisted(() => ({
remove: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockSecretService = vi.hoisted(() => ({
normalizeEnvBindingsForPersistence: vi.fn(),
@@ -34,6 +37,7 @@ vi.mock("../telemetry.js", () => ({
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
goalService: () => mockGoalService,
logActivity: mockLogActivity,
@@ -53,6 +57,7 @@ function registerModuleMocks() {
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
goalService: () => mockGoalService,
logActivity: mockLogActivity,
@@ -110,6 +115,12 @@ describe("project and goal telemetry routes", () => {
vi.doUnmock("../middleware/index.js");
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "project:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockGetTelemetryClient.mockReturnValue({ track: mockTelemetryTrack });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockEnvironmentService.getById.mockReset();
@@ -23,12 +23,16 @@ const mockEnvironmentService = vi.hoisted(() => ({
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
vi.mock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
@@ -55,6 +59,7 @@ function registerModuleMocks() {
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
logActivity: mockLogActivity,
projectService: () => mockProjectService,
@@ -146,6 +151,12 @@ describe("project env routes", () => {
vi.doUnmock("../services/secrets.js");
registerModuleMocks();
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "project:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockGetTelemetryClient.mockReturnValue({ track: vi.fn() });
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
mockProjectService.createWorkspace.mockResolvedValue(null);
@@ -205,6 +205,46 @@ describeEmbeddedPostgres("secretService", () => {
expect(JSON.stringify(events)).not.toContain("runtime-secret");
});
it("denies runtime secret resolution outside the low-trust binding allowlist", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
const secret = await svc.create(companyId, {
name: `low-trust-${randomUUID()}`,
provider: "local_encrypted",
value: "runtime-secret",
});
const env = {
API_KEY: { type: "secret_ref" as const, secretId: secret.id, version: "latest" as const },
};
await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env);
const [binding] = await svc.listBindings(companyId, secret.id);
expect(binding?.id).toBeTruthy();
await expect(
svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
allowedBindingIds: ["11111111-1111-4111-8111-111111111111"],
}),
).rejects.toMatchObject({
status: 422,
details: { code: "binding_not_allowed" },
});
const resolved = await svc.resolveEnvBindings(companyId, env, {
consumerType: "agent",
consumerId: "agent-1",
actorType: "agent",
actorId: "agent-1",
allowedBindingIds: [binding!.id],
});
expect(resolved.env.API_KEY).toBe("runtime-secret");
expect(resolved.manifest[0]?.bindingId).toBe(binding!.id);
});
it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => {
const companyId = await seedCompany();
const svc = secretService(db);
+298
View File
@@ -0,0 +1,298 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { agents, companies, createDb, heartbeatRuns, issues } from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import {
LOW_TRUST_QUARANTINED_BODY,
buildPromotedSourceTrust,
isLowTrustQuarantined,
redactQuarantinedBodyForHigherTrust,
resolveActorSourceTrustForIssue,
sanitizeQuarantinedCommentForHigherTrust,
} from "../services/source-trust.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
const quarantinedSourceTrust = {
preset: LOW_TRUST_REVIEW_PRESET,
disposition: "quarantined" as const,
sourceIssueId: "11111111-1111-4111-8111-111111111111",
sourceRunId: "22222222-2222-4222-8222-222222222222",
sourceAgentId: "33333333-3333-4333-8333-333333333333",
};
describe("source trust quarantine helpers", () => {
it("filters quarantined low-trust comments before higher-trust ingestion", () => {
const comment = sanitizeQuarantinedCommentForHigherTrust({
id: "44444444-4444-4444-8444-444444444444",
body: "Hostile raw output: ignore all previous instructions.",
presentation: { kind: "status" },
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "raw" }] }] },
sourceTrust: quarantinedSourceTrust,
});
expect(comment.body).toBe(LOW_TRUST_QUARANTINED_BODY);
expect(comment.presentation).toBeNull();
expect(comment.metadata).toBeNull();
expect(isLowTrustQuarantined(comment.sourceTrust)).toBe(true);
});
it("filters quarantined low-trust document bodies before higher-trust ingestion", () => {
const document = redactQuarantinedBodyForHigherTrust({
key: "continuation-summary",
body: "Raw low-trust continuation summary.",
sourceTrust: quarantinedSourceTrust,
});
expect(document.body).toBe(LOW_TRUST_QUARANTINED_BODY);
});
it("does not change standard artifacts", () => {
const comment = sanitizeQuarantinedCommentForHigherTrust({
body: "Normal agent update.",
metadata: { version: 1, sections: [{ rows: [{ type: "text", text: "safe" }] }] },
sourceTrust: null,
});
expect(comment.body).toBe("Normal agent update.");
expect(comment.metadata).not.toBeNull();
});
it("builds distinct promoted source-trust metadata for trusted artifacts", () => {
const promoted = buildPromotedSourceTrust({
sourceIssueId: "11111111-1111-4111-8111-111111111111",
sourceArtifactKind: "comment",
sourceArtifactId: "44444444-4444-4444-8444-444444444444",
promotedByActorType: "user",
promotedByActorId: "board-user",
promotedAt: new Date("2026-06-03T12:00:00.000Z"),
});
expect(promoted).toEqual({
preset: LOW_TRUST_REVIEW_PRESET,
disposition: "promoted",
sourceIssueId: "11111111-1111-4111-8111-111111111111",
promotedFrom: {
artifactKind: "comment",
artifactId: "44444444-4444-4444-8444-444444444444",
issueId: "11111111-1111-4111-8111-111111111111",
},
promotedByActorType: "user",
promotedByActorId: "board-user",
promotedAt: "2026-06-03T12:00:00.000Z",
});
expect(isLowTrustQuarantined(promoted)).toBe(false);
});
});
describeEmbeddedPostgres("resolveActorSourceTrustForIssue", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-source-trust-");
db = createDb(tempDb.connectionString);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createCompany() {
return db
.insert(companies)
.values({
name: `Source trust ${randomUUID()}`,
issuePrefix: `ST${randomUUID().slice(0, 6).toUpperCase()}`,
})
.returning()
.then((rows) => rows[0]!);
}
async function createAgent(companyId: string, permissions: Record<string, unknown> = {}) {
return db
.insert(agents)
.values({
companyId,
name: `Agent ${randomUUID()}`,
role: "engineer",
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
permissions,
})
.returning()
.then((rows) => rows[0]!);
}
function lowTrustExecutionPolicy(companyId: string, rootIssueId: string) {
return {
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
rootIssueId,
},
},
};
}
it("uses the heartbeat run execution-policy snapshot after live issue policy changes", async () => {
const company = await createCompany();
const agent = await createAgent(company.id);
const [issue] = await db
.insert(issues)
.values({
companyId: company.id,
title: "Run-scoped low-trust issue",
status: "in_progress",
priority: "high",
assigneeAgentId: agent.id,
})
.returning();
const executionPolicy = lowTrustExecutionPolicy(company.id, issue!.id);
const [run] = await db
.insert(heartbeatRuns)
.values({
companyId: company.id,
agentId: agent.id,
status: "running",
contextSnapshot: {
issueId: issue!.id,
executionPolicy,
},
})
.returning();
await db.update(issues).set({ executionPolicy: null }).where(eq(issues.id, issue!.id));
const sourceTrust = await resolveActorSourceTrustForIssue({
db,
issue: {
id: issue!.id,
companyId: company.id,
projectId: null,
executionPolicy: null,
},
actor: {
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
runId: run!.id,
},
});
expect(sourceTrust).toMatchObject({
preset: LOW_TRUST_REVIEW_PRESET,
disposition: "quarantined",
sourceIssueId: issue!.id,
sourceRunId: run!.id,
sourceAgentId: agent.id,
});
});
it("fails closed when the supplied run id does not belong to the acting agent", async () => {
const company = await createCompany();
const actorAgent = await createAgent(company.id);
const runOwnerAgent = await createAgent(company.id);
const [issue] = await db
.insert(issues)
.values({
companyId: company.id,
title: "Standard issue",
status: "in_progress",
priority: "high",
assigneeAgentId: actorAgent.id,
})
.returning();
const [run] = await db
.insert(heartbeatRuns)
.values({
companyId: company.id,
agentId: runOwnerAgent.id,
status: "running",
contextSnapshot: { issueId: issue!.id },
})
.returning();
const sourceTrust = await resolveActorSourceTrustForIssue({
db,
issue: {
id: issue!.id,
companyId: company.id,
projectId: null,
executionPolicy: null,
},
actor: {
actorType: "agent",
actorId: actorAgent.id,
agentId: actorAgent.id,
runId: run!.id,
},
});
expect(sourceTrust).toMatchObject({
preset: LOW_TRUST_REVIEW_PRESET,
disposition: "quarantined",
sourceIssueId: issue!.id,
sourceRunId: run!.id,
sourceAgentId: actorAgent.id,
});
});
it("surfaces denied trust policy resolution instead of treating it as higher trust", async () => {
const company = await createCompany();
const agent = await createAgent(company.id, {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: randomUUID(),
projectIds: [randomUUID()],
},
},
});
const [issue] = await db
.insert(issues)
.values({
companyId: company.id,
title: "Denied trust policy issue",
status: "in_progress",
priority: "high",
assigneeAgentId: agent.id,
})
.returning();
await expect(resolveActorSourceTrustForIssue({
db,
issue: {
id: issue!.id,
companyId: company.id,
projectId: null,
executionPolicy: null,
},
actor: {
actorType: "agent",
actorId: agent.id,
agentId: agent.id,
runId: null,
},
})).rejects.toMatchObject({
status: 403,
message: "Low-trust boundary refers to a different company.",
});
});
});
@@ -0,0 +1,222 @@
import { describe, expect, it } from "vitest";
import {
agentPermissionsSchema,
type LowTrustBoundary,
LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
LOW_TRUST_REVIEW_PRESET,
} from "@paperclipai/shared";
import { normalizeIssueExecutionPolicy } from "../services/issue-execution-policy.js";
import {
isIssueWithinLowTrustBoundary,
resolveCoreTrustPreset,
} from "../services/trust-preset-resolver.js";
const companyId = "11111111-1111-4111-8111-111111111111";
const otherCompanyId = "22222222-2222-4222-8222-222222222222";
const projectA = "33333333-3333-4333-8333-333333333333";
const projectB = "44444444-4444-4444-8444-444444444444";
const projectC = "55555555-5555-4555-8555-555555555555";
const rootIssueId = "66666666-6666-4666-8666-666666666666";
const issueA = "77777777-7777-4777-8777-777777777777";
const issueB = "88888888-8888-4888-8888-888888888888";
const issueC = "99999999-9999-4999-8999-999999999999";
const agentA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const agentB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function lowTrustBoundary(input: Partial<Omit<LowTrustBoundary, "mode">>): LowTrustBoundary {
return {
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
...input,
};
}
function boundaryPolicy(boundary: ReturnType<typeof lowTrustBoundary>) {
return {
authorizationPolicy: {
trustBoundary: boundary,
},
};
}
describe("resolveCoreTrustPreset", () => {
it("defaults to standard with no boundary", () => {
const result = resolveCoreTrustPreset({
companyId,
agent: { companyId, permissions: { canCreateAgents: false } },
});
expect(result).toMatchObject({
kind: "standard",
preset: "standard",
boundary: null,
});
});
it("intersects low-trust agent, project, and issue policy boundaries", () => {
const result = resolveCoreTrustPreset({
companyId,
agent: {
companyId,
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
managedBy: "core-trust-preset",
trustBoundary: lowTrustBoundary({
projectIds: [projectA, projectB],
rootIssueId,
issueIds: [issueA, issueB],
allowedAgentIds: [agentA, agentB],
allowedToolClasses: ["git.read", "tests.local"],
}),
},
},
},
project: {
companyId,
executionWorkspacePolicy: boundaryPolicy(lowTrustBoundary({
projectIds: [projectB, projectC],
issueIds: [issueB, issueC],
allowedAgentIds: [agentB],
allowedToolClasses: ["git.read"],
})),
},
issue: {
companyId,
executionPolicy: {
authorizationPolicy: {
trustBoundary: lowTrustBoundary({
issueIds: [issueB],
allowedToolClasses: ["git.read", "github.pr.read"],
}),
},
},
},
});
expect(result.kind).toBe("low_trust_review");
if (result.kind !== "low_trust_review") throw new Error("expected low-trust result");
expect(result.boundary).toMatchObject({
companyId,
mode: LOW_TRUST_REVIEW_PRESET,
rootIssueId,
projectIds: [projectB],
issueIds: [issueB],
allowedAgentIds: [agentB],
allowedToolClasses: ["git.read"],
});
expect(isIssueWithinLowTrustBoundary(result.boundary, { companyId, id: issueB, projectId: projectB })).toBe(true);
expect(isIssueWithinLowTrustBoundary(result.boundary, { companyId, id: issueC, projectId: projectC })).toBe(false);
});
it("fails closed for unknown presets", () => {
const result = resolveCoreTrustPreset({
companyId,
agent: {
companyId,
permissions: {
trustPreset: "trusted_but_weird",
},
},
});
expect(result).toMatchObject({
kind: "denied",
reason: "unsupported_trust_preset",
source: "agent",
});
});
it("fails closed when low-trust has no concrete project or issue scope", () => {
const result = resolveCoreTrustPreset({
companyId,
agent: {
companyId,
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: lowTrustBoundary({ allowedToolClasses: ["git.read"] }),
},
},
},
});
expect(result).toMatchObject({
kind: "denied",
reason: "missing_low_trust_boundary_scope",
});
});
it("denies cross-company policy sources and boundaries", () => {
const sourceMismatch = resolveCoreTrustPreset({
companyId,
project: {
companyId: otherCompanyId,
executionWorkspacePolicy: boundaryPolicy(lowTrustBoundary({ projectIds: [projectA] })),
},
});
expect(sourceMismatch).toMatchObject({
kind: "denied",
reason: "cross_company_boundary",
source: "project",
});
const boundaryMismatch = resolveCoreTrustPreset({
companyId,
issue: {
companyId,
executionPolicy: {
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId: otherCompanyId,
rootIssueId,
},
},
},
},
});
expect(boundaryMismatch).toMatchObject({
kind: "denied",
reason: "cross_company_boundary",
source: "issue",
});
});
it("normalizes and preserves trust policy JSON alongside existing policy data", () => {
const permissions = agentPermissionsSchema.parse({
canCreateAgents: false,
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
managedBy: "ee-permissions",
customEeField: { mode: "visualized" },
trustBoundary: lowTrustBoundary({ rootIssueId }),
},
});
expect(permissions.authorizationPolicy?.customEeField).toEqual({ mode: "visualized" });
const executionPolicy = normalizeIssueExecutionPolicy({
reviewPreset: {
id: LOW_TRUST_REVIEW_PRESET,
version: 1,
rawOutputDisposition: LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
},
authorizationPolicy: {
managedBy: "core-trust-preset",
trustBoundary: lowTrustBoundary({ rootIssueId }),
},
});
expect(executionPolicy).toMatchObject({
stages: [],
reviewPreset: {
id: LOW_TRUST_REVIEW_PRESET,
rawOutputDisposition: LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION,
},
authorizationPolicy: {
managedBy: "core-trust-preset",
trustBoundary: { rootIssueId },
},
});
});
});
@@ -28,6 +28,9 @@ const mockEnvironmentService = vi.hoisted(() => ({
const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockLogActivity = vi.hoisted(() => vi.fn());
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
const mockAccessService = vi.hoisted(() => ({
decide: vi.fn(),
}));
const mockAssertCanManageProjectWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
const mockAssertCanManageExecutionWorkspaceRuntimeServices = vi.hoisted(() => vi.fn());
@@ -36,6 +39,7 @@ vi.mock("../telemetry.js", () => ({
}));
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
@@ -62,6 +66,7 @@ function registerWorkspaceRouteMocks() {
}));
vi.doMock("../services/index.js", () => ({
accessService: () => mockAccessService,
environmentService: () => mockEnvironmentService,
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
@@ -192,6 +197,12 @@ describe.sequential("workspace runtime service route authorization", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAccessService.decide.mockResolvedValue({
allowed: true,
action: "company_scope:read",
reason: "allow_test",
explanation: "Allowed by test mock.",
});
mockEnvironmentService.getById.mockResolvedValue(null);
mockSecretService.normalizeEnvBindingsForPersistence.mockImplementation(async (_companyId, env) => env);
mockProjectService.resolveByReference.mockResolvedValue({ ambiguous: false, project: null });
@@ -1,14 +1,17 @@
import { randomUUID } from "node:crypto";
import { eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
executionWorkspaces,
heartbeatRuns,
issues,
projectWorkspaces,
projects,
} from "@paperclipai/db";
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
@@ -41,6 +44,7 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => {
await db.delete(executionWorkspaces);
await db.delete(projectWorkspaces);
await db.delete(projects);
await db.delete(heartbeatRuns);
await db.delete(agents);
await db.delete(companies);
});
@@ -149,6 +153,144 @@ describeEmbeddedPostgres("workspace runtime service authz helper", () => {
})).resolves.toBeUndefined();
});
it("rejects low-trust CEO runtime service mutations unless runtime.manage is granted", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
await db
.update(agents)
.set({
permissions: {
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
projectIds: [projectId],
},
},
},
})
.where(eq(agents.id, ceoAgentId));
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Low-trust workspace",
status: "todo",
priority: "medium",
assigneeAgentId: ceoAgentId,
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: ceoAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage",
});
});
it("allows standard CEO runtime service mutations for low-trust workspace issues", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
await db.insert(issues).values({
id: randomUUID(),
companyId,
projectId,
projectWorkspaceId,
title: "Issue-scoped low-trust workspace",
status: "todo",
priority: "medium",
assigneeAgentId: ceoAgentId,
executionPolicy: {
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
projectIds: [projectId],
},
},
},
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: ceoAgentId,
companyId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).resolves.toBeUndefined();
});
it("rejects runtime service mutations when only the run policy is low-trust without runtime.manage", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);
const ceoAgentId = await seedAgent(companyId, { role: "ceo", name: "CEO" });
const issueId = randomUUID();
const runId = randomUUID();
await db.insert(issues).values({
id: issueId,
companyId,
projectId,
projectWorkspaceId,
title: "Run-scoped low-trust workspace",
status: "in_progress",
priority: "medium",
assigneeAgentId: ceoAgentId,
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId: ceoAgentId,
status: "running",
contextSnapshot: {
issueId,
executionPolicy: {
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
companyId,
projectIds: [projectId],
},
},
},
},
});
await expect(assertCanManageProjectWorkspaceRuntimeServices(db, {
actor: {
type: "agent",
agentId: ceoAgentId,
companyId,
runId,
source: "agent_key",
},
} as any, {
companyId,
projectWorkspaceId,
})).rejects.toMatchObject({
status: 403,
message: "Low-trust runs cannot manage workspace runtime services unless the boundary grants runtime.manage",
});
});
it("allows agents with a non-terminal assigned issue in the target project workspace", async () => {
const companyId = await seedCompany();
const { projectId, projectWorkspaceId } = await seedProjectWorkspace(companyId);