Skip to content

Commit 6e5c8a1

Browse files
committed
add provenance linters
1 parent 7636c7d commit 6e5c8a1

25 files changed

Lines changed: 883 additions & 351 deletions

.github/workflows/test-build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ jobs:
156156
- name: Tool registry client-boundary audit
157157
run: bun run check:tool-registry-boundary
158158

159+
- name: Tool request transport boundary audit
160+
run: bun run check:tool-request-boundary
161+
159162
- name: Verify generated tool metadata is in sync
160163
run: bun run tool-metadata:check
161164

apps/sim/app/api/knowledge/secret-provenance.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ export function resolveKnowledgeWriteSecretProvenance(options: {
4646
workspaceId?: string
4747
selectionKeys: readonly string[]
4848
}): KnowledgeWriteProvenanceResolution {
49-
const inspection = inspectPrivateSecretProvenanceRequest(options.request.headers, options.payload)
49+
const { request } = options
50+
const inspection = inspectPrivateSecretProvenanceRequest(request.headers, options.payload)
5051
if (inspection.status === 'unsupported') {
5152
return options.authType === AuthType.INTERNAL_JWT
5253
? { success: true }
@@ -142,8 +143,9 @@ export async function createKnowledgeProvenanceResponse(options: {
142143
body: Record<string, unknown>
143144
provenances: readonly DurableSecretProvenance[]
144145
}): Promise<NextResponse> {
146+
const { request } = options
145147
const negotiation = negotiatePrivateToolMetadataResponse(
146-
options.request.headers,
148+
request.headers,
147149
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
148150
options.authType === AuthType.INTERNAL_JWT
149151
)
@@ -179,8 +181,9 @@ export function createKnowledgeRegistryResponse(options: {
179181
body: Record<string, unknown>
180182
registry: ResolvedSecretTraceRegistry
181183
}): NextResponse {
184+
const { request } = options
182185
const negotiation = negotiatePrivateToolMetadataResponse(
183-
options.request.headers,
186+
request.headers,
184187
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
185188
options.authType === AuthType.INTERNAL_JWT
186189
)
@@ -213,8 +216,9 @@ export async function createKnowledgePersistedResponse(options: {
213216
value: unknown
214217
}[]
215218
}): Promise<NextResponse> {
219+
const { request } = options
216220
const negotiation = negotiatePrivateToolMetadataResponse(
217-
options.request.headers,
221+
request.headers,
218222
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
219223
options.authType === AuthType.INTERNAL_JWT
220224
)

apps/sim/app/api/memory/secret-provenance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ export function resolveMemoryWriteSecretProvenance(options: {
4747
}):
4848
| { success: true; provenance?: DurableSecretProvenance }
4949
| { success: false; response: NextResponse } {
50-
const inspection = inspectPrivateSecretProvenanceRequest(options.request.headers, options.payload)
50+
const { request } = options
51+
const inspection = inspectPrivateSecretProvenanceRequest(request.headers, options.payload)
5152
if (inspection.status === 'unsupported') {
5253
return options.authType === AuthType.INTERNAL_JWT
5354
? { success: true }
@@ -81,8 +82,9 @@ export async function createMemoryResponse(options: {
8182
body: Record<string, unknown>
8283
memories: MemoryCrossing[]
8384
}): Promise<NextResponse> {
85+
const { request } = options
8486
const negotiation = negotiatePrivateToolMetadataResponse(
85-
options.request.headers,
87+
request.headers,
8688
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
8789
options.authType === AuthType.INTERNAL_JWT
8890
)

apps/sim/app/api/table/row-secret-provenance.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ export function resolveTableWriteSecretProvenance(options: {
7070
targets: TableWriteProvenanceTarget[]
7171
rowKeys: string[]
7272
}): TableWriteProvenanceResult {
73-
const inspection = inspectPrivateSecretProvenanceRequest(options.request.headers, options.payload)
73+
const { request } = options
74+
const inspection = inspectPrivateSecretProvenanceRequest(request.headers, options.payload)
7475
if (inspection.status === 'unsupported') {
7576
if (options.authType === AuthType.INTERNAL_JWT) {
7677
return { success: true, provenanceByRowKey: undefined }
@@ -154,8 +155,9 @@ export async function createTableRowsResponse(options: {
154155
body: Record<string, unknown>
155156
rows: TableRowCrossing[]
156157
}): Promise<NextResponse> {
158+
const { request } = options
157159
const negotiation = negotiatePrivateToolMetadataResponse(
158-
options.request.headers,
160+
request.headers,
159161
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
160162
options.authType === AuthType.INTERNAL_JWT
161163
)

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,8 +1692,9 @@ export function useWorkflowExecution() {
16921692
}
16931693

16941694
let notificationMessage = WORKFLOW_EXECUTION_FAILURE_MESSAGE
1695-
if (isRecord(error) && isRecord(error.request) && sanitizeMessage(error.request.url)) {
1696-
notificationMessage += `: Request to ${(error.request.url as string).trim()} failed`
1695+
const requestError = isRecord(error) && isRecord(error.request) ? error.request : undefined
1696+
if (requestError && sanitizeMessage(requestError.url)) {
1697+
notificationMessage += `: Request to ${(requestError.url as string).trim()} failed`
16971698
if ('status' in error && typeof error.status === 'number') {
16981699
notificationMessage += ` (Status: ${error.status})`
16991700
}

apps/sim/executor/handlers/mothership/mothership-handler.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ vi.mock('@/lib/execution/cancellation', () => ({
7272
}))
7373

7474
vi.mock('@/lib/execution/payloads/materialization.server', () => ({
75-
MAX_INLINE_MATERIALIZATION_BYTES: 50 * 1024 * 1024,
7675
readUserFileContent: mockReadUserFileContent,
7776
}))
7877

apps/sim/executor/utils/resolved-secret-content-projection.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isResolvedSecretModelContentUnchanged,
77
projectResolvedSecretDiagnosticError,
88
projectResolvedSecretModelContent,
9+
projectResolvedSecretModelJsonContent,
910
projectResolvedSecretModelJsonStrings,
1011
} from '@/executor/utils/resolved-secret-content-projection'
1112
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -288,6 +289,88 @@ describe('projectResolvedSecretModelContent', () => {
288289
})
289290
})
290291

292+
describe('projectResolvedSecretModelJsonContent', () => {
293+
it('normalizes dates using their JSON wire representation', () => {
294+
const registry = new ResolvedSecretTraceRegistry()
295+
const createdAt = new Date('2026-08-05T12:34:56.789Z')
296+
297+
expect(projectResolvedSecretModelJsonContent({ createdAt }, registry)).toEqual({
298+
safe: true,
299+
value: { createdAt: '2026-08-05T12:34:56.789Z' },
300+
})
301+
expect(createdAt).toBeInstanceOf(Date)
302+
})
303+
304+
it('projects active secrets emitted by toJSON after materialization', () => {
305+
const registry = new ResolvedSecretTraceRegistry([
306+
{ name: 'TOKEN', plaintext: 'secret-value', encryptedValue: 'ciphertext' },
307+
])
308+
registry.recordResolved('TOKEN', 'secret-value')
309+
310+
expect(
311+
projectResolvedSecretModelJsonContent(
312+
{
313+
toJSON: () => ({ authorization: 'Bearer secret-value' }),
314+
},
315+
registry
316+
)
317+
).toEqual({
318+
safe: true,
319+
value: { authorization: 'Bearer {{TOKEN}}' },
320+
})
321+
})
322+
323+
it('does not invoke JSON serialization when provenance is incomplete', () => {
324+
const registry = new ResolvedSecretTraceRegistry()
325+
registry.markIncomplete()
326+
const toJSON = vi.fn(() => ({ value: 'untrusted' }))
327+
328+
expect(projectResolvedSecretModelJsonContent({ toJSON }, registry)).toEqual({ safe: false })
329+
expect(toJSON).not.toHaveBeenCalled()
330+
})
331+
332+
it('uses native JSON semantics for undefined and non-finite numbers', () => {
333+
const registry = new ResolvedSecretTraceRegistry()
334+
335+
expect(
336+
projectResolvedSecretModelJsonContent(
337+
{
338+
omitted: undefined,
339+
undefinedInArray: [undefined],
340+
nan: Number.NaN,
341+
infinities: [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
342+
},
343+
registry
344+
)
345+
).toEqual({
346+
safe: true,
347+
value: {
348+
undefinedInArray: [null],
349+
nan: null,
350+
infinities: [null, null],
351+
},
352+
})
353+
})
354+
355+
it('returns a controlled unsafe result for values JSON cannot serialize', () => {
356+
const registry = new ResolvedSecretTraceRegistry()
357+
const cyclic: Record<string, unknown> = {}
358+
cyclic.self = cyclic
359+
360+
expect(projectResolvedSecretModelJsonContent(cyclic, registry)).toEqual({ safe: false })
361+
expect(projectResolvedSecretModelJsonContent({ value: 1n }, registry)).toEqual({ safe: false })
362+
})
363+
364+
it('enforces the byte limit after secret aliases are projected', () => {
365+
const registry = new ResolvedSecretTraceRegistry([
366+
{ name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' },
367+
])
368+
registry.recordResolved('X', 'x')
369+
370+
expect(projectResolvedSecretModelJsonContent({ a: 'x' }, registry, 9)).toEqual({ safe: false })
371+
})
372+
})
373+
291374
describe('projectResolvedSecretDiagnosticError', () => {
292375
it('projects plaintext and internal aliases without mutating the runtime error', () => {
293376
const secret = 'diagnostic-secret-value'

apps/sim/executor/utils/resolved-secret-content-projection.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -495,10 +495,42 @@ export function projectResolvedSecretModelContent(
495495
})
496496
}
497497

498+
/**
499+
* Projects content after applying the same normalization as a JSON wire boundary. This converts
500+
* values such as dates through their native `toJSON` representation, omits unsupported object
501+
* fields, and rejects values that JSON itself cannot serialize.
502+
*/
503+
export function projectResolvedSecretModelJsonContent(
504+
value: unknown,
505+
registry: ResolvedSecretTraceRegistry | undefined,
506+
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
507+
options: ResolvedSecretContentProjectionOptions = {}
508+
): ResolvedSecretContentProjection {
509+
if (!getResolvedSecretModelMatcher(registry).complete) return { safe: false }
510+
511+
try {
512+
const encoded = JSON.stringify(value)
513+
if (encoded === undefined || Buffer.byteLength(encoded, 'utf8') > maxBytes) {
514+
return { safe: false }
515+
}
516+
const normalized: unknown = JSON.parse(encoded)
517+
const projection = projectResolvedSecretModelContent(normalized, registry, maxBytes, options)
518+
if (!projection.safe) return projection
519+
520+
const projectedEncoding = JSON.stringify(projection.value)
521+
return projectedEncoding !== undefined &&
522+
Buffer.byteLength(projectedEncoding, 'utf8') <= maxBytes
523+
? projection
524+
: { safe: false }
525+
} catch {
526+
return { safe: false }
527+
}
528+
}
529+
498530
/**
499531
* Projects logger-visible diagnostics and additionally removes internal-looking runtime names.
500-
* Model and tool-result boundaries must use `projectResolvedSecretModelContent` so unrelated data
501-
* remains byte-preserving unless it matches execution provenance.
532+
* Non-JSON model boundaries use `projectResolvedSecretModelContent`; JSON tool-result boundaries
533+
* use `projectResolvedSecretModelJsonContent` to apply their wire semantics before projection.
502534
*/
503535
export function projectResolvedSecretDiagnosticContent(
504536
value: unknown,

apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,28 @@ describe('projectToolResultForCopilot', () => {
226226
})
227227
})
228228

229+
it('serializes table-style dates for Copilot without mutating the runtime result', () => {
230+
const registry = new ResolvedSecretTraceRegistry()
231+
const createdAt = new Date('2026-08-05T12:34:56.789Z')
232+
const runtimeResult = {
233+
success: true,
234+
output: {
235+
table: { id: 'table-1', createdAt },
236+
rows: [{ id: 'row-1', createdAt }],
237+
},
238+
}
239+
240+
expect(projectToolResultForCopilot(runtimeResult, registry)).toEqual({
241+
success: true,
242+
output: {
243+
table: { id: 'table-1', createdAt: '2026-08-05T12:34:56.789Z' },
244+
rows: [{ id: 'row-1', createdAt: '2026-08-05T12:34:56.789Z' }],
245+
},
246+
})
247+
expect(runtimeResult.output.table.createdAt).toBe(createdAt)
248+
expect(runtimeResult.output.rows[0].createdAt).toBe(createdAt)
249+
})
250+
229251
it('preserves foreign internal-looking tool output when the registry has no matching alias', () => {
230252
const registry = new ResolvedSecretTraceRegistry()
231253

apps/sim/lib/copilot/request/tools/resolved-secret-result.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
isResolvedSecretModelContentUnchanged,
66
projectResolvedSecretModelContent,
77
projectResolvedSecretModelControlMessage,
8+
projectResolvedSecretModelJsonContent,
89
} from '@/executor/utils/resolved-secret-content-projection'
910
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
1011

@@ -124,7 +125,7 @@ export function inspectToolResultForCopilot(
124125
if (Object.hasOwn(result, 'output')) content.output = result.output
125126
if (Object.hasOwn(result, 'error')) content.error = result.error
126127
if (resources !== undefined) content.resources = resourceContent(resources)
127-
const projection = projectResolvedSecretModelContent(content, registry)
128+
const projection = projectResolvedSecretModelJsonContent(content, registry)
128129
if (!projection.safe || !projection.value || typeof projection.value !== 'object') {
129130
return { safe: false, result: omittedResult(result, registry) }
130131
}

0 commit comments

Comments
 (0)