diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md new file mode 100644 index 00000000000..9c14570bcba --- /dev/null +++ b/doc/LOW-TRUST-PRESETS.md @@ -0,0 +1,38 @@ +# Low-Trust Presets + +Paperclip ships core trust preset names so containment decisions are enforced in +Community Edition even when EE policy editing is unavailable. + +## Presets + +- `standard`: the default V1 company-visible collaboration model. This preserves + existing behavior for normal agents. +- `low_trust_review`: an opt-in containment preset for automated work that may + consume hostile or prompt-injected input, such as untrusted pull requests, + external tickets, dependency diffs, or generated review output. + +## Boundary Model + +`low_trust_review` is resolved from existing JSON policy fields: + +- agent permissions: `permissions.trustPreset` and + `permissions.authorizationPolicy.trustBoundary` +- project policy: + `executionWorkspacePolicy.authorizationPolicy.trustBoundary` +- issue/run policy: `executionPolicy.authorizationPolicy.trustBoundary` + +The resolver intersects those sources. Narrower wins. A low-trust preset must +resolve to a concrete company-local project, root issue, or issue-id scope. If a +policy source names another company, uses an unsupported preset, or lacks that +scope for risky access, Paperclip fails closed. + +## Containment, Not Privacy + +This is containment for hostile automated work. It is not a general project, +issue, or human privacy system. + +V1 standard work remains company-visible by default: board users and in-company +actors can inspect company work objects unless a separate access-control feature +changes that behavior. Low-trust containment instead limits what the low-trust +agent can read or mutate through the Paperclip API and prevents raw untrusted +output from being automatically promoted into higher-trust agent context. diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index ce94ec6a1fc..3ccc0a13e65 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -45,6 +45,10 @@ These decisions close open questions from `SPEC.md` for V1. | Budget enforcement | Soft alerts + hard limit auto-pause | | Deployment modes | Canonical model is `local_trusted` + `authenticated` with `private/public` exposure policy (see `doc/DEPLOYMENT-MODES.md`) | +Low-trust agent presets are containment controls for hostile automated work, not +general project or issue privacy controls. The core preset resolver contract is +documented in `doc/LOW-TRUST-PRESETS.md`. + ## 4. Current Baseline (Repo Snapshot) As of 2026-02-17, the repo already includes: diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 02b87e60ee2..2fc8337d025 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -7,6 +7,20 @@ export { parseFrontmatterMarkdown, type MarkdownDoc, } from "./frontmatter.js"; +export { + TRUST_PRESETS, + DEFAULT_TRUST_PRESET, + LOW_TRUST_REVIEW_PRESET, + LOW_TRUST_REVIEW_PRESET_VERSION, + LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION, + LOW_TRUST_TOOL_CLASSES, + type TrustPreset, + type LowTrustToolClass, + type LowTrustOutputPromotionTarget, + type LowTrustBoundary, + type LowTrustReviewPresetPolicy, + type TrustAuthorizationPolicy, +} from "./trust-policy.js"; export { COMPANY_STATUSES, DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, @@ -817,8 +831,15 @@ export { instanceExperimentalSettingsSchema, patchInstanceExperimentalSettingsSchema, issueGraphLivenessAutoRecoveryRequestSchema, + trustPresetSchema, + lowTrustBoundarySchema, + lowTrustReviewPresetPolicySchema, + trustAuthorizationPolicySchema, type PatchInstanceExperimentalSettings, type IssueGraphLivenessAutoRecoveryRequest, + type TrustPresetInput, + type LowTrustBoundaryInput, + type TrustAuthorizationPolicyInput, } from "./validators/index.js"; export { diff --git a/packages/shared/src/trust-policy.ts b/packages/shared/src/trust-policy.ts new file mode 100644 index 00000000000..618d907fb17 --- /dev/null +++ b/packages/shared/src/trust-policy.ts @@ -0,0 +1,45 @@ +export const TRUST_PRESETS = ["standard", "low_trust_review"] as const; + +export type TrustPreset = (typeof TRUST_PRESETS)[number]; + +export const DEFAULT_TRUST_PRESET = "standard" as const; +export const LOW_TRUST_REVIEW_PRESET = "low_trust_review" as const; +export const LOW_TRUST_REVIEW_PRESET_VERSION = 1 as const; +export const LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION = "quarantine" as const; + +export const LOW_TRUST_TOOL_CLASSES = [ + "git.read", + "github.pr.read", + "tests.local", +] as const; + +export type LowTrustToolClass = (typeof LOW_TRUST_TOOL_CLASSES)[number] | string; + +export interface LowTrustOutputPromotionTarget { + type: "issue"; + issueId: string; +} + +export interface LowTrustBoundary { + mode: typeof LOW_TRUST_REVIEW_PRESET; + companyId?: string; + projectIds?: string[]; + rootIssueId?: string; + issueIds?: string[]; + allowedAgentIds?: string[]; + allowedSecretBindingIds?: string[]; + allowedToolClasses?: LowTrustToolClass[]; + outputPromotionTarget?: LowTrustOutputPromotionTarget; +} + +export interface LowTrustReviewPresetPolicy { + id: typeof LOW_TRUST_REVIEW_PRESET; + version: typeof LOW_TRUST_REVIEW_PRESET_VERSION; + rawOutputDisposition: typeof LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION; +} + +export interface TrustAuthorizationPolicy extends Record { + trustPreset?: TrustPreset; + reviewPreset?: LowTrustReviewPresetPolicy; + trustBoundary?: LowTrustBoundary; +} diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 14c227dd6ce..577fcbf7448 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -9,9 +9,15 @@ import type { CompanyMembership, PrincipalPermissionGrant, } from "./access.js"; +import type { + TrustAuthorizationPolicy, + TrustPreset, +} from "../trust-policy.js"; -export interface AgentPermissions { +export interface AgentPermissions extends Record { canCreateAgents: boolean; + trustPreset?: TrustPreset; + authorizationPolicy?: TrustAuthorizationPolicy; } export interface AgentModelProfileConfig { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index db0b68f2439..a235150f068 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -40,6 +40,20 @@ export { MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "./instance.js"; +export { + TRUST_PRESETS, + DEFAULT_TRUST_PRESET, + LOW_TRUST_REVIEW_PRESET, + LOW_TRUST_REVIEW_PRESET_VERSION, + LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION, + LOW_TRUST_TOOL_CLASSES, + type TrustPreset, + type LowTrustToolClass, + type LowTrustOutputPromotionTarget, + type LowTrustBoundary, + type LowTrustReviewPresetPolicy, + type TrustAuthorizationPolicy, +} from "../trust-policy.js"; export type { CompanySkillSourceType, CompanySkillTrustLevel, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index c66cd7be18d..c6f9710e8b0 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -30,6 +30,10 @@ import type { Goal } from "./goal.js"; import type { Project, ProjectWorkspace } from "./project.js"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings } from "./workspace-runtime.js"; import type { IssueWorkProduct } from "./work-product.js"; +import type { + LowTrustReviewPresetPolicy, + TrustAuthorizationPolicy, +} from "../trust-policy.js"; export type { IssueWorkMode }; @@ -441,6 +445,8 @@ export interface IssueExecutionPolicy { commentRequired: boolean; stages: IssueExecutionStage[]; monitor?: IssueExecutionMonitorPolicy | null; + reviewPreset?: LowTrustReviewPresetPolicy; + authorizationPolicy?: TrustAuthorizationPolicy; } export interface IssueExecutionMonitorState { diff --git a/packages/shared/src/types/workspace-runtime.ts b/packages/shared/src/types/workspace-runtime.ts index fe1367764ae..13ba4154bca 100644 --- a/packages/shared/src/types/workspace-runtime.ts +++ b/packages/shared/src/types/workspace-runtime.ts @@ -1,3 +1,5 @@ +import type { TrustAuthorizationPolicy } from "../trust-policy.js"; + export type ExecutionWorkspaceStrategyType = | "project_primary" | "git_worktree" @@ -155,6 +157,7 @@ export interface ProjectExecutionWorkspacePolicy { pullRequestPolicy?: Record | null; runtimePolicy?: Record | null; cleanupPolicy?: Record | null; + authorizationPolicy?: TrustAuthorizationPolicy | null; } export interface IssueExecutionWorkspaceSettings { diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 6cbf8823516..6d0f46cfc70 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -7,10 +7,13 @@ import { } from "../constants.js"; import { agentAdapterTypeSchema } from "../adapter-type.js"; import { envConfigSchema } from "./secret.js"; +import { trustAuthorizationPolicySchema, trustPresetSchema } from "./trust-policy.js"; export const agentPermissionsSchema = z.object({ canCreateAgents: z.boolean().optional().default(false), -}); + trustPreset: trustPresetSchema.optional(), + authorizationPolicy: trustAuthorizationPolicySchema.optional(), +}).catchall(z.unknown()); export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]); diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 79cd392b013..cc2177a363a 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -146,6 +146,16 @@ export { type CompanyPortabilityImport, } from "./company-portability.js"; +export { + trustPresetSchema, + lowTrustBoundarySchema, + lowTrustReviewPresetPolicySchema, + trustAuthorizationPolicySchema, + type TrustPresetInput, + type LowTrustBoundaryInput, + type TrustAuthorizationPolicyInput, +} from "./trust-policy.js"; + export { createAgentSchema, createAgentHireSchema, diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index 2b8740f56f6..b750254d7ff 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -27,6 +27,7 @@ import { MODEL_PROFILE_KEYS, } from "../constants.js"; import { multilineTextSchema } from "./text.js"; +import { lowTrustReviewPresetPolicySchema, trustAuthorizationPolicySchema } from "./trust-policy.js"; export const issueBlockedInboxStateSchema = z.enum([ "needs_attention", @@ -197,6 +198,8 @@ export const issueExecutionPolicySchema = z.object({ commentRequired: z.boolean().optional().default(true), stages: z.array(issueExecutionStageSchema).default([]), monitor: issueExecutionMonitorPolicySchema.optional().nullable(), + reviewPreset: lowTrustReviewPresetPolicySchema.optional(), + authorizationPolicy: trustAuthorizationPolicySchema.optional(), }); export const issueExecutionMonitorStateSchema = z.object({ diff --git a/packages/shared/src/validators/project.ts b/packages/shared/src/validators/project.ts index af831e41de5..ef0f71a9956 100644 --- a/packages/shared/src/validators/project.ts +++ b/packages/shared/src/validators/project.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { PROJECT_STATUSES } from "../constants.js"; import { envConfigSchema } from "./secret.js"; +import { trustAuthorizationPolicySchema } from "./trust-policy.js"; const executionWorkspaceStrategySchema = z .object({ @@ -26,6 +27,7 @@ export const projectExecutionWorkspacePolicySchema = z pullRequestPolicy: z.record(z.string(), z.unknown()).optional().nullable(), runtimePolicy: z.record(z.string(), z.unknown()).optional().nullable(), cleanupPolicy: z.record(z.string(), z.unknown()).optional().nullable(), + authorizationPolicy: trustAuthorizationPolicySchema.optional().nullable(), }) .strict(); diff --git a/packages/shared/src/validators/trust-policy.ts b/packages/shared/src/validators/trust-policy.ts new file mode 100644 index 00000000000..a2c0a266b6c --- /dev/null +++ b/packages/shared/src/validators/trust-policy.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { + LOW_TRUST_REVIEW_PRESET, + LOW_TRUST_REVIEW_PRESET_VERSION, + LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION, + TRUST_PRESETS, +} from "../trust-policy.js"; + +export const trustPresetSchema = z.enum(TRUST_PRESETS); + +export const lowTrustOutputPromotionTargetSchema = z.object({ + type: z.literal("issue"), + issueId: z.string().uuid(), +}).strict(); + +export const lowTrustBoundarySchema = z.object({ + mode: z.literal(LOW_TRUST_REVIEW_PRESET), + companyId: z.string().uuid().optional(), + projectIds: z.array(z.string().uuid()).optional(), + rootIssueId: z.string().uuid().optional(), + issueIds: z.array(z.string().uuid()).optional(), + allowedAgentIds: z.array(z.string().uuid()).optional(), + allowedSecretBindingIds: z.array(z.string().uuid()).optional(), + allowedToolClasses: z.array(z.string().trim().min(1)).optional(), + outputPromotionTarget: lowTrustOutputPromotionTargetSchema.optional(), +}).strict(); + +export const lowTrustReviewPresetPolicySchema = z.object({ + id: z.literal(LOW_TRUST_REVIEW_PRESET), + version: z.literal(LOW_TRUST_REVIEW_PRESET_VERSION), + rawOutputDisposition: z.literal(LOW_TRUST_REVIEW_RAW_OUTPUT_DISPOSITION), +}).strict(); + +export const trustAuthorizationPolicySchema = z.object({ + trustPreset: trustPresetSchema.optional(), + reviewPreset: lowTrustReviewPresetPolicySchema.optional(), + trustBoundary: lowTrustBoundarySchema.optional(), +}).catchall(z.unknown()); + +export type TrustPresetInput = z.infer; +export type LowTrustBoundaryInput = z.infer; +export type TrustAuthorizationPolicyInput = z.infer; diff --git a/server/src/__tests__/trust-preset-resolver.test.ts b/server/src/__tests__/trust-preset-resolver.test.ts new file mode 100644 index 00000000000..5aa206ed83c --- /dev/null +++ b/server/src/__tests__/trust-preset-resolver.test.ts @@ -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>): LowTrustBoundary { + return { + mode: LOW_TRUST_REVIEW_PRESET, + companyId, + ...input, + }; +} + +function boundaryPolicy(boundary: ReturnType) { + 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 }, + }, + }); + }); +}); diff --git a/server/src/services/issue-execution-policy.ts b/server/src/services/issue-execution-policy.ts index 37b75c84c10..583b52c5688 100644 --- a/server/src/services/issue-execution-policy.ts +++ b/server/src/services/issue-execution-policy.ts @@ -387,13 +387,18 @@ export function normalizeIssueExecutionPolicy(input: unknown): IssueExecutionPol } : null; - if (stages.length === 0 && !monitor) return null; + const reviewPreset = parsed.data.reviewPreset; + const authorizationPolicy = parsed.data.authorizationPolicy; + + if (stages.length === 0 && !monitor && !reviewPreset && !authorizationPolicy) return null; return { mode: parsed.data.mode ?? "normal", commentRequired: true, stages, ...(monitor ? { monitor } : {}), + ...(reviewPreset ? { reviewPreset } : {}), + ...(authorizationPolicy ? { authorizationPolicy } : {}), }; } diff --git a/server/src/services/trust-preset-resolver.ts b/server/src/services/trust-preset-resolver.ts new file mode 100644 index 00000000000..e7c9b482383 --- /dev/null +++ b/server/src/services/trust-preset-resolver.ts @@ -0,0 +1,347 @@ +import { + DEFAULT_TRUST_PRESET, + LOW_TRUST_REVIEW_PRESET, + type LowTrustBoundary, + type TrustPreset, + lowTrustBoundarySchema, + lowTrustReviewPresetPolicySchema, + trustAuthorizationPolicySchema, + trustPresetSchema, +} from "@paperclipai/shared"; + +type JsonRecord = Record; + +export type TrustPresetPolicySource = "agent" | "project" | "issue" | "run"; + +export type ResolveCoreTrustPresetInput = { + companyId: string; + agent?: { + companyId?: string | null; + permissions?: unknown; + } | null; + project?: { + companyId?: string | null; + executionWorkspacePolicy?: unknown; + } | null; + issue?: { + companyId?: string | null; + executionPolicy?: unknown; + } | null; + run?: { + companyId?: string | null; + executionPolicy?: unknown; + } | null; +}; + +export type TrustPresetDenyReason = + | "unsupported_trust_preset" + | "invalid_authorization_policy" + | "invalid_low_trust_boundary" + | "cross_company_boundary" + | "conflicting_low_trust_boundary" + | "missing_low_trust_boundary_scope"; + +export type TrustPresetResolution = + | { + kind: "standard"; + preset: typeof DEFAULT_TRUST_PRESET; + boundary: null; + sourcePresets: Partial>; + } + | { + kind: "low_trust_review"; + preset: typeof LOW_TRUST_REVIEW_PRESET; + boundary: LowTrustBoundary & { companyId: string }; + sourcePresets: Partial>; + } + | { + kind: "denied"; + reason: TrustPresetDenyReason; + source: TrustPresetPolicySource | null; + detail: string; + sourcePresets: Partial>; + }; + +type ParsedPolicySource = { + source: TrustPresetPolicySource; + companyId: string | null; + rawPolicy: JsonRecord | null; + authorizationPolicy: JsonRecord | null; + trustPreset: TrustPreset | null; + boundary: LowTrustBoundary | null; + impliesLowTrust: boolean; +}; + +function asRecord(value: unknown): JsonRecord | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as JsonRecord + : null; +} + +function deny( + reason: TrustPresetDenyReason, + source: TrustPresetPolicySource | null, + detail: string, + sourcePresets: Partial>, +): TrustPresetResolution { + return { kind: "denied", reason, source, detail, sourcePresets }; +} + +function isTrustPresetResolution(value: unknown): value is TrustPresetResolution { + if (!value || typeof value !== "object") return false; + const kind = (value as { kind?: unknown }).kind; + return kind === "standard" || kind === "low_trust_review" || kind === "denied"; +} + +function parsePreset( + value: unknown, + source: TrustPresetPolicySource, + sourcePresets: Partial>, +): TrustPresetResolution | TrustPreset | null { + if (value === undefined || value === null) return null; + const parsed = trustPresetSchema.safeParse(value); + if (!parsed.success) { + return deny( + "unsupported_trust_preset", + source, + `Unsupported trust preset in ${source} policy.`, + sourcePresets, + ); + } + return parsed.data; +} + +function parseReviewPresetId( + value: unknown, + source: TrustPresetPolicySource, + sourcePresets: Partial>, +): TrustPresetResolution | TrustPreset | null { + if (value === undefined || value === null) return null; + const parsed = lowTrustReviewPresetPolicySchema.safeParse(value); + if (!parsed.success) { + return deny( + "unsupported_trust_preset", + source, + `Unsupported review preset in ${source} policy.`, + sourcePresets, + ); + } + return parsed.data.id; +} + +function parseAuthorizationPolicy( + value: unknown, + source: TrustPresetPolicySource, + sourcePresets: Partial>, +): TrustPresetResolution | JsonRecord | null { + if (value === undefined || value === null) return null; + const parsed = trustAuthorizationPolicySchema.safeParse(value); + if (!parsed.success) { + return deny( + "invalid_authorization_policy", + source, + `Invalid authorization policy in ${source} policy.`, + sourcePresets, + ); + } + return parsed.data as JsonRecord; +} + +function parseBoundary( + value: unknown, + source: TrustPresetPolicySource, + sourcePresets: Partial>, +): TrustPresetResolution | LowTrustBoundary | null { + if (value === undefined || value === null) return null; + const parsed = lowTrustBoundarySchema.safeParse(value); + if (!parsed.success) { + return deny( + "invalid_low_trust_boundary", + source, + `Invalid low-trust boundary in ${source} policy.`, + sourcePresets, + ); + } + return parsed.data; +} + +function parseSource( + source: TrustPresetPolicySource, + companyId: string | null | undefined, + rawPolicy: JsonRecord | null, + authorizationPolicyInput: unknown, + sourcePresets: Partial>, +): ParsedPolicySource | TrustPresetResolution { + const topPreset = parsePreset(rawPolicy?.trustPreset, source, sourcePresets); + if (isTrustPresetResolution(topPreset)) return topPreset; + + const topReviewPreset = parseReviewPresetId(rawPolicy?.reviewPreset, source, sourcePresets); + if (isTrustPresetResolution(topReviewPreset)) return topReviewPreset; + + const authorizationPolicy = parseAuthorizationPolicy(authorizationPolicyInput, source, sourcePresets); + if (isTrustPresetResolution(authorizationPolicy)) return authorizationPolicy; + + const authPreset = parsePreset(authorizationPolicy?.trustPreset, source, sourcePresets); + if (isTrustPresetResolution(authPreset)) return authPreset; + + const authReviewPreset = parseReviewPresetId(authorizationPolicy?.reviewPreset, source, sourcePresets); + if (isTrustPresetResolution(authReviewPreset)) return authReviewPreset; + + const boundary = parseBoundary(authorizationPolicy?.trustBoundary, source, sourcePresets); + if (isTrustPresetResolution(boundary)) return boundary; + + const trustPreset = topPreset ?? topReviewPreset ?? authPreset ?? authReviewPreset; + if (trustPreset) sourcePresets[source] = trustPreset; + + return { + source, + companyId: companyId ?? null, + rawPolicy, + authorizationPolicy, + trustPreset, + boundary, + impliesLowTrust: trustPreset === LOW_TRUST_REVIEW_PRESET || Boolean(boundary), + }; +} + +function normalizeSet(values: readonly string[] | undefined): string[] | undefined { + if (values === undefined) return undefined; + return [...new Set(values)].sort(); +} + +function intersectSets(left: string[] | undefined, right: string[] | undefined): string[] | undefined { + const normalizedRight = normalizeSet(right); + if (normalizedRight === undefined) return left; + const normalizedLeft = normalizeSet(left); + if (normalizedLeft === undefined) return normalizedRight; + const rightSet = new Set(normalizedRight); + return normalizedLeft.filter((value) => rightSet.has(value)); +} + +function mergeBoundary( + current: (LowTrustBoundary & { companyId: string }) | null, + next: LowTrustBoundary, + companyId: string, + source: TrustPresetPolicySource, + sourcePresets: Partial>, +): (LowTrustBoundary & { companyId: string }) | TrustPresetResolution { + if (next.companyId && next.companyId !== companyId) { + return deny( + "cross_company_boundary", + source, + "Low-trust boundary refers to a different company.", + sourcePresets, + ); + } + + const base = current ?? { mode: LOW_TRUST_REVIEW_PRESET, companyId }; + if (base.rootIssueId && next.rootIssueId && base.rootIssueId !== next.rootIssueId) { + return deny( + "conflicting_low_trust_boundary", + source, + "Low-trust boundary root issue scopes do not overlap.", + sourcePresets, + ); + } + + return { + ...base, + projectIds: intersectSets(base.projectIds, next.projectIds), + rootIssueId: base.rootIssueId ?? next.rootIssueId, + issueIds: intersectSets(base.issueIds, next.issueIds), + allowedAgentIds: intersectSets(base.allowedAgentIds, next.allowedAgentIds), + allowedSecretBindingIds: intersectSets(base.allowedSecretBindingIds, next.allowedSecretBindingIds), + allowedToolClasses: intersectSets(base.allowedToolClasses, next.allowedToolClasses), + outputPromotionTarget: next.outputPromotionTarget ?? base.outputPromotionTarget, + }; +} + +function hasBoundaryScope(boundary: LowTrustBoundary): boolean { + return Boolean(boundary.rootIssueId) + || Boolean(boundary.projectIds?.length) + || Boolean(boundary.issueIds?.length); +} + +export function resolveCoreTrustPreset(input: ResolveCoreTrustPresetInput): TrustPresetResolution { + const sourcePresets: Partial> = {}; + const sources: ParsedPolicySource[] = []; + + const agentPermissions = asRecord(input.agent?.permissions); + const agent = parseSource("agent", input.agent?.companyId, agentPermissions, agentPermissions?.authorizationPolicy, sourcePresets); + if ("kind" in agent) return agent; + sources.push(agent); + + const projectPolicy = asRecord(input.project?.executionWorkspacePolicy); + const project = parseSource("project", input.project?.companyId, projectPolicy, projectPolicy?.authorizationPolicy, sourcePresets); + if ("kind" in project) return project; + sources.push(project); + + const issuePolicy = asRecord(input.issue?.executionPolicy); + const issue = parseSource("issue", input.issue?.companyId, issuePolicy, issuePolicy?.authorizationPolicy, sourcePresets); + if ("kind" in issue) return issue; + sources.push(issue); + + const runPolicy = asRecord(input.run?.executionPolicy); + const run = parseSource("run", input.run?.companyId, runPolicy, runPolicy?.authorizationPolicy, sourcePresets); + if ("kind" in run) return run; + sources.push(run); + + for (const source of sources) { + if (source.companyId && source.companyId !== input.companyId) { + return deny( + "cross_company_boundary", + source.source, + "Policy source belongs to a different company.", + sourcePresets, + ); + } + } + + const effectivePreset = sources.some((source) => source.impliesLowTrust) + ? LOW_TRUST_REVIEW_PRESET + : DEFAULT_TRUST_PRESET; + + if (effectivePreset === DEFAULT_TRUST_PRESET) { + return { + kind: "standard", + preset: DEFAULT_TRUST_PRESET, + boundary: null, + sourcePresets, + }; + } + + let boundary: (LowTrustBoundary & { companyId: string }) | null = null; + for (const source of sources) { + if (!source.boundary) continue; + const merged = mergeBoundary(boundary, source.boundary, input.companyId, source.source, sourcePresets); + if (isTrustPresetResolution(merged)) return merged; + boundary = merged; + } + + if (!boundary || !hasBoundaryScope(boundary)) { + return deny( + "missing_low_trust_boundary_scope", + null, + "Low-trust review requires a concrete project, root issue, or issue-id boundary.", + sourcePresets, + ); + } + + return { + kind: "low_trust_review", + preset: LOW_TRUST_REVIEW_PRESET, + boundary, + sourcePresets, + }; +} + +export function isIssueWithinLowTrustBoundary( + boundary: LowTrustBoundary & { companyId: string }, + issue: { companyId: string; id?: string | null; projectId?: string | null }, +): boolean { + if (issue.companyId !== boundary.companyId) return false; + if (issue.id && issue.id === boundary.rootIssueId) return true; + if (issue.id && boundary.issueIds?.includes(issue.id)) return true; + if (issue.projectId && boundary.projectIds?.includes(issue.projectId)) return true; + return false; +}