Per-agent credentials

Bind each agent to specific WhatsApp / Telegram / Google accounts so outbound traffic originates from the right number, bot, or mailbox — never from a shared pool.

Mental model

Three layers:

  1. Plugin instance — a labelled WhatsApp session or Telegram bot in config/plugins/{whatsapp,telegram}.yaml. Each instance owns its own token / session_dir and an optional allow_agents list.
  2. Google account — an entry in the optional config/plugins/google-auth.yaml. Each account is 1:1 with an agent_id.
  3. Agent binding — in config/agents.d/<agent>.yaml, the credentials: block pins the agent to the instance / account it may use for outbound tool calls.

The runtime runs a boot-time gauntlet that cross-checks all three layers before any plugin boots. Every invariant violation surfaces in a single report so you can fix the full YAML in one edit.

Config schemas

config/agents.d/ana.yaml

agents:
  - id: ana
    credentials:
      whatsapp: personal        # must match whatsapp.yaml instance
      telegram: ana_bot         # must match telegram.yaml instance
      google:   ana@gmail.com   # must match google-auth.yaml accounts[].id
      # Opt-out for the symmetric-binding warning when inbound bot and
      # outbound bot are intentionally different:
      # telegram_asymmetric: true
    inbound_bindings:
      - { plugin: whatsapp, instance: personal }
      - { plugin: telegram, instance: ana_bot }

config/plugins/whatsapp.yaml

whatsapp:
  - instance: personal
    session_dir: ./data/workspace/ana/whatsapp/personal
    media_dir:   ./data/media/whatsapp/personal
    allow_agents: [ana]           # defense-in-depth ACL
  - instance: work
    session_dir: ./data/workspace/kate/whatsapp/work
    media_dir:   ./data/media/whatsapp/work
    allow_agents: [kate]

config/plugins/telegram.yaml

telegram:
  - instance: ana_bot
    token: ${file:./secrets/telegram/ana_token.txt}
    allow_agents: [ana]
    allowlist:
      chat_ids: [1194292426]
  - instance: kate_bot
    token: ${file:./secrets/telegram/kate_token.txt}
    allow_agents: [kate]

config/plugins/google-auth.yaml

google_auth:
  accounts:
    - id: ana@gmail.com
      agent_id: ana                       # 1:1 — the gauntlet enforces it
      client_id_path:     ./secrets/google/ana_client_id.txt
      client_secret_path: ./secrets/google/ana_client_secret.txt
      token_path:         ./secrets/google/ana_token.json
      scopes:
        - https://www.googleapis.com/auth/gmail.modify

Agents that still declare the legacy inline google_auth block are auto-migrated into this store on boot (a warning tells you to migrate).

What the gauntlet validates

CheckLenientStrict
Duplicate session_dir across instanceserrorerror
session_dir that is a parent of anothererrorerror
Credential file with lax permissions (linux 0o077)errorerror
credentials.<ch> points to an instance that does not existerrorerror
Agent listens on >1 instance without declaring credentials.<ch>errorerror
Instance allow_agents excludes a binding agenterrorerror
Inbound instance ≠ outbound instance (no <ch>_asymmetric)warnerror
Inline agents.<id>.google_auth without matching google-auth.yamlwarnwarn

