Skip to content

canonical(enums): SecretType and SecretRequirement duplicate the secret taxonomy — agents cannot express an OAuth requirement #13846

Description

@mrveiss

Discovered while auditing the secrets vault for umbrella #13707 (company mailbox).

Design doc: docs/research/email-reading-capability-audit.md section 4.2

Problem

Two enums classify the same thing — what kind of credential a secret is — under
different names, in different layers:

SecretType SecretRequirement
where models/secret.py:33 services/agent_secrets_integration.py:28
role persisted classification on the Secret row what an agent type may request
shared SSH_KEY API_KEY PASSWORD TOKEN CERTIFICATE DATABASE_URL same six
only here OAUTH_REFRESH_TOKEN INFRASTRUCTURE_HOST OTHER ANY

Six members are duplicated verbatim — down to the identical # nosec B105 and
# nosemgrep suppression comments, which is a strong indication of copy-paste rather than
independent design.

This is not merely untidy — it is a functional gap

SecretRequirement has no OAUTH_REFRESH_TOKEN. AgentSecretMapping is expressed
purely in SecretRequirement terms, so an agent mapping cannot state that an agent needs
OAuth credentials
— the vocabulary has no word for it. Meanwhile SecretType persists
exactly that kind on the row.

Any agent whose credential is an OAuth refresh token can only be described as ANY, which
is the blanket "all available secrets" grant — the broadest possible request standing in
for the most specific one.

Why this matters now

Child #13711 of umbrella #13707 adds the agent write path to the vault
(store_secret_for_agent). Mail connectors authenticate via OAuth. An agent depositing a
mail credential must classify it — and the two layers will disagree at exactly that point,
with the requirement vocabulary unable to name the type being stored.

Fixing this is cheap now and gets progressively more expensive once agent-written secrets
exist in the database.

Why a name-based sweep missed this

#12771 triages same-name clusters. These carry different names for the same concept —
the blind spot of a name-keyed scan. Same shape as #13597.

Acceptance criteria

  • Triage first: confirm these are one concept in two layers, not two concepts
  • One canonical secret-kind vocabulary; the requirement layer derives from it rather
    than restating it
  • ANY preserved — it is a genuine wildcard, not a duplicate member, and must not be
    lost in consolidation
  • An agent mapping can express an OAuth-credential requirement without falling back
    to ANY
  • Existing persisted SecretType values migrate with no data loss
  • The nosec/nosemgrep suppressions survive on whichever definition remains

Files

  • autobot-backend/models/secret.py:33
  • autobot-backend/services/agent_secrets_integration.py:28

Activity

  1. mrveiss commented on Aug 24, 2026

    @mrveiss
    OwnerAuthor

    Delivered in #14957 (branch issue-13845-enum-union, head 17b58390). Member sets, deliberate-fork findings and the per-fork union tables are in the PR body. Not merged — CI in flight.

  2. mrveiss commented on Aug 24, 2026

    @mrveiss
    OwnerAuthor

    Delivered and merged as 342eeb05e (PR #14957).

    • CI: all 10 required contexts present and green, checked by name. 59 success / 2 skipped / 0 failures. All 12 python-suite shards green, including shard 10 — the one that went red on the pin tests.
    • Review: independent code-reviewer pass. Every fork's member set was reconstructed from the merge-base tree and diffed against the union rather than taken from the PR body.

    Nothing was dropped. FORBIDDEN, DANGEROUS, OAUTH_REFRESH_TOKEN and ANY all have a real home.

    FORBIDDEN and DANGEROUS were kept as separate members, and the reasoning is now in the source: they are distinct reasons reaching the same verdict — DANGEROUS means the command matched a destructive pattern (RISKY_COMMAND_PATTERNS), FORBIDDEN means the base command is on the executor's deny list (FORBIDDEN_COMMANDS). "dangerous" is also already serialized by two live endpoints. Folding either away would have broken a wire contract or erased a distinction the executor actually makes.

    The union created a fail-open, and the PR closed it. With two blocking members, an equality check against one let the other producer's blocking verdict through. The enum gained .blocks, and review confirmed all three production comparison sites migrated with none missed. A HIGH_RISK_COMMAND_LEVELS set that previously drew only from CommandRiskLevel now picks up FORBIDDEN automatically.

    map_risk_to_level was fail-open — it defaulted unmapped members to MEDIUM, downgrading a blocking verdict to mid-scale. Now total, with the fallback changed to CRITICAL, and a guard that reads the table via AST and asserts totality against the live enum so a future member without an entry fails loudly.

    A third SecretType the issues never named was found in api/schemas_system.py — also missing OAUTH_REFRESH_TOKEN, so POST /secrets could not accept the kind the database row already stored. And the real OAuth producer was persisting "connector_oauth_token" as a bare string, commented as a SecretType label while belonging to no enum at all.

    Four deliberate forks preserved with evidence, none collapsed: the #7258 legacy-wire RiskLevel, the standalone host-side SecretType, InjectionRisk (grades text, not commands — discovered by the new scan and documented rather than folded), and the three role vocabularies whose own issue states they are correctly separate.

    The guard caught itself failing open. Mutation M9 re-declared a fork behind from enum import Enum as _E and the scan stayed green, because it matched the literal word Enum. Fixed by resolving the import to its local names. A two-hop re-export remains a known limit, now stated in the docstring and pinned by a test that instructs the next person to update it rather than delete it.

    A regression this PR caused was found and fixed before merge: models/schemas.py gained an import that test_apply_secrets.py could not satisfy — that test replaces models.database with a MagicMock and hand-installs one real stand-in, because pydantic needs a genuine enum type. The result was a collection error, so all 9 tests in the file stopped running rather than one failing. The blast radius was then settled by collecting both trees (1697 each) rather than grepping, since collection is exactly where the failure manifests.

    Remaining and filed: #14956 (169 severity literals — the honest measured count, not the issue's 119, with 43 already out of vocabulary; ratcheted so it can only fall), #14974 (ANY advertised on two API surfaces that reject it), #14955 (CRITICAL rendering as unclassified grey).

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