Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 0 additions & 47 deletions apps/sim/lib/credentials/secret-values.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/core/security/encryption', () => encryptionMock)
const mockEncryptSecret = encryptionMockFns.mockEncryptSecret
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
vi.mock('@/lib/credentials/environment', () => credentialsEnvironmentMock)

import {
deletePersonalSecret,
deleteWorkspaceSecret,
readWorkspaceSecretValues,
setWorkspaceSecret,
updateWorkspaceSecretMetadata,
} from '@/lib/credentials/secret-values'
Expand Down Expand Up @@ -116,51 +114,6 @@ describe('secret value storage', () => {
})
})

describe('readWorkspaceSecretValues', () => {
beforeEach(() => {
resetDbChainMock()
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
decrypted: `decrypted:${encrypted}`,
}))
})

it('decrypts only the requested names and omits absent or undecryptable ones', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{
id: 'env-1',
variables: {
VISIBLE_KEY: 'encrypted-visible',
BROKEN_KEY: 'encrypted-broken',
OTHER_KEY: 'encrypted-other',
},
},
])
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
if (encrypted === 'encrypted-broken') throw new Error('cannot decrypt')
return { decrypted: `decrypted:${encrypted}` }
})

await expect(
readWorkspaceSecretValues({
workspaceId: 'workspace-1',
names: ['VISIBLE_KEY', 'BROKEN_KEY', 'MISSING_KEY'],
})
).resolves.toEqual({ VISIBLE_KEY: 'decrypted:encrypted-visible' })
expect(mockDecryptSecret).not.toHaveBeenCalledWith('encrypted-other')
})

it('never reads an inherited prototype member for a missing key', async () => {
queueTableRows(schemaMock.workspaceEnvironment, [
{ id: 'env-1', variables: { OTHER_KEY: 'encrypted-other' } },
])

await expect(
readWorkspaceSecretValues({ workspaceId: 'workspace-1', names: ['constructor', 'toString'] })
).resolves.toEqual({})
expect(mockDecryptSecret).not.toHaveBeenCalled()
})
})

/**
* The row-queue mocks resolve whatever was queued regardless of the predicate, so
* the only way to pin a WHERE clause is to read the condition tree the `eq`/`and`
Expand Down
40 changes: 1 addition & 39 deletions apps/sim/lib/credentials/secret-values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { credential, environment, workspaceEnvironment } from '@sim/db/schema'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { encryptSecret } from '@/lib/core/security/encryption'
import { lockPersonalEnvMap, lockWorkspaceEnvMap } from '@/lib/credentials/env-locks'
import {
createWorkspaceEnvCredentials,
Expand All @@ -17,44 +17,6 @@ export interface SecretMutationResult {
updatedAt: Date
}

/**
* Decrypts the stored values for the requested workspace secret names.
*
* Exists for exactly one read path: rows a workspace marked visible (unredacted),
* whose values already print into every run log the caller can open. Every other
* secret read stays metadata-only — callers gate on the flag BEFORE asking. A
* name that is absent or fails to decrypt is omitted rather than failing the
* batch, since the value is optional on the wire.
*/
export async function readWorkspaceSecretValues(params: {
workspaceId: string
names: readonly string[]
}): Promise<Record<string, string>> {
if (params.names.length === 0) return {}

const [row] = await db
.select({ variables: workspaceEnvironment.variables })
.from(workspaceEnvironment)
.where(eq(workspaceEnvironment.workspaceId, params.workspaceId))
.limit(1)
const variables = (row?.variables as Record<string, string> | null) ?? {}

const values: Record<string, string> = {}
await Promise.all(
params.names.map(async (name) => {
const encrypted = Object.hasOwn(variables, name) ? variables[name] : undefined
if (!encrypted) return
try {
const { decrypted } = await decryptSecret(encrypted)
values[name] = decrypted
} catch {
// Omitted from the result; the caller's wire shape treats the value as optional.
}
})
)
return values
}

/** Stores one workspace secret without decrypting any existing value. */
export async function setWorkspaceSecret(params: {
workspaceId: string
Expand Down
18 changes: 12 additions & 6 deletions apps/sim/lib/execution/remote-sandbox/execution-observer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
import type { SessionProcessIdentity } from '@/lib/execution/remote-sandbox/session-process'

interface SandboxExecutionObserver {
sessionInputsSafe?(): boolean
sessionInputProvenance?(): boolean | DurableSecretProvenance
hold(work: Promise<unknown>): void
unsettled(processId?: string): void
claimProcess?(process: SessionProcessIdentity): Promise<void>
Expand Down Expand Up @@ -49,20 +50,25 @@ export async function prepareSandboxSessionAccess(
}

/** The trusted tool adapter supplies current input evidence while preserving execution ownership. */
export function observeSandboxSessionInputs<T>(safe: () => boolean, execute: () => T): T {
export function observeSandboxSessionInputs<T>(
safe: () => boolean | DurableSecretProvenance,
execute: () => T
): T {
const current = executionObserver.getStore()
return executionObserver.run(
{
hold: (work) => current?.hold(work),
unsettled: (id) => current?.unsettled(id),
...current,
sessionInputsSafe: safe,
sessionInputProvenance: safe,
},
execute
)
}

/** Unobserved arbitrary code cannot certify scratch files as safe. */
export function sandboxSessionInputsSafe(): boolean {
return executionObserver.getStore()?.sessionInputsSafe?.() === true
/** Trusted input evidence is sampled immediately before the machine receives the bytes. */
export function sandboxSessionInputProvenance(): DurableSecretProvenance {
const value = executionObserver.getStore()?.sessionInputProvenance?.()
if (typeof value === 'object') return value
return value === true ? { status: 'exact', entries: [] } : { status: 'unknown' }
}
57 changes: 43 additions & 14 deletions apps/sim/lib/execution/remote-sandbox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
prepareSandboxSessionAccess,
reportUnsettledSandboxProcess,
retainSandboxExecution,
sandboxSessionInputsSafe,
sandboxSessionInputProvenance,
} from '@/lib/execution/remote-sandbox/execution-observer'
import { withSandboxFilePublication } from '@/lib/execution/remote-sandbox/file-publication'
import {
Expand Down Expand Up @@ -55,7 +55,10 @@ import {
SESSION_SANDBOX_IDLE_MS,
} from '@/lib/execution/remote-sandbox/session'
import { sessionCommandPath } from '@/lib/execution/remote-sandbox/session-cli'
import { recordSessionFileInput } from '@/lib/execution/remote-sandbox/session-file-provenance'
import {
readSessionSecretProvenance,
recordSessionFileInput,
} from '@/lib/execution/remote-sandbox/session-file-provenance'
import { withSandboxSessionLock } from '@/lib/execution/remote-sandbox/session-lock'
import type {
CreateSandboxOptions,
Expand Down Expand Up @@ -842,6 +845,18 @@ async function provisionWithinBudget(
throwIfAborted(signal)
}

/** Confidentiality checks also run on provider failures, before their diagnostics can escape. */
async function acceptSessionOutputHistory(
session: SandboxSessionRequest | undefined,
machine: { providerId: SandboxProviderId; sandboxId: string }
): Promise<void> {
if (!session) return
const provenance = await readSessionSecretProvenance(session.key, machine)
if (session.acceptOutputProvenance) await session.acceptOutputProvenance(provenance)
else if (provenance.status !== 'exact' || provenance.entries.length > 0)
throw new Error('Workbench output withheld because its secret provenance is unavailable')
}

async function executeInSandboxWithinBudget(
// The budget wrapper always injects the signal; the required-signal type states that
// invariant instead of a cast hiding it.
Expand Down Expand Up @@ -887,13 +902,13 @@ async function executeInSandboxWithinBudget(
// the finally below. Dependencies land before the inputs so user code and its
// mounts always see a complete environment.
//
if (req.session)
if (lease.session && req.session)
await recordSessionFileInput(
req.session.key,
{ providerId: created.providerId, sandboxId },
sandboxSessionInputsSafe() &&
!req.session.unprovenancedInputs &&
!Object.keys(selected?.envs ?? {}).length
req.session.unprovenancedInputs || Object.keys(selected?.envs ?? {}).length
? { status: 'unknown' }
: (req.session.inputProvenance?.() ?? sandboxSessionInputProvenance())
)
await provisionWithinBudget(sandbox, selected, signal)
await writeSandboxInputs(sandbox, req.sandboxFiles, {
Expand Down Expand Up @@ -1025,8 +1040,15 @@ async function executeInSandboxWithinBudget(
if (cost && billableOutputError) {
attachTrustedSandboxOutputCost(billableOutputError, cost)
}
await privateInputFiles?.cleanup()
await lease.release()
try {
await privateInputFiles?.cleanup()
await lease.release()
} finally {
await acceptSessionOutputHistory(lease.session ? req.session : undefined, {
providerId: created.providerId,
sandboxId,
})
}
}
}

Expand Down Expand Up @@ -1076,13 +1098,13 @@ async function executeShellInSandboxWithinBudget(
// Inside the try so a failed install or mount still releases the sandbox via
// the finally below. The install shares the caller's budget rather than adding
// to it — see the note in `executeInSandbox`.
if (req.session)
if (lease.session && req.session)
await recordSessionFileInput(
req.session.key,
{ providerId: created.providerId, sandboxId },
sandboxSessionInputsSafe() &&
!req.session.unprovenancedInputs &&
!Object.keys(selected?.envs ?? {}).length
req.session.unprovenancedInputs || Object.keys(selected?.envs ?? {}).length
? { status: 'unknown' }
: (req.session.inputProvenance?.() ?? sandboxSessionInputProvenance())
)
await provisionWithinBudget(sandbox, selected, signal)
await writeSandboxInputs(sandbox, req.sandboxFiles, {
Expand Down Expand Up @@ -1192,8 +1214,15 @@ async function executeShellInSandboxWithinBudget(
if (cost && billableOutputError) {
attachTrustedSandboxOutputCost(billableOutputError, cost)
}
await privateInputFiles?.cleanup()
await lease.release()
try {
await privateInputFiles?.cleanup()
await lease.release()
} finally {
await acceptSessionOutputHistory(lease.session ? req.session : undefined, {
providerId: created.providerId,
sandboxId,
})
}
}
}

Expand Down
Loading
Loading