feat(ENG-1339): chunk oversized Tier-3 input instead of truncating - #96
Merged
Merged
Conversation
tier3_only reviewed only a truncated/spread-sampled slice of an over-budget tool result. The sampling order was deterministic, so an attacker could precompute the dropped slot on a long list and hide an injection there, unreviewed → allowed. Replace truncation with parallel, boundary-aware chunking: - serialize up to a coverage ceiling (maxChunks × maxTextLength), - split into ≤maxChunks whole-line chunks with an overlap tail so a boundary-spanning injection lands in both, - review every chunk in parallel, union-of-blocks aggregation (any chunk blocks → block; representative verdict = the blocker), - route genuine overflow (whole records dropped / a string value cut) to a new tier3.onOversize policy: skip (default → allow), block, or scan_anyway (scan the ceiling, fail closed on the unseen overflow). maxTextLength now means the per-chunk cap in tier3_only (per provider call), so the reviewer still never sees more than that at once; add tier3.maxChunks (default 5) and tier3.onOversize (default skip) with validation. New DefenseResult.tier3ChunkSummary telemetry. A lone unfittable field/key no longer counts as oversize — only genuine content overflow does. Retire the spread-sampling tests whose premise no longer holds; add chunking tests (evasion repro fixed, overlap, per-chunk cap, each onOversize branch, per-chunk provider error). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…el overlap, chunk-count contract Adversarial review of PR #96 found three real issues: 1. (critical) Two content-drop sites (a field skipped because its key won't fit, a non-string scalar dropped) didn't flag oversize, so a genuinely over-budget single record fell through the normal path and its dropped (possibly injection-bearing) field was never reviewed while the result looked clean. Fix: derive oversize from the GREEDY pass's truncation (full budget, index order → truncates iff content genuinely exceeds the ceiling, at every drop site), captured before the reserve reset. Removes the fragile per-site budgetOverflow flag. 2. (critical) Unit-level chunk overlap was zero whenever a chunk held one line (common when a line approaches the per-chunk cap), so a token split at a chunk edge could evade every chunk. Fix: character-level overlap — each chunk carries the previous chunk's last ~overlap chars, concatenated with no separator so a hard-split token is reconstructed byte-contiguous. 3. (medium) Actual chunk/call count could exceed maxChunks because the ceiling didn't account for overlap stride. Fix: ceiling = maxChunks × (perChunk − overlap); documented maxChunks as a target (uneven line lengths can add a chunk). Tests: retire the oversized-key "review the rest" test (that path now correctly routes to onOversize); add byte-contiguous split-token overlap, character-overlap mechanism, and chunk-count-bound tests. 429 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…l gaps + chunk gluing Round-2 review of PR #96 found the oversize signal still had silent-drop holes and the byte-contiguous overlap corrupted framing. 1. (critical) A multi-line string truncated mid-value at an exact budget boundary hit the per-line loop's top `used >= limit` guard and dropped the remaining lines (the injection) with ZERO signal — no oversize, no coverage flag, looked like a clean single-chunk review. Fix: remove the top guard so a non-fitting line reaches the flagging branch. 2. (critical) The oversize signal (greedyTruncated) fired on ANY drop, including a benign non-string numeric-array summary line, so a common list-shaped payload with a numeric field skipped Tier-3 review entirely (default onOversize:skip → allow) even when the injection-bearing string fit. Fix: signal oversize only when a STRING leaf or a whole RECORD is dropped/truncated (new budgetOverflow flag), captured from the greedy pass; benign non-string drops still trigger the reserve retry but never force onOversize. Also flag the object/array field/element loop breaks and the field-level drop (a later string field dropped after an earlier field fills the ceiling) — closing the same silent-drop class at field granularity. 3. (medium) The no-separator carry glued one field's value onto the next field's key, corrupting the record-oriented framing and risking false-positive blocks. Fix: put the carry on its own line ("\n" join) and move hard-split-token reconstruction into splitLongLine, which now emits byte-overlapping pieces so a split token is whole inside a piece. Tests: regression tests for each (mid-string drop, field-drop, benign-summary non-skip, no-gluing). 433 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t (e3 tuning) e3 tuning on 5,940 real payloads confirmed the defaults (maxChunks:5 covers p99=4; maxTextLength:10000 — raising it doesn't help recall and worsens over-block) and two costs worth stating: onOversize:skip fails open on the ~0.6% oversized tail, and chunked union-of-blocks raises benign FPR ~13pp on >10k results. Keep skip as the availability-favoring default; consumers set block/scan_anyway to fail closed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…p no longer discards it) Round-3 review found the oversize signal's precision was load-bearing in a way it can't be: a benign trailing sibling (e.g. a boolean) tripped the loop-break oversize flag, and under the default onOversize:skip the whole payload — including a fully-fitting injection — was discarded unreviewed (allowed:true). Fix the class architecturally instead of chasing signal precision: skip and scan_anyway now ALWAYS review the content that fit (union-of-blocks), so a fitting injection always blocks; only the genuinely-dropped overflow is governed by onOversize (skip allows it, scan_anyway fails closed, block blocks without reviewing). A false-positive oversize is now harmless — the fitting content is reviewed either way. With review no longer gated on it, the oversize signal reverts to the simple reliable greedyTruncated (greedy uses the full budget in index order, so it truncates iff content genuinely exceeds the ceiling — never under-flags, so no dropped content is ever silently unreviewed). Removes the fragile per-site budgetOverflow flag. Cost: skip now makes up to maxChunks provider calls on the ~0.6% oversized tail (was 0), for full review of the part that fit. Tests updated to the reviewed-what-fit semantics; added a round-3 regression (fitting injection + benign oversize-tipping sibling → reviewed and blocked under skip). 434 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Round-4 review disproved the "class is closed" claim with two reproduced bugs at production defaults: F1 (critical, silent bypass): runRecords charges the "\n\n" separator (`used += 2`) AFTER its `used >= maxChars` precheck but BEFORE calling serialize, so `used` could sit 2 below the ceiling, pass the precheck, then trip serialize's top guard (`if (used >= cap) return`) — which returned WITHOUT setting budgetTruncated. A huge first record then silently dropped every later record (e.g. a whole injected list), greedyTruncated stayed false, oversize was never flagged, coverageDegraded unset → allowed:true with zero signal, and the reserve anti-starvation retry never engaged. Fix: (a) serialize's guard now self-reports (sets budgetTruncated + coverageDegraded before returning) — closes the class at the guard against any caller; (b) the record-loop precheck accounts for the pending +2 so it breaks-and-flags instead of oscillating. F2 (high, policy defeated): the `joined.length === 0` branch preempted the onOversize handling, so block/scan_anyway did NOT fail closed when the whole payload's strings were dropped (e.g. a field whose key alone exceeds the ceiling → empty joined but oversize). Fix: handle onOversize:block before the empty-joined case, and fail closed under scan_anyway when empty+oversize. Regression tests for both (huge-first-record list, and empty+oversize block/scan_anyway/skip). 436 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the drop-site-dependent oversize signal (greedyTruncated) with an independent, positive-accounting check that ends the recurring "content silently dropped from review" class: - `formatRecordsForTier3` now counts the STRING-leaf content it actually emits (`emittedStringChars`, excluding key prefixes and separators). - `sumInputStringChars(value)` independently sums the input's string-leaf content, traversing exactly as the serializer does (arrays/objects incl. non-plain, depth-capped, binary/non-string = 0). - oversize (`depthFlag.budgetExceeded`) ⟺ emitted < input, i.e. any string leaf was dropped or truncated — regardless of WHICH drop path lost it. Why this closes the class: it's positive accounting. A miscount can only UNDER-report emitted → over-flag oversize (harmless: under onOversize:skip the fitting content is still reviewed), never over-report → silent miss. It does not depend on every drop site remembering to set a flag, which is what failed in rounds 1-4. Also gives an ACCURATE signal — content the reserve pass rescues counts as emitted, so a greedy drop that reserve refits is no longer a false oversize. The round-4 self-reporting guard + record-loop precheck stay: they make budgetTruncated fire so the reserve retry engages and RESCUES dropped content into review (better than merely flagging it). 436 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…can't defeat the oversize check
The definitive check re-derived the input total independently, which assumed
idempotent reads. A non-idempotent getter ("malicious on first read, benign
after") could show 100KB to the greedy pass and return "ok" to the reserve
pass and to sumInputStringChars — so emitted == input, oversize never fired,
and the injection was silently allowed under EVERY onOversize policy
(including block/scan_anyway).
Fix: materialize the payload once into a plain deep snapshot (each getter
invoked a single time), then run greedy, reserve, and the input-sum off that
snapshot. All passes see identical data, so a getter cannot reveal content to
one pass and hide it from another. Getter throws still propagate to
payloadError (fail closed), unchanged. Also removes sumStringContent's now-
dead getter try/catch (the snapshot is plain) and its misleading comment.
Regression: a non-idempotent getter is now flagged oversize (block fails
closed; skip allows-but-flags), not silently allowed. 437 pass.
Note: materialize adds one O(payload) deep copy per tier3_only call — a
follow-up can add a no-getter fast path if the copy cost matters.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… cache, node budget) Round-6 review found the read-once snapshot itself was exploitable: F1 (critical): materializeForTier3 used `value.map()` on the untrusted top-level array — an own `map` property shadowing Array.prototype.map made it return [], producing an empty snapshot: injection never reviewed, allowed:true, and (worse than any prior round) NO signal at all. Fix: iterate the top level by index, like every nested array already does. F2 (high): the `seen` cache was keyed by object identity only, so a shared object first visited deep (its children truncated at the depth cap) cached a truncated copy that a later SHALLOW reference reused — silently omitting content the shallow path would have kept. Fix: key the cache by (object, depth); a shallow reference re-materializes fully. F3 (DoS regression): the snapshot deep-copied the whole payload with no budget (unlike the budget-bounded serializer), so a huge tool result (2M objects → ~3.4s) was an amplification vector. Fix: a node budget (TIER3_MAX_MATERIALIZE_NODES) — exceeding it flags oversize (onOversize governs the un-snapshotted remainder) and stops, bounding the copy. Regression tests for all three. 440 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… definitive check The snapshot (materializeForTier3) and its round-6 hardening (map-shadow fix, depth-keyed cache, node budget) defended hostile in-process JS objects — non-idempotent getters, prototype-method shadowing, reference aliasing, cycles — none of which are expressible in JSON. Tool results arrive as JSON.parse output (plain data), so this was out of the input contract, and it cost a deep copy on every tier3_only call plus ~80 lines of subtle traversal that itself needed a round of fixes. Kept: the definitive positive-accounting oversize check (emitted string chars < input string chars) — the real fix for the realistic "content silently dropped" class, cheap and independent of drop-site enumeration. It now runs directly on `value`; a throwing getter propagates → payloadError → fail closed (unchanged). Removed the 4 snapshot-specific tests; all data-class regressions (rounds 1-4) and the definitive-check tests stay. 436 pass. Follow-up: document that defender assumes JSON-serializable tool data (adversarial live JS objects out of scope). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s it
Scoped adversarial review (JSON-data only) found one real bug: content
nested past MAX_TRAVERSAL_DEPTH is dropped by the serializer AND excluded from
sumInputStringChars (both stop at the same depth), so it never registers as a
drop in the emitted-vs-input check. `oversize` was derived only from
budgetExceeded, so a JSON injection buried under ~100 levels of {"a":{"a":…}}
was never flagged and block/scan_anyway never failed closed on it (allowed:
true under every policy, only a soft coverageDegraded note).
Fix: fold depthFlag.hit into the oversize signal — a depth cut is
unreviewable content just like a ceiling overflow, so onOversize governs it
(skip allows+flags, block/scan_anyway fail closed). Regression test added.
437 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ENG-1339 — Tier-3 oversize input: chunk instead of truncate
Problem
In
tier3_only("Deep Scan"), a tool result whose serialized form exceededtier3.maxTextLengthwas truncated / spread-sampled before review (
formatRecordsForTier3). The sampling order(
tier3SpreadOrder) is deterministic, so an attacker can precompute the dropped slot on anover-budget list (a ~200-row list with text notes easily exceeds the old 10k budget), plant the
injection there, and the reviewer never sees it →
allowed: true. Confirmed in adversarial review.cascademode usesmaxSentenceand is unaffected.Fix — parallel, boundary-aware chunking
runTier3Onlynow serializes up to a coverage ceiling and reviews the whole thing across chunksinstead of truncating:
maxChunks × maxTextLength.chunkTier3Input), each ≤maxTextLength, with an~500-char overlap tail so an injection straddling a boundary lands in both chunks. An
over-long single line is word-split so it stays reviewable.
Promise.all), each verdict validated with the existingvalidateTier3Verdict/isTier3Block.(else the highest-score allow). A chunk whose
classifythrows/returns garbage is dropped(per-chunk fail-open, matching the provider-outage policy) and flags
coverageDegraded;all-error → allow (unchanged fail-open).
payloadError(serializer threw) still fails closed instrict mode.
field/key) routes to a new
tier3.onOversizepolicy:skip(default) → allow per theblockHighRiskinvariant,coverageDegraded: true.block→ high risk (blocks in strict mode).scan_anyway→ review the ceiling's chunks; block if any blocks, else fail closed because theoverflow went unreviewed.
Config / API
tier3.maxTextLengthnow also means the per-chunk cap intier3_only(the reviewer stillnever sees more than this per call); unchanged in
cascade.tier3.maxChunks(default 5) — sets the ceiling and the max parallel calls.tier3.onOversize(defaultskip).DefenseResult.tier3ChunkSummary?: { chunks, blocked, blockingChunk?, oversize? }(present only when the chunk path ran; mirrors
tier2Statspresence).maxChunksormaxTextLengthfor more coverage at up to N× Tier-3 cost/latency (surfaced viatier3ChunkSummary.chunks).Cost / latency
Up to
maxChunksparallel provider calls per oversized tool result (vs 1 before).Tests
by chunking).
block), chunk overlap, per-chunk cap, each
onOversizebranch, per-chunk provider error,maxChunks/onOversizevalidation.tsc --noEmitclean,biome checkclean.Notes
tier3SpreadOrder/tier3ItemCap/ the reserve pass are now only reached todetect overflow and to feed
scan_anyway; a follow-up can simplify.(should be uniform now — everything sub-ceiling is reviewed, no sampling).
🤖 Generated with Claude Code
Tuning (e3, n=5,940 real payloads) + defaults
maxChunks: 5— p99 payload needs 4 chunks; adequate.maxTextLength: 10000— raising to 18k gives no recall gain and worsens benign over-block; 10k is the tuned value.onOversizedefault =skip— only ~0.59% of real payloads exceed the ~47.5KB ceiling.skipfavors availability and fails open on that tail (consumers with a stricter threat model setblock/scan_anywayto fail closed). Kept configurable per consumer.Documented costs (inherent, not bugs)
blockHighRiskconsumers feel it on large tool results.Adversarial review
Two adversarial passes; 6 real bugs found and fixed (4 critical) — silent-drop signal gaps (mid-string, field-level, benign-summary over-trigger), zero-overlap on single-line chunks, chunk-count overshoot, and chunk-boundary gluing. Regression tests added for each. 433 tests pass.