Code Index Hardening Review
Why this matters
Semantic code search is what lets the flagship agent work on a codebase instead of merely in one — it was just composed onto the flagship (66 tools now) and had never been properly exercised end to end. This review reproduced real bugs against a live Lemonade Server, fixed the ones that would corrupt state or leak data, and measured the feature at the scale it will actually run at (the fullsrc/gaia tree).
Verdict: usable, not yet bulletproof before this pass — and one of the bugs
found here would have broken indexing for every Windows user on the second
run. Five real bugs are fixed below, each with a test that failed against
the pre-fix code and passes now. What’s left is called out explicitly in
What we recommend and didn’t do.
Bugs found and fixed
1. Every re-index crashed on Windows (severity: critical)
_save_atomic() persisted the FAISS index and metadata by writing to a
.tmp file and calling Path.rename() onto the real path. On POSIX,
rename() silently replaces an existing destination. On Windows it raises
FileExistsError instead — so the first index_repository() call on a
repo always succeeded (nothing to overwrite yet), and every call after that
crashed, because the cache files from the first run were already sitting
there.
That means incremental re-indexing — the entire point of the file-hash
cache — never worked on Windows in practice. Since GAIA’s primary platform is
Windows on Ryzen AI, this wasn’t an edge case; it was the common path.
Fix: Path.replace() instead of Path.rename() — atomically overwrites
on both Windows and POSIX.
src/gaia/code_index/sdk.py _save_atomic().
Regression test: test_atomic_save_overwrites_existing_cache_on_reindex
(unit) plus the live TestIncrementalReindexCorrectness suite, which does
two real index_repository() calls back to back.
2. A symlink inside the allowed repo could re-root the whole sandbox (severity: security)
index_codebase(repo_path=...) lets the LLM redirect the tool to any
subdirectory it names, gated by a check that the resolved path stays under
the agent’s original root. The check compared os.path.abspath() strings —
which does not follow symlinks. CodeIndexSDK.__init__, one call later,
does Path(repo_path).resolve() — which does follow them.
Net effect: a symlink (or Windows junction) planted inside the allowed root
but pointing outside it sailed through the string check, then got resolved to
its real, external target when the SDK constructed its root — silently
re-scoping the entire index/search sandbox (including the SDK’s own
PathValidator allowlist) onto that external location.
This was proven live, not just reasoned about: with the pre-fix code, calling
index_codebase(repo_path=<junction to an outside directory>) actually
indexed and embedded a file outside the sandbox:
Path.resolve(), which follows symlinks) before
the prefix check, so the comparison matches what the SDK will actually treat
as its root.
src/gaia/agents/tools/code_index_tools.py
index_codebase(). Regression test:
TestTraversalGuard::test_symlink_inside_root_escaping_outside_is_blocked
(creates a real NTFS junction; skips cleanly on platforms without link
privilege). .. traversal and an absolute path elsewhere were already
correctly blocked (os.path.abspath normalizes ..) — those are covered too,
as regression tests rather than new fixes.
3. Batch embedding silently degraded 25x, or silently returned “no results” (severity: reliability)
Reported behavior: batches of 1/2/5/25 embed correctly most of the time, but occasionally a batch call returns zero embeddings with no exception — most likely the embedding model still warming up. The old code treated that as final on the first try:- In the bulk-index path (
_encode_texts_with_sync), it fell straight to a one-by-one fallback — ~25x slower than a batch call, for what’s usually a transient condition that would have succeeded on retry. - In the query-encode path used by
search()(_encode_texts), the one-by-one fallback also swallowed failures silently and returned a shorter (possibly empty) array — whichsearch()then treated as “no matches found” instead of a real backend error. A user asking a valid question would get a quiet empty result indistinguishable from “the codebase doesn’t contain that,” with no signal that Lemonade was actually the problem.
_embed_batch_resilient() helper retries the same batch
(not one-by-one) up to twice with backoff when the backend returns short with
no exception — the correct response to “not ready yet.” Only after those
retries are exhausted does behavior diverge by caller:
_encode_texts(query-only, used bysearch()) now raises instead of ever returning a short/empty result — the “no matches” path is no longer reachable for real failures._encode_texts_with_sync(bulk indexing) still falls back to one-by-one as a last resort, so one genuinely bad chunk doesn’t abort an otherwise-healthy index run;index_repository()’s existing check still raises if every chunk fails.
src/gaia/code_index/sdk.py
_embed_batch_resilient, _encode_texts, _encode_texts_with_sync. Four new
tests in TestBatchEmbeddingRace cover: raises after exhausting retries,
recovers if the backend becomes ready mid-retry, search() raises instead of
returning [], and the bulk path still falls back correctly for a
persistently-short batch.
4. Raising max_file_size_mb didn’t actually admit larger files (severity: correctness)
CodeIndexConfig.max_file_size_mb gates file discovery (_discover_files)
correctly. But index_repository() called chunk_code_file(rel_path, content) without passing that value through — so the parser silently
re-applied its own hardcoded 1MB default regardless of what the caller
configured. A file a caller explicitly raised the limit to admit would pass
discovery, then get zeroed out at chunking with no error.
Fix: pass max_size_mb=self.config.max_file_size_mb through.
src/gaia/code_index/sdk.py
index_repository(). Regression test:
test_honors_configured_max_file_size_mb_above_parser_default.
5. A single pathological chunk could hold an entire file, unsearchable past the first ~1200 chars (severity: quality)
_chunk_by_blocks() (the fallback for files/languages with no symbol
matches) splits on blank-line boundaries or every 80 lines — but a minified
or generated file with no newlines at all is one “line” as far as
str.splitlines() is concerned, so neither boundary ever fires. The whole
file became one CodeChunk. Two consequences: metadata.json grows
unbounded for that file, and since _chunk_to_embed_text only embeds the
first 1200 chars of a chunk, everything past that point in the file becomes
permanently unsearchable.
This doesn’t corrupt the index or hang (the default max_file_size_mb=1
already caps how big such a chunk can get), but it’s a real gap the review was
asked to check for, and it also applies to a single symbol — an AST-parsed
Python function or a regex-matched JS function can itself be enormous
(generated code, a huge dict literal) regardless of blank-line structure.
Fix: a _split_oversized_chunk() post-processing pass in
chunk_code_file(), applied uniformly to every parser’s output — any chunk
over 20,000 chars is split into character-bounded pieces (each independently
embeddable), reassembling losslessly. Line numbers are kept approximate
(the original chunk’s full range on every piece) rather than computed exactly
per character offset — precise per-piece line attribution wasn’t worth the
added complexity for what’s fundamentally a size-bounding safety net.
src/gaia/code_index/parsers.py.
Tests: test_single_giant_line_is_split_not_one_unbounded_chunk,
test_oversized_symbol_chunk_is_split.
What’s solid (reviewed, not changed)
- Fail-loud contracts already in good shape for the failure modes that
matter most: embedding-model mismatch between index and query
(
ValueError), Lemonade unreachable during embedder load (RuntimeErrorwith an actionable hint), and a corrupt FAISS binary (RuntimeErrorrather than silently reporting “no index”). These were already covered byTestFailLoudlybefore this review and remain correct. _read_file_safe’s own traversal guard is solid. It usesPathValidator, which resolves symlinks viaos.path.realpathbefore checking containment — so a symlinked file inside the repo pointing to a sensitive external file (e.g.~/.ssh/id_rsarenamed with a.pyextension) is correctly rejected at read time. Bug #2 above was specifically about the tool-level pre-check that runs before this guard ever engages, not this guard itself.- Incremental reindex correctness, proven with real embeddings (not
mocks): indexed two files, changed one, re-indexed, and confirmed (a) the
unchanged file’s reused vector is bit-identical to its first embedding
(proof of real FAISS-reconstruct reuse, not a coincidental re-embed match),
and (b) the changed file’s new content is both stored and actually
returned by
search()— a stale-reuse bug would have kept serving the old answer forever. SeeTestIncrementalReindexCorrectnessintests/integration/test_code_index_live.py. - A no-op reindex touches the embedder zero times. Verified by breaking
the embedder client after the first index and confirming a second
index_repository()call with no file changes still succeeds. - Single-slot model eviction recovers correctly. Lemonade’s embedding
slot holds exactly one model; loading a different embedder to evict the
code-index one and then calling
index_repository()reloads the right model on demand and indexes/searches correctly — no hang, no corruption. - Failure modes that don’t corrupt or hang: a file deleted between
discovery and read, a binary file mislabeled with a
.pyextension, and an unreadable file (permission error) are all caught and skipped, not propagated.os.walk()’s defaultfollowlinks=Falsemeans a symlink loop in directory structure can’t cause an infinite walk either. - Cache keying is
sha256(resolved repo path)[:16]— two different repos get different cache dirs; the same repo path is stable across SDK instances. Known, low-severity gap: Windows path comparisons aren’t case-normalized, so referencing the same on-disk repo with different casing (C:\Repovsc:\repo) produces two separate cache entries instead of reusing one. Wasted disk, not a correctness bug — not fixed in this pass.
Scale: the full src/gaia tree
Verdict on scale: memory and search latency are both fine — 160MB RSS and
sub-100ms search are well within interactive bounds even on a large,
hundreds-of-files repo. The honest caveat is cold-index wall clock:
~3.3 minutes for 544 files with no progress feedback beyond log lines is a
noticeable wait, and it scales roughly linearly with embedding throughput
(~32 chunks/sec observed here, consistent with the ~42 chunks/sec seen on a
smaller 28-file/394-chunk directory). On a truly large monorepo (thousands of
files) this would run into tens of minutes. That’s usable for a one-time
setup step but would benefit from a progress callback surfaced to the caller
— not implemented in this pass (see recommendations).
Cache overhead is also worth flagging: metadata.json stores full,
un-truncated chunk content, so total cache size tracks roughly 1:1 with
source size. Not a problem at 33MB; worth watching on a multi-GB repo.
Search quality on hard queries
Eight behavioral queries — the kind grep can’t answer — run against the full 6,355-chunksrc/gaia index, top-5 results:
Honest read: 5 of 8 are clear hits in the top 3, 1 is a weak hit only
recoverable by reading to rank 5, 1 is a genuine miss, and 1 has no clean
ground truth to grade against. That’s a meaningfully lower hit rate than the
“3 of 4” originally observed against a single 394-chunk subdirectory — and
that’s the expected direction: a larger, noisier corpus gives more
plausible-but-wrong chunks a chance to outscore the right one. This is not a
bug to fix; it’s the real, current ceiling of a pure-embedding-similarity
search with
top_k chosen by the caller. Callers that need the specific
file (not just “something related”) should ask for a higher top_k (5–10)
rather than trusting rank 1, and a coding agent doing multi-step search
should treat a first search as a starting point, not a final answer.
The agent-facing tools
index_codebase’s traversal guard now holds against.., an absolute path elsewhere, and a symlink/junction planted inside the allowed root (bug #2, fixed above).search_code_index’s token cost, actually measured (not estimated) against a real indexed+searched result set: each result includes up to a 500-char content snippet plus file_path/symbol_name/language/start_line/score. A realtop_k=5JSON response measured 3,523 characters (~881 tokens) at a 4 chars/token estimate;top_k=10measured 6,741 characters (~1,685 tokens). That’s noticeable against a 32K NPU context — a multi-step agent turn that callssearch_code_indexthree or four times at the defaulttop_k=10could burn 5–7K tokens on raw search output alone. Not a bug, but worth the caller defaulting to a smallertop_k(5) unless it genuinely needs breadth.top_kandscopeboth behave as documented:top_kcaps result count exactly,scope="code"andscope="all"both currently pass through (the SDK only produces"code"chunks today, so this is forward-compatible rather than a live filter yet).
What we recommend and did not do
- Progress feedback during a long cold index. A 3+ minute index with only log-line output is a rough UX for an interactive agent session. A callback/streaming hook so a caller can surface “312/544 files…” wasn’t implemented here — it’s a UX feature, not a correctness fix, and out of scope for a hardening pass.
- Cache-key case-normalization on Windows. Low severity (wasted disk, not a correctness bug) — noted above, not fixed.
- Chunking quality beyond the oversized-chunk guard. Symbol-level chunking (Python AST, regex-based for other languages) already produces one chunk per function/class with the symbol name in context, verified against a many-small-methods class, a no-symbols constants file, and a deeply nested function — all correct. Retrieval ranking quality (the hit-rate gaps above) is a modeling/ranking question, not a bug; consider it a baseline to track in future eval work, not something this pass should have attempted to fix via code changes.
- The batch-embedding race (#3) could not be reproduced live in this
session despite deliberate stress attempts (repeated cold-load rounds,
concurrent requests). The fix is defensively correct regardless, but if it
recurs in the field, the retry-count/backoff constants in
_embed_batch_resilientare the first place to tune.