Skip to content

security(llc): agent API-key routes and the agent diary ignore the tenant context they bind #13771

Closed

Description

@mrveiss

Summary

Four more handlers in llc/api/ bind require_org_context and never use the result. The same
AST scan that #13756's AC 4 asks for flags them:

autobot-backend/llc/api/api_keys.py:55  create_api_key(ctx)
autobot-backend/llc/api/api_keys.py:76  revoke_api_key(ctx)
autobot-backend/llc/api/api_keys.py:91  list_api_keys(ctx)
autobot-backend/llc/api/runs.py:182     get_agent_diary(ctx)

(llc/api/findings.py:105 get_policy(_ctx) also appears, but its parameter is underscore-named —
the dependency is there as a gate only and the policy it returns is not tenant data. Left alone.)

Evidence

POST /agents/{agent_id}/api-keys — privilege escalation, not just a read.
The company an agent key is minted for comes from the request body, never from the caller's context:

async def create_api_key(
    agent_id: str,
    body: ApiKeyCreate,                                  # body.company_id
    ctx: TenantContext = Depends(require_org_context),   # bound, unused
) -> ApiKeyCreated:
    record, plaintext = await _svc.issue_key(
        session, agent_id=agent_id, company_id=body.company_id, name=body.name
    )

Any authenticated caller with an org context can issue a working agent API key scoped to another
company
by naming that company's id in the body, and the plaintext is returned to them once.

DELETE /agents/{agent_id}/api-keys/{key_id} — ApiKeyService.revoke_key filters on
id + agent_id + revoked_at IS NULL only. Knowing an agent id and a key id is enough to revoke
another company's key (availability).

GET /agents/{agent_id}/api-keys — the query is select(LLCApiKey).where(LLCApiKey.agent_id == agent_id)
with no company predicate, so key metadata (names, company_id, last_used_at) for another tenant's
agent is readable.

GET /agents/{agent_id}/diary — reads AgentDiaryService.read(agent_name=agent_id) straight from
the KB with no tenant predicate; another company's agent diary entries are returned verbatim.

Impact

  • Cross-tenant write: minting a valid agent credential for another company (create).
  • Cross-tenant denial of service: revoking another company's live key.
  • Cross-tenant read: key metadata and agent diary content.

LLCApiKey already carries company_id, so the scope exists in the data model — it is simply never
applied at these four seams.

Suggested fix

  1. issue_key: take the company from ctx.org_id. Keep body.company_id only as an assertion —
    a mismatch is a 403, not a silently-honoured override.
  2. revoke_key / list: add a keyword-only company_id to ApiKeyService and filter on it; a
    mismatch returns "not found" (404), matching the convention WorkItemService.get documents.
  3. get_agent_diary: resolve the agent's company and refuse ids outside ctx.org_id.
  4. Keep the AST scan from security(llc): /api/llc/agent/context/{item_id} binds tenant context but never applies it — cross-tenant work-item + goal read #13756 green — no bound-but-unused require_org_context in llc/api/
    except deliberately underscore-named gate parameters.

Acceptance criteria

  • A caller in company B cannot mint an agent API key for company A (403, no key created)
  • A caller in company B cannot revoke or list company A's agent keys (404)
  • A caller in company B reading company A's agent diary gets no entries
  • Regression tests for all four routes, two companies each
  • The bound-but-unused scan over llc/api/ reports only findings.get_policy(_ctx)

Provenance

Found while implementing #13756 (same defect class, different routes). Filed rather than folded in:
the API-key fix changes issuance semantics and touches ApiKeyService, which is a different blast
radius from the read-only context endpoint.

Related: #13756, #13704, #13729.

Activity

  1. github-actions commented on Aug 9, 2026

    @github-actions
    Contributor

    PR #13773 (merged to Dev_new_gui) references this issue with a close keyword.

    security(llc): scope agent API-key and diary routes to the caller's company (#13771)

    If this issue is fully resolved, close it manually. If work remains, no action is needed.

  2. mrveiss commented on Aug 9, 2026

    @mrveiss
    OwnerAuthor

    Delivered and merged: PR #13773, squashed as 874db93.

    All four bound-but-unused handlers are now scoped:

    • create_api_key mints for ctx.org_id. ApiKeyCreate.company_id became optional and is honoured only as an assertion — naming another company is 403, never silently applied. This was the privilege escalation: a caller could mint a working agent credential for another company and receive the plaintext once.
    • revoke_api_key passes company_id; a cross-tenant key raises KeyError, which the route already maps to 404.
    • list_api_keys filters on company_id.
    • get_agent_diary gates on LLCHeartbeatRun — the agent-to-company binding the sibling routes in the same router already scope on, rather than a second notion of which company an agent belongs to. An unknown agent reads as empty, not 404, so the response cannot be used to probe which agent ids exist elsewhere.

    revoke_key grew a keyword-only, optional company_id, matching the shape #13704 established on WorkItemService.get, so existing internal callers are unchanged.

    llc/tests/test_agent_routes_tenant_scope.py    8 passed
    full LLC suite                                 1409 passed, 1 skipped
    

    Mutation check — reverting all three source files to base: 7 failed, 1 passed. The one that stays green asserts only the route's KeyError → 404 mapping, which holds either way.

    Two assertions were tightened after a first mutation run showed them passing against unfixed code: the list test now matches the rendered WHERE clause rather than the whole statement (company_id is also a selected column, so a full-statement match passed with no predicate at all), and the create test omits company_id from the body entirely instead of sending the caller's own.

    findings.get_policy(_ctx) is the only remaining scan hit, deliberately — underscore-named parameter, the dependency is a gate, and the policy is not tenant data.

    Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions