← Back to document history
Document version

Muzzle — For the Bots v1

This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.

Preview
Source path
muzzle/forthebots.md
Source commit
No commit recorded
Created at
Jul 31, 2026, 8:22 PM UTC
Source digest
1514f426b2ea9ae0e66590cb70fc70d29c5fff7cc113b5a47e7fec3629d2031e

Document snapshot

Muzzle — For the Bots

An operator manual for AI coding agents (Claude Code, Codex, and similar). Dense and action-oriented: install, configure, edit, and operate Muzzle from a shell. Every path, command, and field is real (from products/muzzle/v1/). Humans: see README / HOWITWORKS.

Your mental model

Muzzle is a transparent inspecting proxy between AI clients and model backends (Ollama, OpenAI, Anthropic). Every request, streamed response, and tool call is parsed by a provider adapter into one canonical model, run through a rules-first policy engine (prompt-injection / secrets / PII / content denylist / subjects / tool-calls), then re-rendered into the provider-native shape and forwarded. Clients keep pointing at the same host:port they used for the provider; policy applies both directions and Muzzle fails closed by default (blocks if inspection can't complete).

Shape: listeners (a bound port → one upstream) + upstreams (a backend provider + base_url + key) + policies (per-direction category → action). Keys live only in Muzzle.

Prerequisites & install

  • Root-capable Linux host, Python ≥ 3.12, rsync, useradd. Deps (into the venv): starlette, uvicorn, httpx, pydantic(+settings), pyyaml, cryptography.
sudo bash install.sh        # in products/muzzle/v1/

It creates the muzzle system user, rsyncs to /opt/muzzle/v1 (venv .venv), writes the CLI wrapper /usr/local/bin/muzzle, renders default config to /etc/muzzle/muzzle.yaml (existing config never overwritten — new defaults go to muzzle.yaml.example), pre-creates the list files, generates the Fernet secrets.enc.key(0600)+secrets.enc(0640), renders /etc/systemd/system/muzzle.service, and offers to enable+start. Override with MUZZLE_INSTALL_{PREFIX,CONFIG,UNIT,BIN_DIR,PYTHON}, MUZZLE_SERVICE_USER/_GROUP.

systemctl restart muzzle          # or: muzzle restart (validates then restarts)
sudo bash uninstall.sh             # removes everything incl. the muzzle user

Configuration — /etc/muzzle/muzzle.yaml

Override with -c/--config <path> or MUZZLE_CONFIG. Pydantic-validated.

listeners:
  - bind: "0.0.0.0:11434"        # proxy (default kind); clients hit this port
    upstream: ollama-local        # REQUIRED for proxy listeners; keys into upstreams
  - bind: "0.0.0.0:11435"
    kind: admin                   # admin UI (config form, logs, simulation); upstream ignored

upstreams:                        # name -> backend
  ollama-local:
    provider: ollama              # ollama | openai | anthropic
    base_url: "http://127.0.0.1:11435"
    # api_key: ""                 # literal key
    # api_key_env: "OPENAI_API_KEY"  # env var name; WINS over api_key when set

fail_mode: closed                 # closed = block on undecidable (default) | open = passthrough

policies:
  default:                        # per-direction: category -> action
    input:
      prompt_injection: block
      secrets: redact
      pii: redact
      tool_calls: allow           # <-- set this, or tool calls are BLOCKED (see gotchas)
    output:
      content_policy: block
      secrets: block
      pii: redact
      tool_calls: allow
  overrides: {}                   # per-upstream PolicySet that FULLY REPLACES default (not a merge)

secrets:
  mode: rules                     # rules | file | both
  file: "/etc/muzzle/secrets.enc" # encrypted; key at <file>.key

content_rules:
  denylist_terms: []              # inline banned terms, both directions
  subjects: []                    # inline [{name, action}]
  content_policy_files: {input: /etc/muzzle/content_policy.input.txt, output: .../output.txt}
  subject_files:       {input: /etc/muzzle/subjects.input.txt,       output: .../output.txt}

llm_judge:
  enabled: false                  # optional LLM classifier
  model: "llama3"
  base_url: ""                    # REQUIRED when enabled; a REAL model server, never a Muzzle listener

logging:
  level: info
  decisions: /var/log/muzzle/decisions.jsonl   # "stdout" or a file path (file needed to `muzzle logs`)
  • Categories: prompt_injection, secrets, pii, content_policy, subject, plus the special key tool_calls. Actions: allow, log, redact, transform, block (block = most severe).
  • Listener kind: proxy (forwards to one upstream, inspects) or admin (serves the admin UI only; 404s on model paths).

The CLI (complete surface)

Entry: muzzle. Bare muzzle runs the server (what systemd invokes). Global -c/--config. Every config-mutating subcommand auto-restarts the service.

CommandPurpose
muzzle (no args)Run the proxy/admin server (foreground).
muzzle HOST:PORT NAMEShorthand: add/replace upstream NAME at http://HOST:PORT (provider→ollama) + repoint the first proxy listener.
muzzle validateLoad+validate config (<path>: ok).
muzzle edit$EDITOR the config, then validate + restart.
muzzle statusSummary: fail_mode, listeners, upstreams, denylist/subject counts, judge, log sink.
muzzle restartValidate then systemctl restart muzzle.service.
muzzle logs [--follow --tail N --since --contains --upstream --request-id --stage --action --category]Query/tail the JSONL decision log.
muzzle input [--prompt-injection --secrets --pii --content-policy A]Set default input policy actions.
muzzle output […]Set default output policy actions.
muzzle add --input-content T | --output-content TAppend a denylist term and set that direction's content_policy: block.
muzzle upstream listname base_url provider.
muzzle upstream add BASE_URL NAMEAdd upstream (base_url first!, provider→ollama) + repoint first proxy listener.
muzzle upstream remove NAMEDelete + repoint listeners that used it.
muzzle simulate --upstream U [--phase input|output] [--endpoint chat|generate] [--file F]Dry-run policy on a payload (stdin if no file). Does NOT forward.
muzzle secrets list|add VALUE|remove VALUE|mode {rules,file,both}Manage the encrypted secrets file / detection mode.
muzzle terms list|add TERM|remove TERM (--input|--output)Per-direction content-policy term files.
muzzle subjects list|add NAME [--action A]|remove NAME (--input|--output)Per-direction subject files.

The CLI can't set provider or add a listenerupstream add / the shorthand always write provider: ollama. For openai/anthropic upstreams or new listeners, muzzle edit the YAML (or use the admin UI).

Common tasks (recipes)

# Point Muzzle at a local Ollama (adds upstream + repoints first proxy listener)
muzzle upstream add http://127.0.0.1:11435 ollama-local     # or: muzzle 127.0.0.1:11435 ollama-local

# Add an OpenAI-compatible ROUTER upstream (provider=openai must be set by editing YAML)
muzzle edit
#   upstreams:
#     openai-router: { provider: openai, base_url: "https://router.internal/v1", api_key_env: "OPENAI_API_KEY" }
#   listeners:
#     - { bind: "0.0.0.0:11437", upstream: openai-router }

# Redact secrets both directions
muzzle input --secrets redact
muzzle output --secrets redact

# Ban a term outbound (blocks it)
muzzle add --output-content "internal-codename"

# Rotate a key via env file (keys live only in Muzzle)
sudoedit /etc/muzzle/env.d/openai.env        # OPENAI_API_KEY=sk-new...
systemctl restart muzzle                      # env read at process start

# Inspect + verify after a change
muzzle validate && muzzle restart
muzzle status; muzzle upstream list; muzzle logs --tail 20

# Preview a decision without hitting upstream
echo '{"model":"llama3","messages":[{"role":"user","content":"my key is sk-abc"}]}' \
  | muzzle simulate --upstream ollama-local --phase input --endpoint chat

Key paths & env

/etc/muzzle/muzzle.yaml · /opt/muzzle/v1 (venv) · /var/log/muzzle/decisions.jsonl · /usr/local/bin/muzzle · /etc/systemd/system/muzzle.service · /etc/muzzle/secrets.enc(+.key) · list files /etc/muzzle/{content_policy,subjects}.{input,output}.txt. Runtime env: MUZZLE_CONFIG, PYTHONPATH=/opt/muzzle/v1, plus any api_key_env var. On a Sentinel box the key vars come from EnvironmentFile=/etc/muzzle/env.d/*.env drop-ins — a deployment customization; the repo unit has no EnvironmentFile, so api_key_env won't resolve until one is added.

Gotchas & failure modes

  • tool_calls defaults to BLOCK. If a policy set omits tool_calls, any request/response carrying tool calls is silently blocked ("tool calls blocked by policy") — this quietly breaks agentic (function-calling) traffic. Set tool_calls: allow in both directions. There's no CLI flag for it — edit YAML.
  • Overrides replace, not merge. A per-upstream override PolicySet fully replaces the default — a sparse override drops categories AND re-triggers the tool_calls default-block.
  • Auth injection is OpenAI-only. Muzzle injects Authorization: Bearer only for provider: openai. An Anthropic upstream needing x-api-key isn't authenticated by this path.
  • upstream add arg order is BASE_URL NAME (base_url first) — easy to reverse.
  • Fail-closed default: an inspection/adapter error → HTTP 503 muzzle rejection. fail_mode: open → 502 passthrough error. A blocked request returns a provider-style error at HTTP 400 with log#<ref> (maps to a decision-log line) + direction + category.
  • Upstream errors relayed verbatim (upstream '<name>' returned <code>: <detail>) — that's the backend failing, not Muzzle.
  • decisions: stdout disables muzzle logs — set a file path to tail.
  • Never hand-edit secrets.enc (Fernet ciphertext) — use muzzle secrets …. With mode: rules the file is ignored until muzzle secrets mode both.
  • LLM judge (if enabled) calls its base_url directly, bypassing inspection — never point it at a Muzzle listener (loop). Off by default.
  • Streaming responses are fully buffered before re-emission (added latency; model-call timeout 300s). Reinstall preserves config (new keys land in .yaml.example).

Integration

  • Sentinel sends all model traffic here: sentinel.yaml muzzle_endpoint: http://127.0.0.1:11434 (a Muzzle proxy listener); each Sentinel model's provider must be a configured Muzzle upstream. Provider API keys live only in Muzzle. Under Sentinel's production/airgapped profiles, muzzle_enabled: false is rejected fail-closed and each confined run emits a security_attestation recording the Muzzle endpoint.
  • Leash governs where the agent can go / what it runs (destination names only); Muzzle inspects the content of model I/O. Complementary — Sentinel requires both.

Quick reference

Config:   /etc/muzzle/muzzle.yaml     Service: muzzle.service
Shape:    listeners[bind, kind:proxy|admin, upstream] -> upstreams[provider, base_url, api_key_env] + policies
Point at a backend:  muzzle upstream add <BASE_URL> <NAME>     (openai/anthropic -> edit YAML)
Allow tool calls:    set tool_calls: allow in policies.default.input AND .output   (else BLOCKED)
Verify:   muzzle validate && muzzle restart && muzzle logs --tail 20

Diff from previous

This is the first approved version, so there is no previous diff.