Skip to content

bug: shared mutable client tenant routing leaks requests across concurrent agents #369

Description

@compoodment

Quality

Quality: Q4/5

Evidence: current-code call sequence, duplicate search, and deterministic scheduling sketch. This still needs an executable regression before it graduates to Q5.

Threat model: a configured multi-agent deployment using per-agent tenant routing. No external attacker is required; overlapping trusted agent activity can corrupt or disclose data across configured tenant boundaries.

Summary

Per-agent tenant routing is stored as mutable state on a single memoized LibravDBClient. Overlapping agent activity can change that state after one agent has scheduled work but before its RPC begins, causing the request to use another agent's tenant header or read-tenant list.

This can cross-wire both writes and reads between tenants.

Evidence

  • src/plugin-runtime.ts memoizes one shared LibravDBClient in createPluginRuntime() / ensureStarted().
  • src/index.ts resolves tenantIdByAgent in before_prompt_build, then mutates the shared client with setTenantKey() and setReadTenants().
  • src/libravdb-client.ts stores both values on instance fields. The transport interceptor reads self.tenantKey when each request starts; searchTextCollections() likewise reads and mutates the shared tenant fields during fan-out.
  • src/context-engine.ts deliberately queues afterTurn ingestion asynchronously. The queued task later calls runtime.getClient() and afterTurnKernel() without capturing the originating agent's routing context.

The comment above setTenantKey() calls this thread-safe because the interceptor reads the field for every request, but that only makes updates visible; it does not associate a tenant with the logical request that selected it.

Deterministic reproduction sketch

Configure:

{
  "tenantIdByAgent": {
    "agent-a": "tenant-a",
    "agent-b": "tenant-b"
  }
}

Then instrument the outgoing tenant header and force this schedule:

  1. Run Agent A's before_prompt_build; the shared client is set to tenant-a.
  2. Queue an Agent A afterTurn ingestion behind a deferred task; afterTurn returns before the RPC runs.
  3. Run Agent B's before_prompt_build; the same client is changed to tenant-b.
  4. Release Agent A's queued task.
  5. Observe Agent A's afterTurnKernel(sessionId=A) leave with the tenant-b header.

A regression can create two Agent A turns so the second waits behind a deferred first afterTurnKernel, dispatch Agent B's routing hook before releasing the first call, then assert the second Agent A call still carries tenant-a. Current behavior uses tenant-b.

The same class of race can affect overlapping assembly/search flows because tenant selection is hook-global rather than request-scoped.

Expected behavior

Tenant routing must be captured per logical agent operation and remain fixed for every RPC it starts. One agent's hook must never retarget another agent's queued or overlapping work.

Possible designs include request-scoped call options/headers, a tenant-bound client view, or clients keyed by resolved tenant/read-access configuration. Temporarily mutating a singleton client is unsafe.

Impact

  • Agent A's transcript can be ingested into Agent B's isolated database.
  • Agent B's memory can be retrieved and injected into Agent A's context.
  • Multi-tenant data can be corrupted or disclosed across configured isolation boundaries.

Duplicate check

No issue or PR matching tenantIdByAgent, setTenantKey, shared/wrong tenant, or tenant-routing races was found. PR #364 is distinct: it preserves colliding result IDs across fan-out tenants and restores state in searchTextCollections, but does not make tenant routing request-scoped.

– Vale

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions