Skip to content

security(llc): globally-unique agent_id makes cross-company slug existence probeable #15812

Closed
0 of 1 issue completed

Description

@mrveiss

Parent: #13935 · raised by review on #15804

The oracle, and why it is not closable at the route

AgentOrgNode.agent_id is globally unique — models/agent_org.py:59, unique=True, no company scoping and no composite constraint. So PUT /agents/{agent_id}/org cannot avoid distinguishing two cases:

Caller's company Slug state Response
any slug free node created, 200
any slug taken in another company 404 (after #15804's tenant scoping)

A caller holding admin.reporting_line.write can therefore probe whether an agent_id exists somewhere they cannot see.

#15804 tightened this from 500-vs-200 to 404-vs-200, which fixed the shape of the error and not the distinguishability — the two outcomes still differ, so the oracle survived the fix. That is worth stating plainly rather than leaving the earlier commit message to imply the leak was closed.

Why response shaping cannot fix it

Every option collapses:

  • Return 404 for both → the endpoint stops creating agents, which is its purpose.
  • Return 200 for both → a lie, and the subsequent read fails.
  • Return 409 for a taken slug → still "taken vs free", the same bit.

The distinguishability follows from a global namespace, not from the handler. It is the same class as username enumeration at signup: any system where names are globally unique and creation is permitted will tell you whether a name is taken.

What the leak is worth

Narrower than it first reads. It reveals "this slug exists somewhere", not which company holds it, nor anything about that company. agent_id is an operator-chosen slug (str(uuid4()) in the current seeding paths), so the namespace is not guessable in practice, and the caller must already hold admin.reporting_line.write.

That is an argument for prioritising it, not for dismissing it: a low-value oracle is still an oracle, and it becomes materially worse if agent slugs ever become human-readable (acme-support-bot), because guessable names turn a per-probe question into an enumeration.

The real decision

Should agent_id be unique per company rather than globally?

Blast radius is the reason this is its own issue rather than a fix on #15804: the slug is a keyspace, not just a name. Budgets, heartbeat runs and controls key on it (llc/api/companies.py reads budgets.get(row.agent_id), runs.get(row.agent_id)), and reports_to referenced it before #15763 moved reporting lines onto the UUID assignment keyspace (#10032). Making it company-scoped means every one of those lookups becomes (company_id, agent_id).

Acceptance

  • A decision is recorded: scope agent_id per company, or accept the oracle with the reasoning above
  • If scoped: every consumer keying on the slug is migrated, with no lookup left keyed on the slug alone
  • If accepted: the accepted risk is recorded where the constraint is declared, so the next reviewer finds the decision rather than re-deriving it
  • Either way, PUT /agents/{agent_id}/org stops being the only place this is documented

Activity

  1. mrveiss commented on Sep 6, 2026

    @mrveiss
    OwnerAuthor

    Decision recorded: scope per company, staged

    A second instance turned up while measuring the blast radius, and it is the worse of the two.

    Table agent_id company_id Oracle
    agent_org_nodes globally unique (models/agent_org.py:59) nullable (:65) yes — the one filed here
    llc_agent_budgets globally unique (llc/models/budget.py:32) NOT NULL (:31) yes — not previously recorded
    llc_heartbeat_runs not unique (llc/models/heartbeat_run.py:40) NOT NULL no
    llc_agent_api_keys not unique (llc/models/api_key.py:27) NOT NULL no

    llc_agent_budgets carries the scoping column already, non-nullable, and does not use it. So the
    same "is this slug taken" bit is answerable there, and closing it costs one constraint change with no
    data migration at all. It is a strictly cheaper fix than the one this issue was opened for.

    And agent_org_nodes cannot simply take a composite constraint. company_id is nullable by
    design — migration 20260523_037 states it outright: "The column is nullable so existing rows are
    unaffected; operators must set it"
    . A UniqueConstraint(company_id, agent_id) does not constrain
    rows where company_id IS NULL, because NULL != NULL under SQL unique semantics. Adding the
    constraint there without a backfill would look like a fix, pass its migration, and leave every
    unscoped row free to duplicate — a guard that reports clean having constrained nothing.

    The plan

    Wave 1 — llc_agent_budgets, this PR. Swap unique=True for
    UniqueConstraint(company_id, agent_id) and migrate every lookup keyed on the slug alone. No
    backfill, no nullable column, no expand/contract.

    Wave 2 — agent_org_nodes, its own issue. Backfill company_id, make it NOT NULL, then
    scope the constraint and migrate the slug-keyed consumers. Expand/contract, and it needs to be
    reviewed as a data migration rather than as a constraint change.

    Why staged rather than one PR: the two have nothing in common but the symptom. One is a
    zero-risk constraint swap; the other rewrites rows an operator may never have set company_id on.
    Landing them together gives them a shared fate, so a wrong backfill takes the safe fix down with it,
    and it puts a reviewer's attention on a data migration and a one-line constraint in the same pass.

    Wave 2 is filed and linked below. This issue stays open until both land — the decision it asks for is
    recorded here, but the oracle is only half closed by Wave 1.

  2. mrveiss commented on Sep 6, 2026

    @mrveiss
    OwnerAuthor

    Closed by #15864, merged as fb9b3f2c0. Every criterion verified against merged base (git show origin/Dev_new_gui:<path>), not against the branch.

    AC Evidence
    A decision is recorded Scoped, not accepted. llc/models/budget.py:29 — UniqueConstraint("company_id", "agent_id", name="uq_llc_agent_budgets_company_id_agent_id"), with migration 20260906_089_llc_agent_budgets_company_scope.py in base
    Every consumer keying on the slug is migrated, with no lookup left keyed on the slug alone Verified mechanically, not by reading. Every LLCAgentBudget.agent_id == in autobot-backend/llc/ outside tests was checked for a company_id predicate within its query: 0 unscoped. services/budget.py:48-51 is the canonical selector and carries both; api/budget.py alone carries 26 company_id references
    If accepted: risk recorded where the constraint is declared n/a — scoped rather than accepted
    PUT /agents/{agent_id}/org stops being the only place documented llc/models/budget.py:33-34 documents it at the column: "Per-company slug. NOT globally unique (#15812): global uniqueness made 'is this slug taken?' answerable across the tenant boundary"

    The second criterion is the one worth showing the method for. "No lookup left keyed on the slug alone" is a claim about absence, and a grep for the scoped form would confirm it while telling you nothing about the unscoped ones. So the check enumerated every occurrence of the slug predicate and asserted a company_id predicate accompanies each — a positive assertion over the whole population rather than a negative one over a sample.

    What is deliberately not covered here

    agent_org_nodes.agent_id remains globally unique. The two tables now disagree about what an agent_id identifies, and that is #15858's scope, not this one — this issue is the budget-slug oracle specifically. Recording it so the difference reads as a known boundary rather than an oversight.

    Review note

    The blocking finding on #15864 came from a second session and was correct: the added AgentOrgNode.company_id == company_uuid predicate matched nothing for NULL rows on that un-backfilled column, so the budget hard stop silently disabled itself — a zero-row UPDATE raises nothing and the caller swallowed the result. A security control reporting success having done nothing. Fixed with an is_(None) arm and a rowcount == 0 warning, plus tests against a real SQLite engine; the existing watchdog tests all mocked the session factory, which is why nothing caught a SQL NULL-semantics bug.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: company-osWave 4 · cluster A — Company OS & LLC canvasbackend

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions