Unified LLM key

Wire up DEFENSECLAW_LLM_KEY — the single environment variable that powers the LLM judge, the MCP / skill / plugin scanners, and any custom LLM call DefenseClaw makes through Bifrost.

DefenseClaw routes every LLM call through one of two layers: the in-process Bifrost SDK (used by the gateway and judge) or LiteLLM (used by the scanner SDKs). Both layers derive the provider-specific key (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) from a single canonical knob — DEFENSECLAW_LLM_KEY — plus the model prefix you pass on the model field.

Set it once. Verify it with defenseclaw keys check. Override per-component only when you need a separate billing or rate-limit posture for the judge or a specific scanner.

Why one key

Operators were drowning in N=4 keys for an install (judge, skill scanner, MCP scanner, gateway upstream) — usually all pointing at the same OpenAI / Anthropic / Bedrock account. The unified key collapses that to one env var, with optional per-component overrides for the rare case where they diverge.

The happy path

Pick the provider and model

defenseclaw setup llm

The wizard writes the unified top-level llm: block (provider, model and the env var that holds the key). The judge and the scanners inherit it unless you override them. In scripts, pass the values as flags:

defenseclaw setup llm --non-interactive \
  --provider anthropic \
  --model claude-sonnet-4-5 \
  --api-key-env DEFENSECLAW_LLM_KEY

Set the key (interactive, hidden prompt)

defenseclaw keys set DEFENSECLAW_LLM_KEY

The CLI prompts for the value without echoing it, then writes it to the current user's DefenseClaw dotenv file: ~/.defenseclaw/.env on macOS/Linux or %USERPROFILE%\.defenseclaw\.env on Windows. POSIX installs enforce mode 0o600; native Windows installs replace inherited access with an owner-private DACL. The confirmation shows only the last four characters (…9f3a), and the audit log never records the value.

Verify it landed

defenseclaw keys check

Exits 0 when every required key for the current config is set, non-zero otherwise, and prints a one-line summary. Use it as a CI or preflight gate.

Run guardrail

defenseclaw setup guardrail

When you turn the judge on, the wizard offers the LLM you configured (Use this LLM for the judge?) instead of asking for a provider, model and key again.

Watch the flow

~/project
Recorded on a host with the judge turned on for Claude Code. keys list shows what the current config needs, keys set writes to ~/.defenseclaw/.env, and keys check is the CI gate.

All four keys subcommands

Prop

Type

Resolution order (what keys list actually reads)

The CLI resolves credential values from the most specific source down:

PrioritySourcemacOS/Linux exampleWindows PowerShell example
1Process environmentexport DEFENSECLAW_LLM_KEY='sk-…'$env:DEFENSECLAW_LLM_KEY = 'sk-…'
2User dotenv~/.defenseclaw/.env%USERPROFILE%\.defenseclaw\.env
3UnsetRequired keys are marked missing; optional keys are tolerated.Same behavior.

This order is implemented by cli/defenseclaw/credentials.py::resolve and is the same code path that defenseclaw quickstart and defenseclaw doctor use, so what you see in keys list is exactly what the running gateway and scanners see.

No operating-system keychain or external secret manager

DefenseClaw does not read or write macOS Keychain, Windows Credential Manager, 1Password, Vault, AWS Secrets Manager, or another external secret store. The user dotenv file is the only on-disk credential store. To source a secret elsewhere, inject it into the process environment with export on macOS/Linux or $env:NAME = ... in PowerShell.

Per-component overrides

For most installs, DEFENSECLAW_LLM_KEY is the only knob you ever set. When you want a different key for a specific feature — say, a higher rate-limit account for the judge, or a separate billing line for the skill-scanner LLM second opinion — you point that one component at its own env var:

~/.defenseclaw/config.yaml
guardrail:
  judge:
    enabled: true
    llm:
      provider: anthropic
      model: claude-sonnet-4-5
      api_key_env: JUDGE_ANTHROPIC_KEY     # override; falls through to DEFENSECLAW_LLM_KEY otherwise

scanners:
  skill_scanner:
    use_llm: true
    llm:
      api_key_env: SKILL_SCANNER_OPENAI_KEY

Then store those custom env names just like the canonical key:

defenseclaw keys set JUDGE_ANTHROPIC_KEY
defenseclaw keys set SKILL_SCANNER_OPENAI_KEY
defenseclaw keys check    # both REQUIRED entries should now be ✓ set

keys list automatically resolves the env name from cfg.resolve_llm(), so the table you see is always the env vars you actually configured — not the canonical defaults.

Non-interactive (CI and scripts)

printf '%s\n' "$LLM_KEY" | defenseclaw keys set DEFENSECLAW_LLM_KEY --value-stdin
defenseclaw keys check
$env:LLM_KEY | defenseclaw keys set DEFENSECLAW_LLM_KEY --value-stdin
defenseclaw keys check

--value-stdin reads the value from the first line of standard input without echoing it, so it stays out of the process list and your shell history. --value "$LLM_KEY" also works, but other local users can see command-line values in ps. keys check returns a non-zero exit code that fails the build cleanly.

For multi-key bootstraps in CI (rare; usually a single key is enough):

for ENV in DEFENSECLAW_LLM_KEY CISCO_AI_DEFENSE_API_KEY SPLUNK_ACCESS_TOKEN; do
  printf '%s\n' "${!ENV}" | defenseclaw keys set "$ENV" --value-stdin   # bash indirection
done
defenseclaw keys check
$names = 'DEFENSECLAW_LLM_KEY', 'CISCO_AI_DEFENSE_API_KEY', 'SPLUNK_ACCESS_TOKEN'
foreach ($name in $names) {
  (Get-Item "Env:$name").Value | defenseclaw keys set $name --value-stdin
}
defenseclaw keys check

Provider routing: Bifrost vs LiteLLM

Both routing layers consume DEFENSECLAW_LLM_KEY plus the model prefix on the configured model name. You do not need to set provider-specific env vars. Every component starts from the unified top-level llm: block that defenseclaw setup llm writes; a component-level block overrides only the fields it sets:

ComponentLayerModel setting (falls back to llm.model)
Unified default(inherited by all)llm.provider and llm.model, written by defenseclaw setup llm
Gateway upstream LLM (proxy connectors)Bifrost (Go SDK)guardrail.llm.model prefix (openai/, anthropic/, bedrock/, vertex/, azure/, ollama/, …)
LLM judgeBifrostguardrail.judge.llm.model prefix
Skill scanner second opinionLiteLLM (Python SDK)scanners.skill_scanner.llm.model prefix
MCP scanner introspectionLiteLLMscanners.mcp_scanner.llm.model prefix

Local providers (ollama/, vllm/, lm_studio/) need no key — keys list correctly classifies them as NOT_USED even when the feature is on.

Bifrost provider catalog

Inside the Go gateway, every provider call goes through the embedded Bifrost SDK — DefenseClaw never speaks directly to a provider. Bifrost handles auth, retries, streaming, and routing, and gives DefenseClaw one consistent shape for tool calls, completions, and embeddings.

mapProviderKey is the source of truth for what's wired today:

ProviderProvider nameNotes
OpenAIopenaiDefault for gpt-*, o1-*, o3-*, o4-*
AnthropicanthropicDefault for claude-*
Google Geminigemini (also gemini-openai)Default for gemini-*
AWS Bedrockbedrock or amazon-bedrockResolves region/profile from env
Azure OpenAIazureNeeds deployment name + endpoint
OpenRouteropenrouterMulti-provider passthrough
GroqgroqLower-latency open models
MistralmistralMistral Cloud
OllamaollamaLocal model server (no key needed)
Vertex AIvertex_ai (also vertex, vertexai)Google Vertex
CoherecohereCohere Cloud
PerplexityperplexityPerplexity API
CerebrascerebrasCerebras Cloud
Fireworksfireworks (also fireworks_ai, fireworksai)Fireworks AI
xAIxaiGrok models
HuggingFacehuggingfaceHF Inference Endpoints
ReplicatereplicateReplicate hosted models
vLLMvllmSelf-hosted vLLM (no key needed)
DeepSeek, Together AI, LM Studiodeepseek, together_ai (also togetherai, together), lm_studio (also lmstudio)Reached through their own base_url with Bifrost's OpenAI-compatible adapter

You don't pick the Bifrost key directly. You pick a model — the gateway maps gpt-4o-mini → openai, claude-3-5-sonnet-20241022 → anthropic, and so on. The unified key is then handed to whichever provider the model resolves to.

What consumes the key

What gets stored where

WhereWhatNotes
User-home .defenseclaw/.envCanonical credential storeAtomic tmp+rename writes. POSIX uses 0o600; Windows uses an owner-private DACL. No comments or metadata.
User-home .defenseclaw/audit.dbAction log entry per keys setRecords actor=cli:operator action=config.update target=dotenv:<ENV> with before/after = had_value. The value is never logged.
User-home .defenseclaw/config.yamlThe *_env references onlyThe actual secret never enters config.yaml.
Process environmentHighest-priority lookupexport on macOS/Linux or $env:NAME = ... in PowerShell wins over the dotenv copy. Useful for one-shot debugging.

Hook-based vs proxy-based connectors

How an agent connector intercepts model traffic determines what the unified key actually pays for:

Connector classExamplesWhat the LLM does
Hook-basedCodex, Claude Code, Hermes, Cursor, Devin, Copilot CLI, OpenHands, Antigravity, OpenCode, Amp, OmniGent, KiroThe agent talks to its own LLM directly. DefenseClaw runs as a judge only: it inspects prompts/responses and emits verdicts. The unified key is only needed when guardrail.judge.enabled: true.
Proxy-basedOpenClaw, ZeptoClawOutbound LLM traffic is routed through the gateway proxy. The gateway uses the unified key for both the upstream LLM (the agent's model) and the optional judge.

The wizard records the intent on guardrail.llm_role:

  • judge_only — set automatically for hook-based connectors. Only the judge consumes the key.
  • judge_and_agent — set automatically for proxy-based connectors. The same key powers the agent's upstream LLM and the judge.

Set the role explicitly when scripting:

This OpenClaw proxy example is for macOS/Linux. Native Windows is hook-only and rejects OpenClaw setup.

defenseclaw setup guardrail \
  --connector openclaw \
  --llm-role judge_and_agent \
  --judge-model anthropic/claude-sonnet-4-5 \
  --non-interactive

Custom providers for internal or self-hosted endpoints

Internal LLM gateways (a corporate vLLM cluster, an isolated Bedrock mirror, a lab vending machine) plug in via the ~/.defenseclaw/custom-providers.json overlay. Each entry exposes its own base_url, env keys, allowed request types, and per-instance TLS posture.

defenseclaw setup provider add \
  --name acme-internal-bedrock \
  --base-provider-type bedrock \
  --base-url https://llm.internal:8443 \
  --domain llm.internal \
  --env-key ACME_BEDROCK_KEY \
  --allowed-request chat \
  --allowed-request embedding \
  --available-model us.anthropic.claude-sonnet-4-6 \
  --request-path-override chat=/openai/v1/chat/completions \
  --ca-cert-file /etc/ssl/acme-root.pem

What a custom provider does depends on the connector

A custom provider resolves the same globally, but a binding enforced on the agent versus judge/aux only depends entirely on the connector class:

Connector classA bound custom provider…
Proxy (OpenClaw, ZeptoClaw)Is enforced. The agent's model traffic routes through DefenseClaw, so the custom provider can serve the agent's upstream model, the judge, or both.
Hook (Hermes, Cursor, Codex, OpenCode, Amp, OmniGent, Claude Code, Devin, Copilot, OpenHands, Antigravity, Kiro)Configures DefenseClaw's judge/aux model only. The agent talks to its own model directly — those calls are never routed through or inspected by DefenseClaw, so a custom provider cannot change the agent's model.

Hook connectors: a custom provider is judge-only

This is the easiest thing to get wrong: attaching a custom provider while guarding a hook connector does not make the agent run on your private model. It only changes the model DefenseClaw uses to judge. The CLI states this for you — setup llm prints an "Enforced" vs "Judge/aux only" note when you bind an instance, and setup provider list prints the same legend for the active connector. Programmatically, GET /v1/connectors reports llm_traffic_mode ("proxy" | "hooks-only") per connector.

Set --domain when you set --base-url

The gateway resolves an inbound request to a custom-provider entry by matching the request URL's host against each entry's domains list. If you set --base-url but never declare a --domain covering the same host, defenseclaw doctor emits a base_url host … not covered by domains warning and the gateway falls back to default TLS / routing because it cannot match the inbound URL to the overlay. Always pass --domain <host> alongside --base-url <scheme>://<host>:<port> (or add the host to domains in the JSON overlay directly).

Then bind a resolved role to the instance:

defenseclaw setup llm \
  --provider custom \
  --instance-name acme-internal-bedrock \
  --model us.anthropic.claude-sonnet-4-6 \
  --api-key-env ACME_BEDROCK_KEY \
  --non-interactive

The judge can point at a different instance than the top-level LLM without copy-pasting:

defenseclaw setup guardrail \
  --judge-instance-name acme-judge-cluster \
  --judge-provider anthropic \
  --judge-model claude-sonnet-4-5 \
  --non-interactive

defenseclaw doctor validates that every instance_name in your config resolves to an overlay entry, warns when an overlay TLS block declares both ca_cert_pem and insecure_skip_verify, and warns when an entry's base_url host is not covered by its domains list (which would otherwise silently bypass the overlay at request time).

Bedrock, Vertex AI, Azure on a custom instance

A custom instance can carry the full regional posture (region, auth mode, deployment aliases) so a single setup llm --instance-name X call is all that's needed downstream. Same flags as the role-level regional flags below — they just attach to the overlay entry instead.

defenseclaw setup provider add \
  --name acme-bedrock-prod \
  --base-provider-type bedrock \
  --domain bedrock-runtime.us-east-2.amazonaws.com \
  --bedrock-region us-east-2 \
  --bedrock-auth-mode iam_credentials \
  --bedrock-access-key-env ACME_AWS_AKID \
  --bedrock-secret-key-env ACME_AWS_SECRET \
  --bedrock-inference-profile us. \
  --bedrock-deployment fast=anthropic.claude-3-haiku-20240307-v1:0 \
  --bedrock-deployment heavy=anthropic.claude-3-opus-20240229-v1:0

defenseclaw setup llm \
  --provider custom \
  --instance-name acme-bedrock-prod \
  --model heavy \
  --non-interactive

When the upstream is the public AWS Bedrock service (rather than an internal proxy in front of it), the --domain value should be the regional Bedrock runtime host — that's the URL the agent's outbound calls actually carry. Internal Bedrock mirrors should use the mirror's host plus --base-url.

The same shape works for Vertex AI (--vertex-project-id, --vertex-region, --vertex-auth-mode, --vertex-service-account-json-env) and Azure OpenAI (--azure-endpoint, --azure-api-version, --azure-auth-mode, --azure-deployment-alias model=deployment).

Role wins, overlay fills blanks

When the resolved role and the overlay both declare a sub-block, the role's fields win and the overlay only supplies values the role omitted. The example below pins region on the role but inherits auth mode and the deployment alias table from the overlay:

# ~/.defenseclaw/config.yaml
llm:
  provider: custom
  instance_name: acme-bedrock-prod
  bedrock:
    region: us-west-2          # overrides overlay's us-east-2
# ~/.defenseclaw/custom-providers.json
{
  "providers": [
    {
      "name": "acme-bedrock-prod",
      "base_provider_type": "bedrock",
      "bedrock": {
        "region": "us-east-2",                  // shadowed by role above
        "auth_mode": "iam_credentials",         // inherited
        "access_key_env": "ACME_AWS_AKID",      // inherited
        "secret_key_env": "ACME_AWS_SECRET",    // inherited
        "deployment_aliases": {                 // inherited
          "fast": "anthropic.claude-3-haiku-20240307-v1:0",
          "heavy": "anthropic.claude-3-opus-20240229-v1:0"
        }
      }
    }
  ]
}

Effective dispatch posture: region us-west-2, IAM-credentials auth from ACME_AWS_*, alias table from the overlay. defenseclaw doctor flags overlay fields that the role silently shadows so the dead config can be pruned.

Regional providers (Bedrock, Vertex AI, Azure OpenAI)

Regional providers require more than a single API key: a region (or project), an auth mode, and sometimes a deployment alias. The CLI ships dedicated flags so non-interactive installs can configure them without hand-editing YAML.

AWS Bedrock

defenseclaw setup llm \
  --provider bedrock \
  --model us.anthropic.claude-sonnet-4-6 \
  --bedrock-region us-east-1 \
  --bedrock-auth-mode iam_credentials \
  --bedrock-access-key-env AWS_ACCESS_KEY_ID \
  --bedrock-secret-key-env AWS_SECRET_ACCESS_KEY \
  --bedrock-session-token-env AWS_SESSION_TOKEN \
  --bedrock-inference-profile us. \
  --non-interactive

Auth modes:

ModeWhat's required
api_key (default)AWS_BEARER_TOKEN_BEDROCK or DEFENSECLAW_LLM_KEY
iam_credentials--bedrock-access-key-env, --bedrock-secret-key-env, optional --bedrock-session-token-env
profile--bedrock-profile-name (matching ~/.aws/credentials)
instance_rolenothing — EC2 / EKS pod identity supplies short-lived creds

Only api_key needs DEFENSECLAW_LLM_KEY. With the other three modes setup llm shows the key as (not needed: bedrock auth_mode=...) and defenseclaw doctor skips its LLM API key row; LLM reachable still checks that the AWS credentials work.

Google Vertex AI

defenseclaw setup llm \
  --provider vertex_ai \
  --model claude-sonnet-4-5@vertex \
  --vertex-project-id acme-prod-vertex \
  --vertex-region us-central1 \
  --vertex-auth-mode service_account \
  --vertex-service-account-json-env GOOGLE_APPLICATION_CREDENTIALS \
  --non-interactive

Azure OpenAI

defenseclaw setup llm \
  --provider azure \
  --model gpt-4o \
  --azure-endpoint https://my-resource.openai.azure.com \
  --azure-api-version 2024-10-21 \
  --azure-auth-mode api_key \
  --azure-deployment-alias gpt-4o=gpt4o-prod \
  --azure-deployment-alias gpt-4o-mini=gpt4o-mini-prod \
  --api-key-env AZURE_OPENAI_API_KEY \
  --non-interactive

defenseclaw doctor calls _check_regional_provider_config which fails fast when a regional sub-block is missing a required field, and runs _check_llm_reachable to send a one-shot max_tokens=1 ping through the resolved route.

Reachability ping

Pair any setup llm invocation with --ping to issue an immediate llm.ping after save. It returns (ok, message) and never raises, so the wizard stays usable when the network is flaky:

defenseclaw setup llm \
  --provider anthropic \
  --model claude-sonnet-4-5 \
  --api-key-env DEFENSECLAW_LLM_KEY \
  --non-interactive --ping
  ✓ llm ping: ok (anthropic/claude-sonnet-4-5)

Reference