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
61 changes: 60 additions & 1 deletion apps/sim/lib/execution/durable-secret-provenance-telemetry.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
AuditAction,
type AuditLogParams,
AuditResourceType,
recordAudit,
recordAuditBatch,
} from '@sim/audit'
import { createLogger } from '@sim/logger'

const persistenceLogger = createLogger('DurableSecretProvenancePersistence')
Expand Down Expand Up @@ -28,7 +35,6 @@ export type DurableSecretProvenanceRefusalCause =
| 'workspace-file-provenance-unavailable'
| 'workspace-file-opaque-secret-content'
| 'workspace-file-registry-unavailable'
| 'workspace-file-unrecorded-enforced'

export interface DurableSecretProvenanceRefusalReport {
surface: DurableSecretProvenanceSurface
Expand Down Expand Up @@ -65,3 +71,56 @@ export function reportDurableSecretProvenanceRefusal(
...(report.resourceId ? { resourceId: report.resourceId } : {}),
})
}

export interface DurableSecretProvenanceUnrecordedReport {
surface: DurableSecretProvenanceSurface
workspaceId?: string
organizationId?: string
resourceId?: string
recordCount?: number
actorUserId?: string
}

function unrecordedProvenanceAuditEntry(
report: DurableSecretProvenanceUnrecordedReport
): AuditLogParams | undefined {
const metadata = {
surface: report.surface,
...(report.organizationId ? { organizationId: report.organizationId } : {}),
...(report.recordCount !== undefined ? { recordCount: report.recordCount } : {}),
}
persistenceLogger.warn('Using content without recorded secret provenance', {
...metadata,
...(report.workspaceId ? { workspaceId: report.workspaceId } : {}),
...(report.resourceId ? { resourceId: report.resourceId } : {}),
})
if (!report.workspaceId && !report.organizationId) return undefined
return {
workspaceId: report.organizationId ? null : report.workspaceId,
actorId: report.actorUserId ?? null,
action: AuditAction.SECRET_PROVENANCE_UNRECORDED,
resourceType: AuditResourceType.SECRET_PROVENANCE,
...(report.resourceId ? { resourceId: report.resourceId } : {}),
description: 'Used content without recorded secret provenance',
metadata,
}
}

/** Records accepted content whose producer did not supply provenance, without recording bytes. */
export function reportDurableSecretProvenanceUnrecorded(
report: DurableSecretProvenanceUnrecordedReport
): void {
const entry = unrecordedProvenanceAuditEntry(report)
if (entry) recordAudit(entry)
}

/** Batches tenant-scoped admission events without one pooled database query per source. */
export function reportDurableSecretProvenanceUnrecordedBatch(
reports: readonly DurableSecretProvenanceUnrecordedReport[]
): void {
const entries = reports.flatMap((report) => {
const entry = unrecordedProvenanceAuditEntry(report)
return entry ? [entry] : []
})
if (entries.length > 0) recordAuditBatch(entries)
}
92 changes: 68 additions & 24 deletions apps/sim/lib/function-execution/execute-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2264,11 +2264,13 @@ describe('Function execution request', () => {
)

it.each([
{ reason: 'source-provenance-incomplete', status: 200 },
{ reason: 'entry-decrypt-failed', status: 400 },
{ reason: 'source-provenance-incomplete', status: 200, knownMount: false, text: false },
{ reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: false },
{ reason: 'source-provenance-incomplete', status: 200, knownMount: true, text: true },
{ reason: 'entry-decrypt-failed', status: 400, knownMount: false, text: false },
] as const)(
'distinguishes historical absence from provenance faults: $reason',
async ({ reason, status }) => {
'distinguishes historical absence from provenance faults: $reason knownMount=$knownMount text=$text',
async ({ reason, status, knownMount, text }) => {
envFlagsMock.isRemoteSandboxEnabled = true
const registry = new ResolvedSecretTraceRegistry([], {
userId: 'user-123',
Expand All @@ -2278,7 +2280,7 @@ describe('Function execution request', () => {
const contentUpdatedAt = new Date('2026-01-01T00:00:00Z')
mockMountContributors.mockReturnValue([
{
fileId: 'legacy-file',
fileId: knownMount ? 'known-file' : 'legacy-file',
key: 'execution/workspace-1/workflow-1/execution-1/a/input.txt',
context: 'execution',
contentUpdatedAt,
Expand All @@ -2287,25 +2289,38 @@ describe('Function execution request', () => {
dbChainMockFns.limit.mockResolvedValue([
{
fileContentUpdatedAt: contentUpdatedAt,
secretProvenanceVersion: null,
provenanceContentUpdatedAt: null,
status: null,
entries: null,
secretProvenanceVersion: knownMount ? 1 : null,
provenanceContentUpdatedAt: knownMount ? contentUpdatedAt : null,
status: knownMount ? 'exact' : null,
entries: knownMount
? [
{
name: 'API_KEY',
encryptedValue: 'encrypted:mounted-secret',
sourceUserId: 'user-123',
sourceWorkspaceId: 'workspace-1',
},
]
: null,
},
])
const buffer = Buffer.from('ordinary file')
mockExecuteInSandbox.mockResolvedValueOnce({
const buffer = Buffer.from(text ? 'Bearer mounted-secret' : 'ordinary file')
mockExecuteInSandbox.mockResolvedValue({
result: null,
stdout: '',
sandboxId: 'sbx',
collectedFiles: [
{
relativePath: 'report.zip',
path: '/tmp/sim/outputs/report.zip',
contentBase64: buffer.toString('base64'),
byteLength: buffer.length,
},
],
...(text
? { exportedFiles: { '/home/user/report.txt': buffer.toString('utf8') } }
: {
collectedFiles: [
{
relativePath: 'report.zip',
path: '/tmp/sim/outputs/report.zip',
contentBase64: buffer.toString('base64'),
byteLength: buffer.length,
},
],
}),
})
const response = await POST(
createMockRequest('POST', {
Expand All @@ -2314,13 +2329,42 @@ describe('Function execution request', () => {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
...(text
? {
outputs: {
files: [
{
path: 'files/report.txt',
sandboxPath: '/home/user/report.txt',
mimeType: 'text/plain',
},
],
},
}
: {}),
}),
registry
)
expect(response.status).toBe(status)
if (status === 200)
expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({ status: 'unrecorded' })
else expect(mockUploadExecutionFile).not.toHaveBeenCalled()
if (status === 200) {
if (text) {
expect(mockWriteWorkspaceFileByPath.mock.calls[0][0].secretProvenance).toEqual({
status: 'exact',
entries: [
{
name: 'API_KEY',
encryptedValue: 'encrypted:mounted-secret',
sourceUserId: 'user-123',
sourceWorkspaceId: 'workspace-1',
},
],
})
} else {
expect(mockUploadExecutionFile.mock.calls[0][5]).toEqual({
status: knownMount ? 'unknown' : 'unrecorded',
})
}
} else expect(mockUploadExecutionFile).not.toHaveBeenCalled()
}
)

Expand Down Expand Up @@ -2567,7 +2611,7 @@ describe('Function execution request', () => {
it.each([
['a mount with no provenance source', true],
['no mounts', false],
] as const)('withholds workbench certification for %s', async (_label, mounted) => {
] as const)('keeps workbench use available for %s', async (_label, mounted) => {
envFlagsMock.isMothershipSandboxEnabled = true
mockUnprovenancedMountCount.mockReturnValue(mounted ? 1 : 0)
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
Expand All @@ -2588,7 +2632,7 @@ describe('Function execution request', () => {
expect(response.status).toBe(200)
const session = mockExecuteInSandbox.mock.calls.at(-1)?.[0].session
expect(session.key).toBe('chat-session')
expect(session.unprovenancedInputs === true).toBe(mounted)
expect(session.unprovenancedInputs).not.toBe(true)
})

it('gives overlapping calls in one persistent workbench distinct automatic export directories', async () => {
Expand Down
8 changes: 7 additions & 1 deletion apps/sim/lib/function-execution/execute-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1089,6 +1089,10 @@ async function importRuntimeInputProvenance(
})
if (decision.safe && decision.provenance.status === 'unrecorded') {
context.runtimeInputProvenanceUnrecorded = true
context.runtimeFileSecretTraceRegistry = new ResolvedSecretTraceRegistry([], {
userId: context.attributedUserId,
workspaceId: context.workspaceId,
})
return
}
}
Expand Down Expand Up @@ -2718,6 +2722,9 @@ export async function executeFunctionRequest(
logger,
},
})
if (resolvedMounts.unprovenancedMountCount > 0) {
routeContext.runtimeInputProvenanceUnrecorded = true
Comment thread
icecrasher321 marked this conversation as resolved.
Comment thread
icecrasher321 marked this conversation as resolved.
}
await importRuntimeFileContributors(
routeContext,
resolvedMounts.contributingFiles,
Expand Down Expand Up @@ -2747,7 +2754,6 @@ export async function executeFunctionRequest(
const mothershipSession = admittedSession
? {
...admittedSession,
unprovenancedInputs: resolvedMounts.unprovenancedMountCount > 0,
inputProvenance: () => {
const runtime = activeRouteContext.runtimeFileSecretTraceRegistry?.exportProvenance()
return mergeDurableSecretProvenance(
Expand Down
19 changes: 10 additions & 9 deletions apps/sim/lib/function-execution/sandbox-mounts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { reportDurableSecretProvenanceUnrecorded } from '@/lib/execution/durable-secret-provenance-telemetry'
import { resolveStoredFileProvenanceSource } from '@/lib/execution/payloads/file-secret-provenance'
import {
assertUserFileContentAccess,
Expand Down Expand Up @@ -277,15 +278,7 @@ export async function resolveUserFileMounts(args: {
manifest: SandboxMountManifestEntry[]
contributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
renderedContributingFiles?: readonly WorkspaceFileSecretProvenanceIdentity[]
/**
* Mounts whose own bytes have no provenance source (no principal to bind one, or a key with no
* canonical metadata record). Workflow runs keep their legacy absence policy; a persistent
* workbench must not certify a machine that received one.
*
* Storage contexts other than workspace and execution (chat, copilot, knowledge-base, logs, and
* the other public contexts) never have a source, so they always count here and taint a
* workbench. That is conservative by design.
*/
/** Mounts without producer provenance remain usable and report the recording gap. */
unprovenancedMountCount: number
}> {
let unprovenancedMountCount = 0
Expand Down Expand Up @@ -365,6 +358,14 @@ export async function resolveUserFileMounts(args: {
})
}

if (unprovenancedMountCount > 0) {
reportDurableSecretProvenanceUnrecorded({
surface: 'workspace-file',
workspaceId: args.context.workspaceId,
actorUserId: args.context.userId,
recordCount: unprovenancedMountCount,
})
}
logger.info('Resolved sandbox file mounts', {
mountCount: sandboxFiles.length,
bufferedBytes: budget.buffered,
Expand Down
10 changes: 6 additions & 4 deletions apps/sim/lib/internal/file/operations.provenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/app/api/files/authorization', () => filesAuthorizationMock)

vi.mock('@/lib/execution/durable-secret-provenance-telemetry', () => ({
reportDurableSecretProvenanceUnrecorded: vi.fn(),
reportDurableSecretProvenanceUnrecordedBatch: vi.fn(),
reportDurableSecretProvenanceWrite: vi.fn(),
reportDurableSecretProvenanceRefusal: vi.fn(),
}))
Expand Down Expand Up @@ -237,7 +239,7 @@ describe('appended file provenance', () => {
})

it.each([
{ predecessor: 'unrecorded', secret: true, expectedStatus: 'unknown' },
{ predecessor: 'unrecorded', secret: true, expectedStatus: 'exact' },
{ predecessor: 'exact', secret: true, expectedStatus: 'exact' },
{ predecessor: 'legacy', secret: true, expectedStatus: 'exact' },
{ predecessor: 'unrecorded', secret: false, expectedStatus: 'unrecorded' },
Expand Down Expand Up @@ -315,7 +317,7 @@ describe('appended file provenance', () => {
view: 'complete',
value: `before:${content}`,
})
expect(permitted).toBe(expectedStatus === 'exact')
expect(permitted).toBe(expectedStatus !== 'unknown')
if (permitted) {
expect(projectResolvedSecretModelContent(`before:${content}`, registry)).toEqual({
safe: true,
Expand All @@ -326,7 +328,7 @@ describe('appended file provenance', () => {
joinedRow(persisted.status, persisted.entries, persisted.contentUpdatedAt),
])
expect(await isOpaqueWorkspaceFileEgressSafe('workspace-1', IDENTITY)).toBe(
expectedStatus === 'exact' && !secret
expectedStatus !== 'unknown' && !secret
)
}
)
Expand All @@ -352,7 +354,7 @@ describe('execution-file content provenance', () => {

it.each([
{ status: 'exact', version: 1, stale: false, complete: true },
{ status: 'unrecorded', version: 1, stale: false, complete: false },
{ status: 'unrecorded', version: 1, stale: false, complete: true },
{ status: 'unknown', version: 1, stale: false, complete: false },
{ status: 'unknown', version: null, stale: false, complete: true },
{ status: 'exact', version: 1, stale: true, complete: false },
Expand Down
15 changes: 14 additions & 1 deletion apps/sim/lib/internal/file/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { ensureAbsoluteUrl } from '@/lib/core/utils/urls'
import { isUserFile } from '@/lib/core/utils/user-file'
import { durableSecretProvenanceFromPrivateBundle } from '@/lib/execution/durable-secret-provenance'
import { reportDurableSecretProvenanceUnrecorded } from '@/lib/execution/durable-secret-provenance-telemetry'
import {
inspectPrivateSecretProvenanceRequest,
isPrivateSecretProvenanceBundleV1,
Expand Down Expand Up @@ -596,6 +597,7 @@ export async function getFileContentProvenance(
? { userId: ownerUserId, workspaceId }
: undefined
const accumulator = new ResolvedSecretTraceProvenanceAccumulator(scope)
let unrecorded = 0

for (const source of sources) {
signal?.throwIfAborted()
Expand All @@ -605,7 +607,11 @@ export async function getFileContentProvenance(
}
const provenance = await readFileSourceSecretProvenance(principal, workspaceId, source.identity)
signal?.throwIfAborted()
if (provenance.status !== 'exact' || (source.opaque && provenance.entries.length > 0)) {
if (provenance.status === 'unrecorded') {
unrecorded += 1
continue
}
if (provenance.status === 'unknown' || (source.opaque && provenance.entries.length > 0)) {
accumulator.markIncomplete('workspace-file-provenance-unknown')
continue
}
Expand All @@ -625,6 +631,13 @@ export async function getFileContentProvenance(
})
}

if (unrecorded > 0) {
reportDurableSecretProvenanceUnrecorded({
surface: 'workspace-file',
workspaceId,
recordCount: unrecorded,
})
}
return accumulator.exportProvenance()
}

Expand Down
Loading
Loading