fix(build-context): bound the per-file read into file_cache - #377
fix(build-context): bound the per-file read into file_cache#377wernerkasselman-au wants to merge 1 commit into
Conversation
`_read_file_cache()` called `_read_text_no_follow()` on every discovered component, and that does an unbounded `source.read()`. A local directory scan therefore materialized each file whole before any analyzer looked at it, so a multi-GB file in a skill drove peak memory to its full size and was only then skipped downstream at `MAX_FILE_CHARS`. `INGEST_MAX_BYTES` does not cover this. Its own docstring scopes it to "Each remote/archive ingest path", and a local directory target reaches `build_context` through `validate_local_input_path()`, which does no sizing. `MAX_FILE_BYTES` is not a gate here either: in this module it is used only inside `_is_valid_oms_signature()` and for a `size_bytes` metadata field. The gate reuses the stat already taken for the `S_ISREG` check, so it costs no extra syscall, and it emits `LedgerOutcome.SKIPPED` with `LedgerReason.SIZE_LIMIT` rather than raising. That matches what the static, AST, taint, and YARA analyzers already do for oversized input, so the file is reported as not-inspected instead of silently vanishing, and it flows into `analysis_completeness` the same way. The bound is derived rather than picked. Every downstream consumer limits itself in characters, the largest being `MAX_PYTHON_AST_CACHE_SOURCE_CHARS`. UTF-8 uses at most 4 bytes per character, and `errors="replace"` yields one character per undecodable byte, so nothing above 4x that character budget can decode to a size any consumer accepts. The gate is outcome-preserving by construction: it cannot exclude content that would otherwise have been analyzed, it only declines to materialize bytes already guaranteed to be skipped. `test_cache_read_bound_cannot_exclude_content_any_consumer_accepts` fails if a consumer ever raises its budget past the bound. One behavior change worth calling out: the LLM `semantic_*` path has no character cap of its own, so a file above this bound previously would have been chunked and sent to the provider. It now reaches `get_batches()` absent from the cache. That is the intended direction for a scanner, and the ledger event makes it visible rather than silent. Tests cover the skip, the ledger event fields, the inclusive boundary, and the derivation. `test_build_context_never_reads_an_oversized_file` spies on `_read_text_no_follow` because asserting only that the path is missing from `file_cache` would still pass if the file were read in full and discarded, which would leave the peak-memory problem exactly where it was. Verified against three mutants: removing the gate, making it read before testing size, and making the bound exclusive. The read-then-discard mutant is caught by that spy test alone. 2189 passed, 17 skipped, 4 xfailed. Ruff clean. Signed-off-by: Werner Kasselman <145896621+wernerkasselman-au@users.noreply.github.com>
|
Hi Keshav, Context for this one sits in a longer note I left on #375, so I will keep this Like #375 it is MERGEABLE and rebased on Thanks, |
| ) | ||
| continue | ||
| try: | ||
| content = _read_text_no_follow(full) |
There was a problem hiding this comment.
The pre-read stat is not a bound on the read itself. A regular file can grow after file_stat.st_size is checked (including while it is being read), and _read_text_no_follow() still calls unbounded source.read(), so a concurrent writer can recreate the memory DoS. Enforce the byte limit on the opened handle (for example, read at most limit + 1 and reject overflow) and test growth/oversize at read time.
rng1995
left a comment
There was a problem hiding this comment.
[SkillSpector Review]
Requesting changes. The stat gate reduces ordinary memory use but does not bound the subsequent source.read(): a regular file can grow between or during those operations and recreate the unbounded-read DoS. Enforce the limit on the opened handle and add a read-time growth/overflow regression, as described inline.
What this fixes
_read_file_cache()calls_read_text_no_follow()on every discoveredcomponent, and that does an unbounded read:
So a local directory scan materializes each file whole before any analyzer sees
it. A multi-GB file in a skill drives peak memory to its full size and is only
then skipped downstream at
MAX_FILE_CHARS.INGEST_MAX_BYTESdoes not cover this. Its docstring scopes it to "Eachremote/archive ingest path". A local directory target reaches
build_contextvia
validate_local_input_path(), which does no sizing.MAX_FILE_BYTESisnot a gate here either: in
build_contextit appears only inside_is_valid_oms_signature()and in asize_bytesmetadata field.The change
A size check before the read, reusing the
stat()already taken for theS_ISREGtest so it costs no extra syscall. It emitsLedgerOutcome.SKIPPEDwith
LedgerReason.SIZE_LIMITrather than raising, matching what the static,AST, taint, and YARA analyzers already do for oversized input. The file is
reported as not-inspected and flows into
analysis_completenessnormally,instead of silently vanishing from the cache.
The bound is derived, not picked
Every downstream consumer bounds itself in characters: analyzers skip at
static_runner.MAX_FILE_CHARS, and the prewarmed AST cache rejects a singlesource above
MAX_PYTHON_AST_CACHE_SOURCE_CHARS, the larger of the two. UTF-8uses at most 4 bytes per character, and
errors="replace"yields one characterper undecodable byte, so nothing above 4x that character budget can decode to
a size any consumer would accept.
The gate is therefore outcome-preserving by construction. It cannot exclude
content that would otherwise have been analyzed; it only declines to
materialize bytes already guaranteed to be skipped.
test_cache_read_bound_cannot_exclude_content_any_consumer_acceptsfails if aconsumer ever raises its budget past the bound, so the two cannot drift apart
silently.
One behavior change worth your eye
The LLM
semantic_*path has no character cap of its own, so a file above thisbound previously would have been chunked and sent to the provider. It now
reaches
get_batches()absent from the cache. I believe that is the rightdirection for a scanner (it is a real cost and resource exposure), and the
ledger event makes it visible rather than silent, but it is a change and not a
pure no-op, so I would rather flag it than bury it.
Minor: the canned message for
SIZE_LIMITreads "File exceeds this analyzer'scharacter limit", which is slightly off for a cache-phase byte gate. I left the
shared string alone since four analyzers depend on it. Happy to reword it in a
follow-up if you want.
Tests
Four tests: the skip and its ledger fields, the inclusive boundary, and the
derivation assertion.
test_build_context_never_reads_an_oversized_filespies on_read_text_no_follow, because asserting only that the path is missing fromfile_cachewould still pass if the file were read in full and then discarded,which leaves the peak-memory problem exactly where it was.
Verified against three mutants rather than assumed:
>=)The middle row is why the spy test earns its place.
Oversized fixtures are sparse files via
truncate(), so they cost no disk andthe suite stays fast.
Refs #131, which bounded the ingest layer; this closes the local-directory path
that budget does not reach.