← Back to document history
Document version

Muzzle — For the Bots v2

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
Oct 18, 2026, 8:50 AM UTC
Source digest
b2524b827bb900b9258ca2ef38d3ef8682e147d1b247983eddfd6588d065f4db

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 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 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_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

--- forthebots@1
+++ forthebots@2
@@ -15,8 +15,12 @@
 **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.
+`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
 
@@ -56,6 +60,14 @@
     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