From e8c17bec496c747dbdc045a89117009cf8362892 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 3 Jun 2026 17:11:07 +0000 Subject: [PATCH] Fix low-trust sandbox preflight ordering --- .../heartbeat-workspace-session.test.ts | 128 ++++++++++++++++++ server/src/services/heartbeat.ts | 74 +++++++++- 2 files changed, 201 insertions(+), 1 deletion(-) diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index a9affbf8e7c..df68a4940a2 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -11,14 +11,17 @@ import { formatRuntimeWorkspaceWarningLog, mergeExecutionWorkspaceMetadataForPersistence, mergeCoalescedContextSnapshot, + preflightLowTrustWorkspaceIsolation, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, resolveNextSessionState, resolveRuntimeSessionParamsForWorkspace, + stripHostWorkspaceProvisionForLowTrustSandbox, stripWorkspaceRuntimeFromExecutionRunConfig, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; +import type { TrustPresetResolution } from "../services/trust-preset-resolver.ts"; function buildResolvedWorkspace(overrides: Partial = {}): ResolvedWorkspaceForRun { return { @@ -85,6 +88,131 @@ 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: {}, + }; +} + +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"); + }); +}); + describe("resolveRuntimeSessionParamsForWorkspace", () => { it("migrates fallback workspace sessions to project workspace when project cwd becomes available", () => { const agentId = "agent-123"; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index c9573803d04..ecb4c2a3748 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -754,6 +754,53 @@ function buildExecutionWorkspaceConfigSnapshot( return hasSnapshot ? snapshot : null; } +export function stripHostWorkspaceProvisionForLowTrustSandbox(input: { + config: Record; + trustPreset: TrustPresetResolution; + selectedEnvironmentDriver: string | null | undefined; +}): Record { + if (input.trustPreset.kind !== "low_trust_review") return input.config; + if (input.selectedEnvironmentDriver !== "sandbox") return input.config; + + const workspaceStrategy = parseObject(input.config.workspaceStrategy); + if (typeof workspaceStrategy.provisionCommand !== "string") return input.config; + + const nextWorkspaceStrategy = { ...workspaceStrategy }; + delete nextWorkspaceStrategy.provisionCommand; + + return { + ...input.config, + workspaceStrategy: nextWorkspaceStrategy, + }; +} + +export async function preflightLowTrustWorkspaceIsolation(input: { + trustPreset: TrustPresetResolution; + isolatedWorkspacesEnabled: boolean; + effectiveExecutionWorkspaceMode: string | null | undefined; + issue: { companyId: string; id?: string | null; projectId?: string | null } | null; + resolveSelectedEnvironmentDriver: () => Promise; +}): Promise { + if (input.trustPreset.kind !== "denied" && input.trustPreset.kind !== "low_trust_review") { + return null; + } + + const selectedEnvironmentDriver = + input.trustPreset.kind === "low_trust_review" + ? await input.resolveSelectedEnvironmentDriver() + : null; + + assertLowTrustWorkspaceIsolation({ + resolution: input.trustPreset, + isolatedWorkspacesEnabled: input.isolatedWorkspacesEnabled, + effectiveExecutionWorkspaceMode: input.effectiveExecutionWorkspaceMode, + selectedEnvironmentDriver, + issue: input.issue, + }); + + return selectedEnvironmentDriver ?? null; +} + function deriveRepoNameFromRepoUrl(repoUrl: string | null): string | null { const trimmed = repoUrl?.trim() ?? ""; if (!trimmed) return null; @@ -7599,6 +7646,26 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ? persistedExecutionWorkspaceMode : requestedExecutionWorkspaceMode; const selectedEnvironmentId = environmentResolution.environmentId; + const lowTrustPreflightEnvironmentDriver = await preflightLowTrustWorkspaceIsolation({ + trustPreset, + isolatedWorkspacesEnabled, + effectiveExecutionWorkspaceMode, + issue: issueRef + ? { + companyId: agent.companyId, + id: issueRef.id, + projectId: issueRef.projectId, + } + : null, + resolveSelectedEnvironmentDriver: async () => { + const preflightEnvironment = await envOrchestrator.resolveEnvironment({ + companyId: agent.companyId, + selectedEnvironmentId, + defaultEnvironmentId: defaultEnvironment.id, + }); + return preflightEnvironment.driver; + }, + }); const workspaceManagedConfig = shouldReuseExisting ? { ...config } : buildExecutionWorkspaceAdapterConfig({ @@ -7685,6 +7752,11 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) ...effectiveResolvedConfig, paperclipRuntimeSkills: runtimeSkillEntries, }; + const hostExecutionWorkspaceConfig = stripHostWorkspaceProvisionForLowTrustSandbox({ + config: runtimeConfig, + trustPreset, + selectedEnvironmentDriver: lowTrustPreflightEnvironmentDriver, + }); const workspaceOperationRecorder = workspaceOperationsSvc.createRecorder({ companyId: agent.companyId, heartbeatRunId: run.id, @@ -7733,7 +7805,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : null; const executionWorkspace = reusedExecutionWorkspace ?? await realizeExecutionWorkspace({ base: executionWorkspaceBase, - config: runtimeConfig, + config: hostExecutionWorkspaceConfig, issue: issueRef, agent: { id: agent.id,