Skip to content

feat(a2a): ship RAG retrieval in the shared image; fix the RAG that was inert in prod - #194

Open
github-actions[bot] wants to merge 5 commits into
mainfrom
claude/a2a-base-and-skills
Open

feat(a2a): ship RAG retrieval in the shared image; fix the RAG that was inert in prod#194
github-actions[bot] wants to merge 5 commits into
mainfrom
claude/a2a-base-and-skills

Conversation

@github-actions

@github-actions github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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:

# Defect Effect
1 CHROMA_HOST defaulted to "localhost" and nothing set it in any deployment manifest the pod dialled itself
2 chroma_client_auth_provider="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="" even with (2) fixed, it would have authenticated with an empty secret
4 a broad except Exception set collection = None every search then 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 first three are ordinary bugs. (4) is why they were invisible for months.

The fixes are structural, not corrected literals

  • No default for CHROMA_HOST. A default that is wrong but still lets the process start is worse than no default at all.
  • The auth providers are dotted class paths held in one constant each, and services/orchestrator/tests/test_rag_provider_parity.py fails if the orchestrator's copy and the A2A copy diverge. The two images cannot import each other — the orchestrator builds with context services/orchestrator, the A2A image with context agent-templates — so the duplication is unavoidable. Leaving it silent is not.
  • Unauthenticated access requires CHROMA_ALLOW_UNAUTHENTICATED=1, stated out loud in a values file. 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, 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:

FuzeInfra Chroma instance
└── database: a2a                 ← allocated to A2A, not the default
    ├── collection: a2a-fuzeagent
    ├── collection: a2a-fuzebi
    └── collection: a2a-<tenant>

Per-tenant collections, not one collection with a where filter — 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:

Case Result
enabled + complete config renders A2A_RAG_ENABLED, CHROMA_HOST, CHROMA_AUTH_TOKEN from the secret ref
enabled, host missing helm template fails: deploy.rag.host is required when deploy.rag.enabled
disabled (default) zero CHROMA_* vars rendered
helm lint 0 charts failed

15 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 across agent-templates/a2a.

Note on the main.py diff size

services/orchestrator/main.py shows ~6000 changed lines. That is the CRLF→LF renormalisation this repo's own .gitattributes mandates and gate-line-endings hard-fails withoutmain.py is one of the 20 known pre-existing CRLF files, and touching it converts it. The semantic diff is 13 insertions, 1 deletion:

git diff --ignore-cr-at-eol -- services/orchestrator/main.py
 services/orchestrator/main.py | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

— the import, and the except RAGUnavailable handler 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: false

The Chroma service DNS, the a2a database on it, and a scoped auth token delivered as a SealedSecret in the fuzeagent namespace are FuzeInfra allocations. FuzeInfra is never edited from a consuming repo, so that request goes over separately via @claude with 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.

…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
@izzywdev
izzywdev marked this pull request as ready for review August 25, 2026 00:43
@izzywdev
izzywdev self-requested a review as a code owner August 25, 2026 00:43
izzywdev and others added 2 commits August 25, 2026 00:51
…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

Copy link
Copy Markdown
Owner

dependency-check is red, and it is not this PR

The failing job scans services/orchestrator/requirements.txt. This PR does not touch that file. The advisories come from chromadb>=0.5.0,<2.0.0 — a floating range that resolves to 1.1.1 today.

chromadb 1.1.1  CVE-2026-45830
chromadb 1.1.1  CVE-2026-45833
chromadb 1.1.1  CVE-2026-45831

The two exceptions ci.yml carries (PYSEC-2026-311, PYSEC-2026-1325) predate these three. This will red every PR in the repo until it is decided, not just this one.

No upstream fix exists — measured, not assumed

I ran pip-audit against eight pins rather than reading the empty "Fix Versions" column and stopping there:

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_code on a collection update, requiring UPDATE_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

izzywdev and others added 2 commits August 25, 2026 01:01
`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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant