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.
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 keytool_calls. Actions:allow,log,redact,transform,block(block = most severe). - Listener
kind:proxy(forwards to one upstream, inspects) oradmin(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.
| Command | Purpose |
|---|---|
muzzle (no args) | Run the proxy/admin server (foreground). |
muzzle HOST:PORT NAME | Shorthand: add/replace upstream NAME at http://HOST:PORT (provider→ollama) + repoint the first proxy listener. |
muzzle validate | Load+validate config (<path>: ok). |
muzzle edit | $EDITOR the config, then validate + restart. |
muzzle status | Summary: fail_mode, listeners, upstreams, denylist/subject counts, judge, log sink. |
muzzle restart | Validate 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 T | Append a denylist term and set that direction's content_policy: block. |
muzzle upstream list | name base_url provider. |
muzzle upstream add BASE_URL NAME | Add upstream (base_url first!, provider→ollama) + repoint first proxy listener. |
muzzle upstream remove NAME | Delete + 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 listener — upstream 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_callsdefaults to BLOCK. If a policy set omitstool_calls, any request/response carrying tool calls is silently blocked ("tool calls blocked by policy") — this quietly breaks agentic (function-calling) traffic. Settool_calls: allowin 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: Beareronly forprovider: openai. An Anthropic upstream needingx-api-keyisn't authenticated by this path. upstream addarg order isBASE_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 withlog#<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: stdoutdisablesmuzzle logs— set a file path to tail.- Never hand-edit
secrets.enc(Fernet ciphertext) — usemuzzle secrets …. Withmode: rulesthe file is ignored untilmuzzle secrets mode both. - LLM judge (if enabled) calls its
base_urldirectly, 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.yamlmuzzle_endpoint: http://127.0.0.1:11434(a Muzzle proxy listener); each Sentinel model'sprovidermust be a configured Muzzle upstream. Provider API keys live only in Muzzle. Under Sentinel'sproduction/airgappedprofiles,muzzle_enabled: falseis rejected fail-closed and each confined run emits asecurity_attestationrecording 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