diff --git a/packages/plugins/sandbox-providers/daytona/src/manifest.ts b/packages/plugins/sandbox-providers/daytona/src/manifest.ts index d1ebbc70960..b1815acb511 100644 --- a/packages/plugins/sandbox-providers/daytona/src/manifest.ts +++ b/packages/plugins/sandbox-providers/daytona/src/manifest.ts @@ -24,6 +24,10 @@ const manifest: PaperclipPluginManifestV1 = { description: "Provisions Daytona sandboxes with configurable image or snapshot selection, startup timeouts, and lease reuse.", supportsReusableLeases: true, + isolation: { + kind: "container", + hardBoundary: true, + }, configSchema: { type: "object", properties: { diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 20ea2c2ecef..f4517816d06 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -144,6 +144,15 @@ export interface PluginEnvironmentDriverDeclaration { * behavior even if their config schema exposes a reuse-like setting. */ supportsReusableLeases?: boolean; + /** + * Optional isolation claim used by the host to decide whether runtime secret + * material may be described as protected by a hard boundary. Omit this unless + * the driver actually runs work behind the declared boundary. + */ + isolation?: { + kind: "separate_uid" | "namespace" | "container" | "vm" | "remote"; + hardBoundary?: boolean; + }; /** JSON Schema describing the driver's provider-specific configuration. */ configSchema: JsonSchema; } diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 3462d9d656d..17378793201 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -126,6 +126,10 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({ displayName: z.string().min(1).max(100), description: z.string().max(500).optional(), supportsReusableLeases: z.boolean().optional(), + isolation: z.object({ + kind: z.enum(["separate_uid", "namespace", "container", "vm", "remote"]), + hardBoundary: z.boolean().optional(), + }).optional(), configSchema: jsonSchemaSchema, }); diff --git a/server/src/__tests__/environment-runtime.test.ts b/server/src/__tests__/environment-runtime.test.ts index 4ec89cbe1c6..d085e007e3b 100644 --- a/server/src/__tests__/environment-runtime.test.ts +++ b/server/src/__tests__/environment-runtime.test.ts @@ -277,6 +277,83 @@ describeEmbeddedPostgres("environmentRuntimeService", () => { }; } + it("reports hard secret posture only for plugin-backed sandbox providers with a hard isolation claim", async () => { + await db.insert(plugins).values({ + id: randomUUID(), + pluginKey: "acme.hard-sandbox-provider", + packageName: "@acme/hard-sandbox-provider", + version: "1.0.0", + apiVersion: 1, + categories: ["automation"], + manifestJson: { + id: "acme.hard-sandbox-provider", + apiVersion: 1, + version: "1.0.0", + displayName: "Hard Sandbox Provider", + description: "Test provider with a declared container boundary", + author: "Paperclip", + categories: ["automation"], + capabilities: ["environment.drivers.register"], + entrypoints: { worker: "dist/worker.js" }, + environmentDrivers: [ + { + driverKey: "hard-plugin", + kind: "sandbox_provider", + displayName: "Hard Plugin", + isolation: { + kind: "container", + hardBoundary: true, + }, + configSchema: { + type: "object", + properties: { + image: { type: "string" }, + }, + }, + }, + ], + }, + status: "ready", + installOrder: 1, + config: {}, + createdAt: new Date(), + updatedAt: new Date(), + }); + const { environment } = await seedEnvironment({ + driver: "sandbox", + config: { + provider: "hard-plugin", + image: "paperclip:test", + }, + }); + + await expect(runtime.resolveSecretPosture(environment)).resolves.toBe("hard"); + }); + + it("reports soft secret posture for the built-in fake sandbox provider", async () => { + const { environment } = await seedEnvironment({ + driver: "sandbox", + config: { + provider: "fake", + image: "ubuntu:24.04", + }, + }); + + await expect(runtime.resolveSecretPosture(environment)).resolves.toBe("soft"); + }); + + it("defaults unknown sandbox providers to soft secret posture", async () => { + const { environment } = await seedEnvironment({ + driver: "sandbox", + config: { + provider: "missing-plugin", + image: "paperclip:test", + }, + }); + + await expect(runtime.resolveSecretPosture(environment)).resolves.toBe("soft"); + }); + async function seedReusablePluginSandboxLease() { const pluginId = randomUUID(); const { companyId, agentId, environment: baseEnvironment, runId } = await seedEnvironment(); diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 3fd5112a324..abe1275f8a9 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -118,6 +118,7 @@ describe("resolveExecutionRunAdapterConfig", () => { "secret-project", "secret-routine", ]); + expect(result.secretManifest.every((entry) => entry.posture === "soft")).toBe(true); expect(JSON.stringify(result.secretManifest)).not.toContain("agent-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("environment-only"); expect(JSON.stringify(result.secretManifest)).not.toContain("project-only"); @@ -132,6 +133,44 @@ describe("resolveExecutionRunAdapterConfig", () => { }); }); + it("labels runtime secret manifest entries with the selected environment posture", async () => { + const resolveAdapterConfigForRuntime = vi.fn().mockResolvedValue({ + config: { env: { AGENT_SECRET: "resolved" } }, + secretKeys: new Set(["AGENT_SECRET"]), + manifest: [ + { + configPath: "env.AGENT_SECRET", + envKey: "AGENT_SECRET", + secretId: "secret-agent", + secretKey: "agent-secret", + version: 1, + provider: "local_encrypted", + posture: "soft", + outcome: "success", + }, + ], + }); + + const result = await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + executionRunConfig: { env: { AGENT_SECRET: { type: "secret_ref", secretId: "secret-agent" } } }, + projectEnv: null, + secretPosture: "hard", + secretsSvc: { + resolveAdapterConfigForRuntime, + resolveEnvBindings: vi.fn(), + } as any, + }); + + expect(result.secretManifest).toEqual([ + expect.objectContaining({ + secretId: "secret-agent", + posture: "hard", + }), + ]); + }); + it("drops Paperclip runtime-owned env before resolving environment, agent, project, and routine overlays", async () => { const resolveAdapterConfigForRuntime = vi.fn(async (_companyId, config: Record) => ({ config: { diff --git a/server/src/services/environment-runtime.ts b/server/src/services/environment-runtime.ts index c5323085e5a..57c1c8fbf4f 100644 --- a/server/src/services/environment-runtime.ts +++ b/server/src/services/environment-runtime.ts @@ -8,6 +8,7 @@ import type { EnvironmentLeaseStatus, ExecutionWorkspace, PluginEnvironmentConfig, + PluginEnvironmentDriverDeclaration, SandboxEnvironmentConfig, } from "@paperclipai/shared"; import type { @@ -1113,6 +1114,53 @@ function sandboxConfigForLeaseMetadata(config: SandboxEnvironmentConfig): Record return { ...config }; } +export type RuntimeSecretPosture = "hard" | "soft"; + +function pluginDriverReportsHardIsolation( + driver: PluginEnvironmentDriverDeclaration | null | undefined, +): boolean { + return driver?.isolation?.hardBoundary === true; +} + +async function resolvePluginEnvironmentHardIsolation(input: { + db: Db; + config: PluginEnvironmentConfig; +}): Promise { + const plugin = await pluginRegistryService(input.db).getByKey(input.config.pluginKey); + if (!plugin || plugin.status !== "ready") return false; + const driver = plugin.manifestJson.environmentDrivers?.find( + (candidate) => candidate.driverKey === input.config.driverKey, + ); + return pluginDriverReportsHardIsolation(driver); +} + +export async function resolveEnvironmentRuntimeSecretPosture(input: { + db: Db; + environment: Pick; +}): Promise { + try { + const parsed = parseEnvironmentDriverConfig(input.environment as Environment); + if (parsed.driver === "sandbox") { + if (isBuiltinSandboxProvider(parsed.config.provider)) return "soft"; + const resolved = await resolvePluginSandboxProviderDriverByKey({ + db: input.db, + driverKey: parsed.config.provider, + requireRunning: false, + }); + return pluginDriverReportsHardIsolation(resolved?.driver) ? "hard" : "soft"; + } + if (parsed.driver === "plugin") { + return await resolvePluginEnvironmentHardIsolation({ + db: input.db, + config: parsed.config, + }) ? "hard" : "soft"; + } + } catch { + return "soft"; + } + return "soft"; +} + function tryParseCurrentPluginConfig(environment: Environment): PluginEnvironmentConfig | null { if (environment.driver !== "plugin") { return null; @@ -1438,6 +1486,10 @@ export function environmentRuntimeService( return { getDriver, + async resolveSecretPosture(environment: Pick): Promise { + return await resolveEnvironmentRuntimeSecretPosture({ db, environment }); + }, + async acquireRunLease(input: { companyId: string; environment: Environment; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 197b11d8c6e..e6071dbfb3d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -192,7 +192,7 @@ import { import { extractSkillMentionIds, isUuidLike } from "@paperclipai/shared"; import { environmentService } from "./environments.js"; import { parseExecutionPolicyBootstrapEnv } from "./execution-policy-bootstrap.js"; -import { environmentRuntimeService } from "./environment-runtime.js"; +import { environmentRuntimeService, type RuntimeSecretPosture } from "./environment-runtime.js"; import { skillVersionSelectionMap } from "./runtime-skill-selections.js"; import { environmentRunOrchestrator } from "./environment-run-orchestrator.js"; import { isUnsafeSessionWorkspaceCwd } from "./session-workspace-cwd.js"; @@ -504,6 +504,7 @@ export async function resolveExecutionRunAdapterConfig(input: { projectEnv: unknown; routineEnv?: unknown; secretsSvc: RuntimeConfigSecretResolver; + secretPosture?: RuntimeSecretPosture; trustPreset?: TrustPresetResolution; requiredScopedEnvBinding?: { keys: string[]; @@ -728,6 +729,7 @@ export async function resolveExecutionRunAdapterConfig(input: { secretKeys.add(key); } } + const secretPosture = input.secretPosture ?? "soft"; return { resolvedConfig, secretKeys, @@ -736,7 +738,7 @@ export async function resolveExecutionRunAdapterConfig(input: { ...(manifest ?? []), ...(projectEnvResolution.manifest ?? []), ...(routineEnvResolution.manifest ?? []), - ], + ].map((entry) => ({ ...entry, posture: secretPosture })), }; } @@ -8905,6 +8907,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : selectedEnvironmentId ? await environmentsSvc.getById(selectedEnvironmentId) : null; + const runtimeSecretPosture = selectedEnvironmentForConfig + ? await environmentRuntime.resolveSecretPosture(selectedEnvironmentForConfig) + : "soft"; const runScopedMentionedSkillKeys = await resolveRunScopedMentionedSkillKeys({ db, companyId: agent.companyId, @@ -8928,6 +8933,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) projectEnv: projectContext?.env ?? null, routineEnv: routineEnvContext.env, secretsSvc, + secretPosture: runtimeSecretPosture, trustPreset, requiredScopedEnvBinding: pushCapabilityPreflightRequired ? { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 8d8b7a418c3..dfa22c2914c 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -246,6 +246,7 @@ export type RuntimeSecretManifestEntry = { secretKey: string; version: number; provider: SecretProvider; + posture: "hard" | "soft"; outcome: "success" | "failure"; errorCode?: string | null; }; @@ -819,6 +820,7 @@ export function secretService(db: Db) { secretKey: secret.key, version: resolvedVersion, provider: providerId, + posture: "soft", outcome: "success", }, }; @@ -864,6 +866,7 @@ export function secretService(db: Db) { secretKey: input.secret.key, version: 0, provider: input.secret.provider, + posture: "soft", outcome: "success", }, }; @@ -915,6 +918,7 @@ export function secretService(db: Db) { secretKey: input.secret.key, version: 0, provider: input.secret.provider, + posture: "soft", outcome: "success", }, };