fix(runtime): redact a Deep Research artifact before paging it - #5435
Totoro-qaq wants to merge 2 commits into
Conversation
deep_research_read_artifact picked the offset_chars window from the raw artifact and only then redacted that window. A credential split by a page boundary matched no pattern in either page, so two consecutive reads joined back into the value a whole read hides. A JSON artifact larger than one read never parsed as a document, so its key-based redaction did not apply to pages either. Redact the whole artifact once per read, after the ledger hash check, and page over the redacted text; offset, end and total now count that text, and offset_chars is no longer capped at the stored length because redaction can lengthen it. JSON redaction now also covers object keys, which a whole-document read would otherwise leave visible. Generated-by: Claude Code
Astro-Han
left a comment
There was a problem hiding this comment.
Summary
The ordering fix is right, and the invariants it claims hold up under inspection: the read now redacts the whole stored body once (packages/runtime/src/deep-research-tools.ts:233) before selecting the window (:236-241), the SHA-256 ledger check still runs on the raw bytes ahead of it (:225-227), and offset/end/total are all derived from the exact string that gets emitted — so consecutive pages cannot reassemble a token that a whole read hides. Dropping .max(DEEP_RESEARCH_ARTIFACT_CONTENT_MAX_CHARS) from offset_chars (:192-197) is required, because redaction can lengthen the text; the "reaches the end of a redacted artifact…" test demonstrates a total beyond the stored length.
What I verified:
- No other model-facing path emits an artifact body:
validateArtifactIntegrityonly hashes (:874-887), and status/name/locator/summary go throughnormalizeInlineText(:925-930); save, checkpoint and complete echo no bodies. - Tag stripping now runs on the whole body instead of per window, so a forged envelope split by a page boundary can no longer leave a
<deep-research-artifactfragment in one page and…>in the next (deep-research-tools.test.ts:619-668). - Coverage for the reported bug is thorough: every split point for seven secret shapes,
max_chars: 1, forged tags across pages, redacted-longer-than-stored, secret-free artifacts keeping their offsets, and a core test for key redaction. - I did not execute the suites (they need #5434, which is not on
main). The checks below are from the diff plus a standalone run ofredactSecrets/ the currentsafeResearchArtifactContentbody against synthetic inputs.
Findings
1. Redaction still runs before forged-tag stripping, so a secret split by a forged tag is rejoined after redaction
safeResearchArtifactContent (packages/runtime/src/deep-research-tools.ts:932-936) redacts first and strips </?deep-research-(?:workspace|artifact)…> second. A token that straddles such a tag matches no pattern while redaction runs, and the strip step then glues the halves back together:
input : ghp_FAKE<deep-research-artifact id="dr-x" role="source">tokFAKEtokFAKEtokFAKE evidence
output: ghp_FAKEtokFAKEtokFAKEtokFAKE evidence ← matches /ghp_[0-9A-Za-z_]{20,}/
(Reproduced with packages/core/src/redaction.ts plus the helper as written on this branch.)
This is pre-existing rather than a regression, but it is the same "reassemble after redaction" invariant this PR establishes — with tag stripping as the join instead of a page boundary — and this read is the one place artifact bodies reach the model. Stripping the tags first and redacting afterwards (or redacting again after stripping) closes it; the order is otherwise unobservable, since redactSecrets never emits these tags.
No test pins it: 'strips forged workspace and artifact envelopes from persisted artifact content' (deep-research-tools.test.ts:619-668) asserts only that the tags themselves do not survive a split, not that a secret straddling one stays hidden. A fixture such as ghp_FAKE<deep-research-artifact id="dr-x">tokFAKEtokFAKEtokFAKE read whole and split at every point would cover both orderings.
nits
<deep-research-artifact id="a>b" role="source">leavesb" role="source">in the body, because[^>]{0,4096}stops at the first>(:934). Pre-existing and cosmetic, but it is the same line finding 1 touches.- Whole-document JSON redaction re-serializes through
JSON.stringify(packages/core/src/redaction.ts:127), so any secret now collapses a pretty-printed artifact to one line; before this change, pages (invalid JSON slices) took the text path and kept their indentation. Line numbers a resumed model may cite therefore shift for multi-page JSON reads, and the JSON test only assertsJSON.parse(...)deep-equality (deep-research-tools.test.ts:734-766), so nothing pins the emitted shape. - Object-key redaction collapses distinct keys that redact to the same text (
packages/core/src/redaction.ts:154-167): for{"ghp_AAA…": "public", "ghp_BBB…": "private"}the later entry overwrites the earlier, so a value can be presented under a key it never had. The comment covers the collapse but not the value mis-association; fine to keep if that is the intent. offset_charsis now unbounded (:192-197) whileendstaysMath.min(total, offset + maxChars)(:240), sooffset > totalyields an inverted envelope (offset="1000000000" end="97" total="97"), an empty body andTruncated: false. Pre-existing logic, but clampingoffsettototal— or rejecting it — is a one-liner now that the cap is gone.
Merge-order note
Every read redacts up to 512,000 characters and paging re-redacts per call, so this branch is only fast with #5434's linear assignment scan. Worth keeping that as an enforced dependency (as the PR body states) rather than relying on PR text alone, since nothing in the code guards the slow path.
…ipped Artifact content and inline text were redacted first and stripped of forged envelope tags second, so a secret split by a forged tag was rejoined after redaction had passed over it. Alternate redaction and normalizing until normalizing changes nothing, and withhold text that nested tag fragments keep unsettled past four passes. Also report an offset past the end of an artifact at its end, and note that colliding redacted JSON keys keep only the last value. Generated-by: Claude Code
|
Thanks. Changes in 8f56c15:
|
Summary
deep_research_read_artifactpicked theoffset_charswindow from the raw artifact and only then redacted that window. A credential split by a page boundary matched no pattern in either page, so two consecutive reads joined back into the value a whole read hides. A JSON artifact larger than one read never parsed as a document, so key-based JSON redaction did not apply to its pages either.offset,endandtotalcount that text. Theoffset_charsdescription now says so and tells the model to continue from the previousend.offset_charsis no longer capped at the stored length (512,000). Redaction can make the text longer, and the tail of such an artifact could otherwise not be reached.Depends on #5434. Redacting a whole 512,000-character artifact needs the linear assignment scan from that PR. On main, a 512,000-character hyphenated run takes minutes; with both changes it takes a few milliseconds. Please merge #5434 first.
Fixes #5432
Verification
max_chars: 1reads;offset_charscap.@maka/runtimetest:dist passed (3,527 passed, 14 skipped) before the assignment-regex part moved to fix(core): redact a secret assignment nested in a harmless assignment's value #5434. The runtime-host tests that load redaction pass 314/314.git diff --checkand the Windows test inventory pass.AI use
Tool(s) and scope: Claude Code helped investigate, implement and test this change. The commit carries a
Generated-by: Claude Codetrailer.Checklist
Does this PR entail a change in behavior?