feat(a2a): ship RAG retrieval in the shared image; fix the RAG that was inert in prod - #194
feat(a2a): ship RAG retrieval in the shared image; fix the RAG that was inert in prod#194github-actions[bot] wants to merge 5 commits into
Conversation
…as inert in prod FuzeAgent's RAG has been dead in production. Not degraded — dead, while every log line, health probe and endpoint reported success. Three defects in services/orchestrator/rag_integration.py, and a fourth that hid the other three: 1. CHROMA_HOST defaulted to "localhost" and NOTHING set it in any deployment manifest, so the pod dialled itself. 2. chroma_client_auth_provider was the literal "basic". Chroma resolves that string as an import path to a provider class, so "basic" resolved to nothing and the client was built UNAUTHENTICATED while appearing to configure auth. 3. chroma_client_auth_credentials was a hardcoded "". Even with (2) fixed it would have authenticated with an empty secret. 4. A broad `except Exception` turned all of that into `collection = None`, after which every search returned an empty RAGContext — a well-formed "we found nothing" that no caller could distinguish from an empty corpus. (4) is the one that matters. The other three are ordinary bugs; (4) is what made them invisible for months. Fixed, and the fixes are structural rather than corrected literals: - No default for CHROMA_HOST. A default that is wrong but still lets the process start is worse than no default. - The two auth providers are dotted class paths held in one constant each, and a parity test fails if the orchestrator's copy and the A2A copy diverge. The two images cannot import each other — different build contexts — so the duplication is unavoidable; leaving it silent is not. - Unauthenticated access requires CHROMA_ALLOW_UNAUTHENTICATED=1, said out loud. An empty credential is never read as "no auth wanted". - Unreachable RAISES. /rag/search and /rag/enhance-prompt return 503, not 200-with-no-results. NEW: agent-templates/a2a/rag/ — retrieval for the shared A2A server, in the fuze-a2a image, off unless the chart enables it. Retrieval only. Indexing needs the original documents and a multi-tenant pod must not hold any tenant's document store. No document database is needed for retrieval either: the indexer stores chunk TEXT alongside each vector and the query reads it back, so a second hop to Mongo or Postgres would only be needed if Chroma held vectors alone. It does not. Isolation is one Chroma instance, A2A's OWN database on it, and a collection per tenant — not one collection with a `where` filter, which is one forgotten argument away from returning another tenant's chunks. The chart's guards are verified to fire, not assumed: enabled with a complete config renders the env; enabled with `host` missing FAILS `helm template` naming the key; disabled renders zero CHROMA_* vars. 15 new tests for the package, weighted toward asserting it REFUSES, plus the provider-parity test. 176 pass across agent-templates/a2a. deploy.rag.enabled stays false: the Chroma service, the `a2a` database and a scoped token are FuzeInfra allocations, requested separately via @claude. Turning it on before those exist does not yield a degraded pod — it yields one that refuses to start, which is the intended behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
…port
Two defects in the chart wiring from the previous commit, both found by running
the guards rather than reading them.
1. Only `database` was projected. FuzeInfra's authorization provider
(helm/fuzeinfra/templates/chroma-authz.yaml) binds each token to exactly one
(tenant, database) PAIR, and the Chroma client resolves both eagerly at
construction — so a missing tenant is not a subtle permission error later,
it is a failure to connect at all. CHROMA_TENANT is now projected from
deploy.rag.tenant.
2. `required "..." .authTokenSecretRef.name` never ran. Helm evaluates the field
access first, so omitting the ref produced
nil pointer evaluating interface {}.name
instead of the message written to explain it — a guard that fires as a panic
tells an operator nothing about what to set. The ref itself is required first,
then each key.
Also corrected an overclaim in the template comment: `required` on `tenant` and
`database` only fires when they are nil or explicitly blanked, because
values.yaml gives them non-empty defaults. Omitting them yields the default,
which is right for the known allocation — but the comment said the guards catch
a missing value, and they do not.
Verified, each case run rather than reasoned about:
tenant: "" -> named error
host omitted -> named error
authTokenSecretRef -> named error (was a nil-pointer panic)
...ref.key omitted -> named error
complete config -> 7 CHROMA_* vars
disabled -> 0 CHROMA_* vars
helm lint -> 0 charts failed
docs/a2a/rag.md now names the exact FuzeInfra allocation to request. That
mechanism already exists — serviceChromaCollections[] provisions an isolated
tenant/database per consumer, seals its token, and verifies cross-tenant denial
in the provisioning job, with fuzeplan-repo-digester and fuzequality as live
entries. It is one more entry, not a new capability.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
The RAG doc claimed per-tenant collections give structural isolation. True here, but NOT for the reason a reader would assume, and the assumption is dangerous: stock Chroma does not isolate tenants in any published version. CVE-2026-45830 any authenticated user can read/write/delete data in any tenant's collection regardless of which tenant they belong to (0.4.17+) CVE-2026-45831 SimpleRBACAuthorizationProvider checks whether a user holds a permission but never which tenant/database/collection it applies to (0.5.0+) Neither has an upstream fix. Verified with pip-audit across 0.4.24, 0.5.23, 0.6.3, 1.0.0, 1.0.21, 1.1.1, 1.2.0 and 1.3.0 — no version is clean. What actually closes it is that FuzeInfra replaced the provider with TenantDatabaseAuthorizationProvider, which resolves the collection UUID in SysDB instead of trusting the request's tenant/database, and its provisioning job verifies cross-tenant denial with each token. Recording it so the isolation claim names its real mechanism. A confident boundary claim that silently depends on someone else having replaced a CVE'd component is the kind of thing that survives right up until the component is swapped back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
|
| version | advisories |
|---|---|
| 0.4.24 | 45830, 45833 |
| 0.5.23 | 45830, 45833, 45831 |
| 0.6.3 | 45830, 45833, 45831 |
| 1.0.0 → 1.3.0 | 45830, 45833, 45831, + PYSEC-2026-311 |
No version is clean. Upgrading cannot fix this and neither can downgrading, so I am not pushing a pin to make the check pass. I am also not adding --ignore-vuln for three advisories I did not assess and whose acceptance is not mine to decide.
What they actually are — and why this matters more than a red check
Two of them are cross-tenant isolation failures in Chroma's server-side authorization:
- CVE-2026-45830 — "lack of authorization validation … allows any authenticated user to arbitrarily read, write, update, or delete data in any tenant's collection regardless of which tenant they belong to" (0.4.17+)
- CVE-2026-45831 — "
SimpleRBACAuthorizationProvider… evaluates whether a user holds a given permission but never checks which tenant, database, or collection that permission applies to" (0.5.0+) - CVE-2026-45833 — code injection via
trust_remote_codeon a collection update, requiringUPDATE_COLLECTION
That lands directly on this PR's design, which is why I chased it rather than just reporting a red job. The isolation claim here — per-tenant collections inside a dedicated database — would be worthless on stock Chroma, because those two CVEs say stock Chroma does not enforce the tenant boundary at all.
It holds here for a reason that was implicit and is now written down (937ecaf): FuzeInfra does not use the built-in provider. helm/fuzeinfra/templates/chroma-authz.yaml ships TenantDatabaseAuthorizationProvider, which resolves the immutable collection UUID in SysDB instead of trusting the request's tenant/database — its own comment says it "prevents a token from using a known collection UUID to cross an allocation boundary" — and the provisioning job then verifies the denial by attempting a foreign-tenant write with each token.
So the mitigation for the two isolation CVEs is already deployed, and it is tested. docs/a2a/rag.md now says so explicitly, because a confident boundary claim that silently depends on someone else having replaced a CVE'd component is exactly the kind of thing that survives until the component is swapped back.
What I think should happen — owner's call, not mine
The exposure is server-side, and the server FuzeInfra runs is chromadb/chroma:0.5.23 with the stock authorization provider replaced. The Python package failing this scan is a client dependency; it is not running the vulnerable endpoints. That is the same shape of reasoning test.yml already uses for PYSEC-2026-311 ("uses chromadb.HttpClient() … never serves the vulnerable endpoint") — and PYSEC-2026-311 is additionally 1.0.0+, which 0.5.23 is not.
If that reasoning holds for these three, the honest resolution is a documented exception with the same rationale block the existing two carry, plus pinning the floating range so the gate stops being non-deterministic. Both are security decisions and belong to security / the owner, not to a PR about A2A retrieval — so I have not made them here.
Tracked as the pre-existing item "3 unfixable chromadb CVEs + a floating pin that makes the gate non-deterministic". Everything else on this PR is green as far as it has run; say the word and I will open the exception + pin as its own PR.
Generated by Claude Code
`black --check .` failed on exactly the two files this branch adds or edits; the other 70 were already clean. So this is my formatting debt, not a repo-wide reformat, and it is scoped to those two files deliberately. Scoped because this repo has already been bitten by the alternative: a whitespace-only reformat commit that silently reverted auth hardening in simple_main.py. A wide `black .` here would put the RAG auth fix inside a diff nobody can read. Verified inert rather than eyeballed — `ast.dump()` of each file before and after is IDENTICAL, which ignores whitespace entirely and so proves no behaviour changed. A text diff could not prove that: a reformat that drops a hunk looks like whitespace. The auth constants, the fail-closed CHROMA_ALLOW_UNAUTHENTICATED branch and every `raise RAGUnavailable` are still present afterwards. `black --check services/orchestrator/` now reports 72 files unchanged; isort is clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
…tant `backend-security-scan` failed on a finding this branch introduced: B105:hardcoded_password_string Possible hardcoded password: 'chromadb.auth.token_authn.TokenAuthClientProvider' ./rag_integration.py:45 It is a false positive, and provably so rather than by assertion. B105 fires on the variable NAME containing "TOKEN"; the VALUE is a dotted import path that Chroma resolves to a provider class. It is public — FuzeInfra passes the same literal to the same server in templates/service-chroma-provisioning.yaml, and the parity test compares it across two files, which would be an odd thing to do with a secret. The actual credential is read from CHROMA_AUTH_TOKEN at runtime and appears nowhere in this file. So: `# nosec B105` with the reason inline, matching the 43 deliberate skips this repo already carries (a2a_protocol.py B608, claude_code_wrapper.py B404). A suppression that records WHY, where a reviewer sees it, is the sanctioned path; suppressing a true finding to reach green is not, and this is not that. Considered and rejected: renaming the constant to dodge B105's keyword list. That makes the check stop firing without the reasoning being recorded anywhere, and leaves a later reader wondering why the name is odd. Verified with the workflow's exact command rather than a近 approximation: $ bandit -r . --skip B101 -f txt Total issues (by severity): Undefined 0, Low 0, Medium 0, High 0 exit 0 and the disabled-skip count moved 43 -> 44, so the annotation is doing the work rather than the finding having moved. `black --check` still reports 72 files unchanged. Not applied to agent-templates/a2a/rag/config.py, which carries the same constant: bandit does not scan it (gate-sast is Semgrep). Adding a suppression to a file nothing scans is its own small vacuity, so it is left off deliberately rather than by oversight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GaPa3JgrVNtWrGvqQEAEqv
The defect
FuzeAgent's RAG has been dead in production. Not degraded — dead, while every log line, health probe and endpoint reported success.
Three bugs in
services/orchestrator/rag_integration.py, and a fourth that hid the other three:CHROMA_HOSTdefaulted to"localhost"and nothing set it in any deployment manifestchroma_client_auth_provider="basic""basic"resolved to nothing and the client was built unauthenticated while appearing to configure authchroma_client_auth_credentials=""except Exceptionsetcollection = NoneRAGContext— a well-formed "we found nothing" that no caller could distinguish from an empty corpus(4) is the one that matters. The first three are ordinary bugs. (4) is why they were invisible for months.
The fixes are structural, not corrected literals
CHROMA_HOST. A default that is wrong but still lets the process start is worse than no default at all.services/orchestrator/tests/test_rag_provider_parity.pyfails if the orchestrator's copy and the A2A copy diverge. The two images cannot import each other — the orchestrator builds with contextservices/orchestrator, the A2A image with contextagent-templates— so the duplication is unavoidable. Leaving it silent is not.CHROMA_ALLOW_UNAUTHENTICATED=1, stated out loud in a values file. An empty credential is never read as "no auth wanted"./rag/searchand/rag/enhance-promptreturn 503, not 200-with-no-results.New:
agent-templates/a2a/rag/Retrieval for the shared A2A server, shipping in
ghcr.io/izzywdev/fuze-a2a, off unless the chart enables it.Retrieval only. Indexing needs the original documents, and a multi-tenant pod must not hold any tenant's document store — FuzeAgent's orchestrator keeps them on a filesystem under
KNOWLEDGE_STORAGE_PATH.And no document database is needed for retrieval either. The open question was whether Mongo or Postgres would be needed to fetch the source when Chroma holds only vectors. Measured: it does not. The indexer stores the chunk text alongside each vector (
collection.add(..., documents=text_chunks)) and the query reads it back (include=["documents", …]). A second hop would only be required if Chroma held vectors alone. The document store is for re-indexing and download, not for answering a query.Isolation is structural:
Per-tenant collections, not one collection with a
wherefilter — a filter is one forgotten argument away from returning another tenant's chunks; a separate collection cannot be read by omission.Verification
The chart's guards are verified to fire, not assumed:
A2A_RAG_ENABLED,CHROMA_HOST,CHROMA_AUTH_TOKENfrom the secret refhostmissinghelm templatefails:deploy.rag.host is required when deploy.rag.enabledCHROMA_*vars renderedhelm lint15 new tests for the package, weighted toward asserting it refuses (no host, no database, no credential, two credentials, a bare
"basic"provider, unreachable-≠-empty), plus the provider-parity test. 176 pass acrossagent-templates/a2a.Note on the
main.pydiff sizeservices/orchestrator/main.pyshows ~6000 changed lines. That is the CRLF→LF renormalisation this repo's own.gitattributesmandates andgate-line-endingshard-fails without —main.pyis one of the 20 known pre-existing CRLF files, and touching it converts it. The semantic diff is 13 insertions, 1 deletion:— the import, and the
except RAGUnavailablehandler on each of the two endpoints. I checked this specifically because a whitespace-only reformat silently reverting auth hardening has already happened in this repo once.Blocked, deliberately:
deploy.rag.enabled: falseThe Chroma service DNS, the
a2adatabase on it, and a scoped auth token delivered as a SealedSecret in thefuzeagentnamespace are FuzeInfra allocations. FuzeInfra is never edited from a consuming repo, so that request goes over separately via@claudewith the allocation named.Turning the flag on before those exist does not produce a degraded pod — it produces one that refuses to start. That is the intended behaviour and the whole point of the change.