Skip to content

security(secrets): credential redaction is name-keyed and cannot see credentials in free text — add content scanning before mail is indexed #13708

Description

@mrveiss

Part of umbrella #13707.

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

Wave 1 — foundation. Depends on: nothing. Can start immediately.

This blocks the mail connector. No mail may reach the knowledge base until this ships.

Problem

autobot_shared/secret_redaction.py redacts by field name, not by content. It cannot
see a credential sitting in free text.

  • is_credential_field(name) (line 84) matches a field name against CREDENTIAL_SUFFIXES.
  • redact_value(name, value) (line 121) only masks when handed a credential-shaped name.
  • redact_url_userinfo (line 100) handles connection strings, and fails closed on
    unparseable input — correct, but still URL-shaped values only.

Every caller is a config/model context: autobot_shared/ssot_config.py,
models/settings.py, pki/config.py, services/config_revision_service.py,
services/workflow_secret_service.py,
llm_shared/observability/tracing_config.py.

Nothing scans a message body.

Why this matters now

Signup and onboarding emails routinely carry the credential in the body — "your temporary
password is X", "here is your API key: Y". Under umbrella #13707 those bodies get parsed by
knowledge/connectors/content_extraction.py, persisted, indexed into Chroma, and embedded.

A credential copied into embeddings cannot be revoked. Deleting the vault row does
nothing about the vector-store copy, the logs, or the replay record.

This is already a known hole, independently of mail: llc/api/replay.py:261-263 states that
emails are not redacted in replay, noting it "would require regex patterns not"
currently present.

Scope

Add a content-scanning redactor alongside the existing name-keyed one. This is a
companion capability, not a rewrite — redact_value stays as-is for config paths.

  • Detect credential-shaped values in free text: high-entropy tokens, known key prefixes,
    bearer/JWT shapes, private-key PEM blocks, password is <x> phrasings, basic-auth URLs.
  • Emit a structured result — what matched, at what offset, with what confidence — so
    callers can quarantine rather than silently mangle.
  • Fail closed: an unparseable or ambiguous body is quarantined, not indexed in the clear.

Acceptance criteria

  • Content-scanning redactor exists in autobot_shared/, with the name-keyed
    redact_value left intact and still used by its current callers
  • Quarantine path: a body with a detected credential is stored redacted and never
    passed to the indexer/embedder in the clear
  • Wired into llc/api/replay.py, closing the gap documented at lines 261-263
  • False-positive behaviour is tested — a code snippet or a base64 image must not
    trigger quarantine of an entire thread
  • Tested against the reproduction, not just the predicate: a body containing a real
    credential shape is caught, and an ordinary body is not
  • No hardcoded UI strings if any operator-facing message is added

Files

  • autobot_shared/secret_redaction.py — companion content scanner
  • autobot-backend/llc/api/replay.py — consume it; remove the "not redacted" caveat
  • autobot-backend/knowledge/connectors/content_extraction.py — redact before persistence

Evidence

Full audit: docs/research/email-reading-capability-audit.md section 4.3.

Activity

  1. added this to the v0.9.0 milestone on Sep 12, 2026
  2. mrveiss commented on Sep 19, 2026

    @mrveiss
    OwnerAuthor

    Verified against merged code (origin/main @ 928573e3bb, PR #17077)

    The fix landed on branch issue-13708-content-redaction, merged into main via the
    vehicle-v090-2026-09-19-security-kb train (PR #17077, member PR #16895). All 9 commits
    tagged #13708 are in the merge range 9b21b99a78..928573e3bb. Issue only referenced
    (not auto-closed) because #17077's Closes line listed multiple issues and only the first
    fired — reopening/closing this manually now that the fix is verified.

    AC Status Evidence
    Content-scanning redactor in autobot_shared/, redact_value left intact for current callers ✅ Met autobot_shared/secret_redaction.py:303 scan_content_for_credentials(), :362 redact_content(). redact_value unchanged at :172, still used by autobot_shared/ssot_config.py, autobot-backend/models/settings.py, autobot-backend/pki/config.py, autobot-backend/llm_shared/observability/tracing_config.py.
    Quarantine path: a body with a detected credential is stored redacted, never reaches the indexer/embedder in the clear ✅ Met autobot-backend/knowledge/ingest_sanitize.py:122 — sanitize_fact_content() runs redact_content() before store_fact/update_fact reach _store_and_vectorize_fact (Redis projection + ChromaDB vectorization). Chokepoint closed further in commits 126c186ab7 (true KB-write chokepoint, all 4 API write routes + connector sync), f99e076738 (version-rollback gap), c25aea6124 (ECL pipeline, backup restore, URL-shaped metadata fields, dedup-hash consistency) — enforced by an AST sweep guard, repo_tests/kb_content_redaction_chokepoint_guard_test.py.
    Wired into llc/api/replay.py, closing the "not redacted... would require regex patterns not currently present" gap at the old lines 261-263 ✅ Met autobot-backend/llm_shared/credential_redaction.py:76 — redact_string() now calls redact_content() (the new content scanner) after its own patterns; consumed by llc/services/replay_service.py:349-369 (redact_dict/redact_string), which backs GET /agents/{id}/runs/{id}/replay-log?redact_pii=true in llc/api/replay.py. The old caveat text no longer exists anywhere in the repo (git grep for it returns nothing). End-to-end test: autobot-backend/llc/tests/test_replay_credential_redaction.py — drives a real onboarding-email body with a password through _log_to_dict(..., redact_pii=True) and asserts the password is gone.
    False-positive behaviour tested — code snippet / base64 image must not quarantine a whole thread ✅ Met autobot_shared/secret_redaction_test.py _FALSE_POSITIVES (lines ~100-114): code_snippet (api_key = os.environ.get(...), getKey()), base64_image (a data:image/png;base64,... <img> tag), plus plain text, UUID, commit SHA, constant name, GitHub URL, function signature — all asserted to produce zero matches and be left untouched by redact_content.
    Tested against the reproduction: a real credential shape is caught, an ordinary body is not ✅ Met Same file, _TRUE_POSITIVES (lines ~52-61): "your temporary password is X" phrasing, an OpenAI-shaped key, a JWT, a PEM private-key block, a basic-auth URL, and AWS/GitHub/Slack key shapes — each asserted caught by scan_content_for_credentials and masked by redact_content, with the original span asserted gone from the output. Idempotency also covered (redact_content(redact_content(x)) == redact_content(x)).
    No hardcoded UI strings if an operator-facing message was added ✅ N/A / met No frontend/.vue files appear in any #13708-tagged commit (git log --all --grep="#13708" --name-only has zero .vue hits) — this landed as backend redaction logic only, no operator-facing string was added.

    All 6 acceptance criteria are met in the code currently on main. Closing.

  3. mrveiss commented on Sep 19, 2026

    @mrveiss
    OwnerAuthor

    Closing: all 6 ACs verified against merged code on main (see evidence comment above).

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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions