Skip to content

Commit cb8d0ff

Browse files
committed
fix(logs): resolve --field under a withheld --where and mark nested content omissions
1 parent b043ef0 commit cb8d0ff

7 files changed

Lines changed: 63 additions & 21 deletions

File tree

‎apps/docs/openapi-v2-logs.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1451,7 +1451,7 @@
14511451
"contentWithheld": {
14521452
"type": "boolean",
14531453
"const": true,
1454-
"description": "Present when the log projection withheld some or all of this span's content (input, output, thinking, error message) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
1454+
"description": "Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
14551455
},
14561456
"toolCalls": {
14571457
"type": "array",

‎apps/sim/lib/api/contracts/logs.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,7 +277,7 @@ export const traceSpanSchema: z.ZodType<LogTraceSpan> = z
277277
contentWithheld: z
278278
.literal(true)
279279
.describe(
280-
"Present when the log projection withheld some or all of this span's content (input, output, thinking, error message) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
280+
"Present when the log projection withheld some or all of this span's content (input, output, thinking, error message, tool-call or provider-segment content) because it could not be proven safe to display. Status, timing, and usage remain. A field missing from a span carrying this marker may exist on the run but was not returned."
281281
)
282282
.optional(),
283283
toolCalls: z.array(toolCallSchema).describe('Tool calls recorded by the span.').optional(),

‎apps/sim/lib/logs/execution/trace-secret-projection.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,33 @@ describe('projectTraceSpansForSecrets', () => {
553553
expect(intact).not.toHaveProperty('contentWithheld')
554554
})
555555

556+
it('marks a span whose tool-call content was omitted', async () => {
557+
const [span] = await projectTraceSpansForSecrets(
558+
[
559+
createSpan({
560+
output: { safe: 'keep' },
561+
toolCalls: [
562+
{
563+
name: 'legacy',
564+
duration: 1,
565+
startTime: '2026-07-01T00:00:00.000Z',
566+
endTime: '2026-07-01T00:00:00.001Z',
567+
output: { secretval: 1, '{{TOKEN}}': 2 },
568+
},
569+
],
570+
}),
571+
],
572+
{
573+
registry: createRegistry([{ plaintext: 'secretval', replacement: '{{TOKEN}}' }]),
574+
store: STORE,
575+
}
576+
)
577+
578+
expect(span.output).toEqual({ safe: 'keep' })
579+
expect(span.toolCalls?.[0]).not.toHaveProperty('output')
580+
expect(span.contentWithheld).toBe(true)
581+
})
582+
556583
it('fails closed when a later log transform reintroduces an active literal', async () => {
557584
const source = [createSpan({ output: { apiKey: '[REDACTED]' } })]
558585

‎apps/sim/lib/logs/execution/trace-secret-projection.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ interface ProjectionContext {
8282
sourceLargeValueBytes: number
8383
storedLargeValueBytes: number
8484
storedLargeValueCount: number
85+
/** Content fields omitted so far; a span compares it across its own fields to set `contentWithheld`. */
86+
omittedContentFields: number
8587
}
8688

8789
interface OversizedHydrationGate {
@@ -162,6 +164,7 @@ function createProjectionContext(
162164
manifestIds: new WeakMap<object, string>(),
163165
nextManifestId: 0,
164166
largeValueCount: 0,
167+
omittedContentFields: 0,
165168
sourceLargeValueBytes: 0,
166169
storedLargeValueBytes: 0,
167170
storedLargeValueCount: 0,
@@ -840,6 +843,7 @@ async function sanitizeContentField(
840843
logger.warn('Omitting trace content that could not be sanitized', {
841844
failure: describeProjectionFailure(error),
842845
})
846+
context.omittedContentFields += 1
843847
return OMIT
844848
}
845849
}
@@ -959,13 +963,14 @@ async function sanitizeTraceSpan(
959963
}
960964

961965
let projected = omit(span, ['displayResolvedSecretTraceProvenance']) as TraceSpan
966+
const omittedBefore = context.omittedContentFields
962967

963968
for (const key of ['input', 'output', 'thinking', 'errorMessage'] as const) {
964969
const value = span[key]
965970
if (value === undefined) continue
966971
const sanitized = await sanitizeContentField(value, context)
967972
if (sanitized === OMIT) {
968-
projected = { ...(omit(projected, [key]) as TraceSpan), contentWithheld: true }
973+
projected = omit(projected, [key]) as TraceSpan
969974
} else {
970975
projected[key] = sanitized as never
971976
}
@@ -993,6 +998,8 @@ async function sanitizeTraceSpan(
993998
projected.providerTiming = { ...span.providerTiming, segments }
994999
}
9951000

1001+
if (context.omittedContentFields > omittedBefore) projected.contentWithheld = true
1002+
9961003
assertContentIsSafe(projected, depth)
9971004

9981005
if (span.children !== undefined) {

‎apps/sim/lib/logs/types.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,10 @@ export interface TraceSpan {
309309
*/
310310
childTraceAccess?: 'granted' | 'disabled' | 'missing' | 'truncated'
311311
/**
312-
* Set by secret projection when it omitted one of this span's own content fields
313-
* (`input`, `output`, `thinking`, `errorMessage`) it could not prove safe, or reduced
314-
* the span to structure only (which also strips tool-call and provider-segment
315-
* content). Status, timing, and usage remain. Without it a withheld field is
312+
* Set by secret projection when it omitted content it could not prove safe from
313+
* this span (`input`, `output`, `thinking`, `errorMessage`, or its tool-call and
314+
* provider-segment content), or reduced the span to structure only. Status, timing,
315+
* and usage remain. Without it a withheld field is
316316
* indistinguishable from one the block never produced.
317317
*/
318318
contentWithheld?: true

‎apps/sim/lib/mothership/agent-cli/engines.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@ describe('logs query on a span whose content was withheld', () => {
154154
expect(report.rows[1].note).toMatch(/withheld/)
155155
expect(report.contentWithheld).toBe(1)
156156
})
157+
158+
it('still resolves a readable --field when --where hits withheld content', async () => {
159+
const report = await logsQueryWithheld({ field: 'duration', where: 'result.count=6' })
160+
expect(report.rows[1]).toMatchObject({ runId: 'run-2', value: 12 })
161+
expect(report.rows[1].note).toMatch(/--where/)
162+
expect(report.contentWithheld).toBe(1)
163+
})
157164
})
158165

159166
const DEPS_STATE = {

‎apps/sim/lib/mothership/agent-cli/engines/query.ts‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ const PATH_NOT_FOUND_NOTE = 'path not found on span'
2828
* prove safe to display, so an unresolved content path says nothing about the run.
2929
*/
3030
const CONTENT_WITHHELD_NOTE = 'span content withheld by the log projection; value unknown'
31+
const WHERE_WITHHELD_NOTE =
32+
'span content withheld by the log projection; --where could not be checked, run kept'
3133

3234
/**
3335
* Keys that live on the span itself. A path whose head is anything else is read
@@ -205,30 +207,29 @@ export const logsQueryCommand: AgentCliEngine = {
205207
hits: matches.length,
206208
blockStatus: last.status ?? 'success',
207209
}
210+
let whereWithheld = false
208211
if (wherePath) {
209212
const matched = resolveSpanPathWithFallback(last, wherePath)
210213
if (matched.resolvedUnder) whereResolvedUnder = matched.resolvedUnder
211214
// Filtering on a withheld value would silently drop a run that may well match.
212-
if (matched.value === undefined && last.contentWithheld) {
213-
contentWithheld++
214-
return { ...matchedRow, value: null, note: CONTENT_WITHHELD_NOTE }
215-
}
216-
if (String(matched.value) !== whereValue) {
215+
whereWithheld = matched.value === undefined && last.contentWithheld === true
216+
if (!whereWithheld && String(matched.value) !== whereValue) {
217217
filteredOut++
218218
return undefined
219219
}
220220
}
221221
const resolved = resolveSpanPathWithFallback(last, field)
222222
if (resolved.resolvedUnder) fieldResolvedUnder = resolved.resolvedUnder
223-
if (resolved.value === undefined) {
224-
if (last.contentWithheld) contentWithheld++
225-
return {
226-
...matchedRow,
227-
value: null,
228-
note: last.contentWithheld ? CONTENT_WITHHELD_NOTE : PATH_NOT_FOUND_NOTE,
229-
}
230-
}
231-
return { ...matchedRow, value: clipValue(resolved.value) }
223+
const fieldWithheld = resolved.value === undefined && last.contentWithheld === true
224+
if (whereWithheld || fieldWithheld) contentWithheld++
225+
const note = fieldWithheld
226+
? CONTENT_WITHHELD_NOTE
227+
: resolved.value === undefined
228+
? PATH_NOT_FOUND_NOTE
229+
: whereWithheld
230+
? WHERE_WITHHELD_NOTE
231+
: undefined
232+
return { ...matchedRow, value: clipValue(resolved.value), ...(note ? { note } : {}) }
232233
}
233234
)
234235

0 commit comments

Comments
 (0)