Linux permission check is skipped for /run/secrets/* (Docker secrets) and can be disabled entirely with CHAT_AUTH_SKIP_PERM_CHECK=1.

Topics

Outbound tool calls land on instance-suffixed topics when the resolver has a binding:

plugin.outbound.whatsapp.<instance>
plugin.outbound.telegram.<instance>

Unlabelled (instance: None) plugin entries keep publishing to the legacy bare topic plugin.outbound.whatsapp / plugin.outbound.telegram for full back-compat.

CLI gate

# Run the full gauntlet without booting the daemon. Exits 0 clean,
# 1 on errors, 2 on warnings-only.
agent --config ./config --check-config

# Promote warnings to errors (CI lane).
agent --config ./config --check-config --strict

The gate scans agents.yaml, every agents.d/*.yaml, whatsapp.yaml, telegram.yaml, and google-auth.yaml. Sample failure:

credentials: FAILED with 1 error(s):
   1. agent 'ana_per_binding_example' binds credentials.telegram='ana_tg' but no such telegram instance exists (available: [])

Secrets in logs

The credential layer never logs a raw account id. Every reference is via an 8-byte sha256(account_id) fingerprint rendered as hex:

2025-04-24T16:03:42Z INFO credentials.audit agent="ana" channel="whatsapp" fp=a3f2…7c direction=outbound

The fingerprint is pinned — switching the algorithm is an explicit breaking change tracked by crates/auth/tests/fingerprint_stability.rs.

Observability

Nine Prometheus series land at /metrics:

SeriesTypeLabels
credentials_accounts_totalgaugechannel
credentials_bindings_totalgaugeagent, channel
channel_account_usage_totalcounteragent, channel, direction, instance
channel_acl_denied_totalcounteragent, channel, instance
credentials_resolve_errors_totalcounterchannel, reason
credentials_breaker_stategaugechannel, instance
credentials_boot_validation_errors_totalcounterkind
credentials_insecure_paths_totalgauge
credentials_google_token_refresh_totalcounteraccount_fp, outcome

Back-compat

  • Configs without a credentials: block keep working — the resolver infers outbound from the single inbound_bindings entry when it is unambiguous; otherwise outbound tools are marked unbound and fall back to the legacy bare topic.
  • Plugin entries with instance: None stay on the legacy bare topic.
  • agents.<id>.google_auth still registers google_* tools for that agent; google-auth.yaml is preferred going forward.

Hot-reload (no daemon restart)

Edit agents.d/*.yaml, plugins/whatsapp.yaml, plugins/telegram.yaml, or plugins/google-auth.yaml, then trigger a reload via the loopback admin endpoint:

curl -fsSX POST http://127.0.0.1:9091/admin/credentials/reload | jq
{
  "accounts_wa": 2,
  "accounts_tg": 2,
  "accounts_google": 1,
  "warnings": [],
  "version": 4
}

The resolver runs the gauntlet against the fresh files, then atomically swaps bindings in place. Plugin tools holding Arc<…> references see the new state on their next call. Failure mode: gauntlet errors return HTTP 400 with the error list; the previous bindings stay active so a typo in YAML does not knock out the runtime.

CredentialHandles already issued to in-flight tool calls keep working — handles are by-value clones; the resolver only mediates lookup of future calls.

What the reload does NOT cover

  • Adding a brand-new WhatsApp / Telegram instance still requires a restart for the plugin (each instance owns its own session_dir
    • websocket). The resolver picks up the new account but the plugin side stays as-was until next boot.
  • Removing an account leaks its breaker entry in BreakerRegistry until restart. No correctness impact.

Google client_id / client_secret rotation

Rewriting the secret files (./secrets/<agent>_google_client_id.txt, ..._client_secret.txt) is picked up automatically on the next google_* tool call — GoogleAuthClient checks file mtime before each network hop and re-reads when it advanced. No reload call required for that case. Audit log line:

INFO credentials.audit event="google_secrets_refreshed" \
  google_*: re-read client_id/client_secret after on-disk rotation

Strict mode

agent --check-config --strict promotes warnings to errors. Two checks behave differently under strict:

ConditionLenientStrict
Inline agents.<id>.google_auth block (legacy)warn + auto-migrateBuildError::LegacyInlineGoogleAuth, fail boot
Asymmetric inbound ≠ outbound (no <ch>_asymmetric: true)warnerror

Run --strict in CI to gate PRs that touch credential YAML.

Migrating

  1. Add instance: + allow_agents: to each entry in whatsapp.yaml / telegram.yaml.
  2. Create config/plugins/google-auth.yaml with one accounts[] per agent that needs Gmail.
  3. Add credentials: to each agents.d/*.yaml.
  4. Run agent --check-config --strict. Fix every listed error.
  5. Commit.