Muzzle — For the Bots v3
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 + its own read_timeout_s / stream_upstream) +
policies (per-direction category → action). Keys live only in Muzzle.
A request may carry max_output_tokens to cap one turn: sent as max_tokens to an
OpenAI-shaped upstream and as options.num_predict to Ollama (top-level there is
silently ignored — a cap that looks applied and is not). 0 = no cap, the default.
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
# read_timeout_s: 300 # PER-UPSTREAM wait for the reply. Unstreamed this is
# # effectively a token ceiling (~21 tok/s -> ~6k at 300s),
# # and a reasoning model spends CoT from the same budget.
# stream_upstream: false # ask THIS upstream to stream; Muzzle reassembles the
# # same complete body. A read timeout is a gap between
# # BYTES, so this stops a long turn being killed by the
# # shortest timeout in the path. Off by default: an
# # upstream without it pays a failed try + buffered retry.
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). tool_callsgates the CALL, not the reading of it. Under any value butblock, a tool call'sargumentsare inspected like any other text and the per-category actions decide the outcome — sosecrets: block+tool_calls: allowblocks a tool call carrying a key. The reported reason is"... detected in tool call arguments".- 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 [--provider P] [--api-key-env VAR] [--read-timeout S] [--stream-upstream] [--no-route] | Add/update upstream (base_url first!, provider→ollama) + repoint first proxy listener unless --no-route. Re-running it preserves fields you don't pass. |
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 add a LISTENER — for a new listener, muzzle edit the YAML (or use the
admin UI). It can set the provider and the key env var: upstream add --provider openai|anthropic|ollama|elevenlabs --api-key-env VAR. The bare shorthand
(muzzle HOST:PORT NAME) still writes provider: ollama.
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 (key goes in the EnvironmentFile, never argv)
printf 'OPENAI_API_KEY=%s\n' "$KEY" | sudo tee /etc/muzzle/sentinel-keys.env >/dev/null
sudo chown muzzle:muzzle /etc/muzzle/sentinel-keys.env && sudo chmod 0600 /etc/muzzle/sentinel-keys.env
sudo muzzle upstream add https://router.internal openai-router \
--provider openai --api-key-env OPENAI_API_KEY --read-timeout 900
# 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.tool_calls: allowis NOT a bypass. It permits the call; the arguments are still scanned. It used to return before the scan, so the one setting agents need skipped the likeliest exfiltration channel (a key in anhttp_postbody). Fixed — but note the next item, which still limits it.- Tool calls are inspected in BOTH directions. Every provider parses tool calls on the
request (an assistant turn in the history) and on the response, and the arguments are
scanned in each case; a redaction is written back in the provider's own wire format. The
request pass appears in the decision log as its own
input-toolsstage, and only when the request actually carried tool calls. - 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 per provider, and the client's own headers are NOT forwarded.
openai→Authorization: Bearer;anthropic→x-api-keyplus the mandatoryanthropic-version(Anthropic rejects a request without one);elevenlabs→xi-api-key;ollama→ nothing. No key configured means no auth header at all, for every provider — the symptom is a 401 from the far end, not anything Muzzle reports. 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> [--provider P --api-key-env VAR]
Allow tool calls: set tool_calls: allow in policies.default.input AND .output (else BLOCKED)
(allow still INSPECTS the arguments, on request AND response)
Verify: muzzle validate && muzzle restart && muzzle logs --tail 20
Diff from previous
--- forthebots@2
+++ forthebots@3
@@ -108,6 +108,10 @@
- **Categories:** `prompt_injection`, `secrets`, `pii`, `content_policy`, `subject`, plus the
special key `tool_calls`. **Actions:** `allow`, `log`, `redact`, `transform`, `block`
(block = most severe).
+- **`tool_calls` gates the CALL, not the reading of it.** Under any value but `block`, a tool
+ call's `arguments` are inspected like any other text and the per-category actions decide the
+ outcome — so `secrets: block` + `tool_calls: allow` blocks a tool call carrying a key. The
+ reported reason is `"... detected in tool call arguments"`.
- **Listener `kind`:** `proxy` (forwards to one upstream, inspects) or `admin` (serves the
admin UI only; 404s on model paths).
@@ -129,16 +133,17 @@
| `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 add BASE_URL NAME [--provider P] [--api-key-env VAR] [--read-timeout S] [--stream-upstream] [--no-route]` | Add/update upstream (**base_url first!**, provider→ollama) + repoint first proxy listener unless `--no-route`. Re-running it preserves fields you don't pass. |
| `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).
+**The CLI can't add a LISTENER** — for a new listener, `muzzle edit` the YAML (or use the
+admin UI). It *can* set the provider and the key env var: `upstream add --provider
+openai|anthropic|ollama|elevenlabs --api-key-env VAR`. The bare shorthand
+(`muzzle HOST:PORT NAME`) still writes `provider: ollama`.
## Common tasks (recipes)
@@ -146,10 +151,11 @@
# 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" }
+# Add an OpenAI-compatible ROUTER upstream (key goes in the EnvironmentFile, never argv)
+printf 'OPENAI_API_KEY=%s\n' "$KEY" | sudo tee /etc/muzzle/sentinel-keys.env >/dev/null
+sudo chown muzzle:muzzle /etc/muzzle/sentinel-keys.env && sudo chmod 0600 /etc/muzzle/sentinel-keys.env
+sudo muzzle upstream add https://router.internal openai-router \
+ --provider openai --api-key-env OPENAI_API_KEY --read-timeout 900
# listeners:
# - { bind: "0.0.0.0:11437", upstream: openai-router }
@@ -189,11 +195,22 @@
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.
+- **`tool_calls: allow` is NOT a bypass.** It permits the call; the arguments are still
+ scanned. It used to return before the scan, so the one setting agents need skipped the
+ likeliest exfiltration channel (a key in an `http_post` body). Fixed — but note the next
+ item, which still limits it.
+- **Tool calls are inspected in BOTH directions.** Every provider parses tool calls on the
+ request (an assistant turn in the history) and on the response, and the arguments are
+ scanned in each case; a redaction is written back in the provider's own wire format. The
+ request pass appears in the decision log as its own `input-tools` stage, and only when the
+ request actually carried tool calls.
- **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.
+- **Auth injection is per provider, and the client's own headers are NOT forwarded.**
+ `openai` → `Authorization: Bearer`; `anthropic` → `x-api-key` **plus** the mandatory
+ `anthropic-version` (Anthropic rejects a request without one); `elevenlabs` → `xi-api-key`;
+ `ollama` → nothing. No key configured means no auth header at all, for every provider —
+ the symptom is a 401 from the far end, not anything Muzzle reports.
- **`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
@@ -223,7 +240,8 @@
```
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)
+Point at a backend: muzzle upstream add <BASE_URL> <NAME> [--provider P --api-key-env VAR]
Allow tool calls: set tool_calls: allow in policies.default.input AND .output (else BLOCKED)
+ (allow still INSPECTS the arguments, on request AND response)
Verify: muzzle validate && muzzle restart && muzzle logs --tail 20
```