Skip to content

Commit a005f4b

Browse files
committed
Merge origin/staging into fix/midrun-usage-enforcement
2 parents 074f4b8 + 9703b07 commit a005f4b

17 files changed

Lines changed: 1517 additions & 112 deletions

‎apps/sim/app/api/copilot/tools/execute/route.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { withIncomingGoSpan } from '@/lib/mothership/request/otel'
1414
import {
1515
describeWithholdingCause,
1616
inspectToolResultForCopilot,
17+
measureWithheldContent,
1718
projectToolErrorMessageForCopilot,
1819
} from '@/lib/mothership/request/tools/resolved-secret-result'
1920
import { handleResourceSideEffects } from '@/lib/mothership/request/tools/resources'
@@ -235,6 +236,7 @@ export const POST = withRouteHandler((request: NextRequest) =>
235236
toolCallId,
236237
runtimeSucceeded: result.success,
237238
...describeWithholdingCause(projection.cause),
239+
...measureWithheldContent(result),
238240
})
239241
}
240242
if (!projected.success) {

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, expect, it, vi } from 'vitest'
22
import {
33
createResolvedSecretMatcher,
4+
MAX_CONTENT_NODES,
5+
MAX_MODEL_CONTENT_BYTES,
6+
measureModelContent,
47
projectResolvedSecretContent,
58
projectResolvedSecretDiagnosticError,
69
projectResolvedSecretModelContent,
@@ -307,3 +310,48 @@ describe('literals too small to identify anything', () => {
307310
})
308311
})
309312
})
313+
314+
/**
315+
* The measure feeds budgets and log lines for payloads that may be far over the caps, so it must
316+
* report "over" without materializing them, and must agree with the projection on what is over.
317+
*/
318+
describe('measureModelContent', () => {
319+
it('reports a string past the byte cap as over it', () => {
320+
const measure = measureModelContent({ text: 'x'.repeat(MAX_MODEL_CONTENT_BYTES + 1) })
321+
expect(measure).toMatchObject({ exceeded: true })
322+
})
323+
324+
it('reports content past the value cap as over it', () => {
325+
const measure = measureModelContent(Array.from({ length: MAX_CONTENT_NODES + 10 }, () => 1))
326+
expect(measure).toMatchObject({ exceeded: true })
327+
})
328+
329+
it('reports content nested past the projection depth limit as over it', () => {
330+
let deep: Record<string, unknown> = { leaf: 1 }
331+
for (let level = 0; level < 150; level += 1) deep = { next: deep }
332+
expect(measureModelContent(deep)).toMatchObject({ exceeded: true })
333+
})
334+
335+
it('measures ordinary content exactly, as JSON encodes it', () => {
336+
const value = { a: 'é', list: [1, null, true], at: new Date(0) }
337+
expect(measureModelContent(value)).toEqual({
338+
exceeded: false,
339+
values: 7,
340+
bytes: Buffer.byteLength(JSON.stringify(value), 'utf8'),
341+
})
342+
})
343+
344+
it.each([
345+
['a BigInt', { n: BigInt(1) }],
346+
[
347+
'a cycle',
348+
(() => {
349+
const cyclic: Record<string, unknown> = {}
350+
cyclic.self = cyclic
351+
return cyclic
352+
})(),
353+
],
354+
])('returns nothing for %s, which JSON cannot encode', (_label, value) => {
355+
expect(measureModelContent(value)).toBeUndefined()
356+
})
357+
})

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

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export {
2323
scanResolvedSecretString,
2424
} from '@/executor/utils/resolved-secret-matcher'
2525

26-
const MAX_CONTENT_NODES = 100_000
26+
/** Values one model-content projection will walk before refusing the whole payload. */
27+
export const MAX_CONTENT_NODES = 100_000
28+
/** Encoded bytes a model-content projection accepts by default before refusing the payload. */
29+
export const MAX_MODEL_CONTENT_BYTES = MAX_INLINE_MATERIALIZATION_BYTES
2730
const MAX_CONTENT_DEPTH = 100
2831
const INTERNAL_DIAGNOSTIC_IDENTIFIER_PATTERN =
2932
/__var_[A-Za-z0-9_]+|__sim_code_\d+_(?:binding|input|runtime)_\d+[A-Za-z0-9_]*|__sim_placeholder_[a-f0-9]{64}__|__sim_runtime_[A-Za-z0-9_]+_\d+[A-Za-z0-9_]*|__SIM_RUNTIME_PAYLOAD_PATH/g
@@ -351,12 +354,116 @@ function projectContent(
351354
export function projectResolvedSecretContent(
352355
value: unknown,
353356
matcher: ResolvedSecretMatcher,
354-
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
357+
maxBytes = MAX_MODEL_CONTENT_BYTES,
355358
options: ResolvedSecretContentProjectionOptions = {}
356359
): ResolvedSecretContentProjection {
357360
return projectContent(value, matcher, maxBytes, options)
358361
}
359362

363+
/** Size of a value as the model-content projection sees it, for budgeting and diagnostics. */
364+
export interface ModelContentMeasure {
365+
/** Values walked: the root and every array item and object property value JSON encodes. */
366+
values: number
367+
/** Bytes of the value's JSON encoding. */
368+
bytes: number
369+
/**
370+
* True once the walk passed the projection's value, byte, or depth limit and stopped; `values`
371+
* and `bytes` are then only what was counted before stopping.
372+
*/
373+
exceeded: boolean
374+
}
375+
376+
class ModelContentMeasureExceeded extends Error {}
377+
class ModelContentUnencodable extends Error {}
378+
379+
/**
380+
* Measures a value in the units the projection caps, following JSON's encoding rules, without
381+
* serializing it: strings are measured one at a time and only while they fit the remaining byte
382+
* budget, and the walk stops at the first limit it passes. Returns undefined for a value JSON
383+
* cannot encode (a BigInt, a cycle), which the projection refuses too.
384+
*/
385+
export function measureModelContent(value: unknown): ModelContentMeasure | undefined {
386+
let values = 0
387+
let bytes = 0
388+
const ancestors = new Set<object>()
389+
390+
const addBytes = (count: number): void => {
391+
bytes += count
392+
if (bytes > MAX_MODEL_CONTENT_BYTES) throw new ModelContentMeasureExceeded()
393+
}
394+
const addString = (text: string): void => {
395+
// A JSON string is never shorter than its UTF-16 length plus its quotes.
396+
if (text.length + 2 > MAX_MODEL_CONTENT_BYTES - bytes) throw new ModelContentMeasureExceeded()
397+
addBytes(Buffer.byteLength(JSON.stringify(text), 'utf8'))
398+
}
399+
const walk = (raw: unknown, key: string, depth: number): void => {
400+
const item =
401+
raw !== null &&
402+
typeof raw === 'object' &&
403+
typeof (raw as { toJSON?: unknown }).toJSON === 'function'
404+
? (raw as { toJSON: (key: string) => unknown }).toJSON(key)
405+
: raw
406+
values += 1
407+
if (values > MAX_CONTENT_NODES || depth > MAX_CONTENT_DEPTH) {
408+
throw new ModelContentMeasureExceeded()
409+
}
410+
if (typeof item === 'string') {
411+
addString(item)
412+
return
413+
}
414+
if (typeof item === 'number') {
415+
addBytes(Number.isFinite(item) ? String(item).length : 4)
416+
return
417+
}
418+
if (typeof item === 'boolean') {
419+
addBytes(item ? 4 : 5)
420+
return
421+
}
422+
if (typeof item === 'bigint') throw new ModelContentUnencodable()
423+
if (item === null || typeof item !== 'object') {
424+
addBytes(4)
425+
return
426+
}
427+
if (ancestors.has(item)) throw new ModelContentUnencodable()
428+
ancestors.add(item)
429+
if (Array.isArray(item)) {
430+
addBytes(2 + Math.max(0, item.length - 1))
431+
for (const [index, child] of item.entries()) {
432+
if (child === undefined || typeof child === 'function' || typeof child === 'symbol') {
433+
values += 1
434+
addBytes(4)
435+
} else {
436+
walk(child, String(index), depth + 1)
437+
}
438+
}
439+
} else {
440+
addBytes(2)
441+
let first = true
442+
for (const [childKey, child] of Object.entries(item)) {
443+
if (child === undefined || typeof child === 'function' || typeof child === 'symbol')
444+
continue
445+
if (!first) addBytes(1)
446+
first = false
447+
addString(childKey)
448+
addBytes(1)
449+
walk(child, childKey, depth + 1)
450+
}
451+
}
452+
ancestors.delete(item)
453+
}
454+
455+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
456+
return { values: 0, bytes: 0, exceeded: false }
457+
}
458+
try {
459+
walk(value, '', 0)
460+
return { values, bytes, exceeded: false }
461+
} catch (error) {
462+
if (error instanceof ModelContentMeasureExceeded) return { values, bytes, exceeded: true }
463+
return undefined
464+
}
465+
}
466+
360467
/** Returns the registry-revision-cached matcher used for all model-visible projection. */
361468
export function getResolvedSecretModelMatcher(
362469
registry: ResolvedSecretTraceRegistry | undefined
@@ -396,7 +503,7 @@ export function getResolvedSecretModelMatcher(
396503
export function projectResolvedSecretModelContent(
397504
value: unknown,
398505
registry: ResolvedSecretTraceRegistry | undefined,
399-
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
506+
maxBytes = MAX_MODEL_CONTENT_BYTES,
400507
options: ResolvedSecretContentProjectionOptions = {}
401508
): ResolvedSecretContentProjection {
402509
const snapshot = getResolvedSecretModelMatcher(registry)
@@ -419,7 +526,7 @@ export function projectResolvedSecretModelContent(
419526
export function projectResolvedSecretModelJsonContent(
420527
value: unknown,
421528
registry: ResolvedSecretTraceRegistry | undefined,
422-
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES,
529+
maxBytes = MAX_MODEL_CONTENT_BYTES,
423530
options: ResolvedSecretContentProjectionOptions = {}
424531
): ResolvedSecretContentProjection {
425532
const snapshot = getResolvedSecretModelMatcher(registry)
@@ -455,7 +562,7 @@ export function projectResolvedSecretModelJsonContent(
455562
export function projectResolvedSecretDiagnosticContent(
456563
value: unknown,
457564
registry: ResolvedSecretTraceRegistry | undefined,
458-
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
565+
maxBytes = MAX_MODEL_CONTENT_BYTES
459566
): ResolvedSecretContentProjection {
460567
return projectResolvedSecretModelContent(value, registry, maxBytes, {
461568
sanitizeInternalIdentifiers: true,
@@ -509,7 +616,7 @@ export function isResolvedSecretModelContentUnchanged(
509616
if (!snapshot.complete) return false
510617
if (!snapshot.matcher) return true
511618

512-
const projection = projectContent(value, snapshot.matcher, MAX_INLINE_MATERIALIZATION_BYTES, {
619+
const projection = projectContent(value, snapshot.matcher, MAX_MODEL_CONTENT_BYTES, {
513620
projectPrimitiveLiterals: true,
514621
rejectResolvedSecretLiterals: true,
515622
})
@@ -523,7 +630,7 @@ export function isResolvedSecretModelContentUnchanged(
523630
export function projectResolvedSecretModelJsonStrings(
524631
values: readonly (string | undefined)[],
525632
registry: ResolvedSecretTraceRegistry | undefined,
526-
maxBytes = MAX_INLINE_MATERIALIZATION_BYTES
633+
maxBytes = MAX_MODEL_CONTENT_BYTES
527634
): ResolvedSecretContentProjection {
528635
const snapshot = getResolvedSecretModelMatcher(registry)
529636
if (!snapshot.complete) return { safe: false }

0 commit comments

Comments
 (0)