Security
Muzzle's checks run on your Mac. This page lists everything that leaves it, what our servers keep, and how rules, licences and updates are signed.
Last updated 29 October 2026
On your Mac
- Hooks for Claude Code, Codex and git check commands, prompts, tool calls and commits before they run.
- A local proxy (Pro) on
127.0.0.1:8899checks the HTTP(S) requests that routed agent commands make, then forwards them to where they were going. Traffic is never routed through our servers. - Settings lock: agent commands that try to stop Muzzle, change its settings, skip its git checks or send traffic around the proxy are refused. You can still do all of this yourself.
- Local data (settings, the event log, the local CA and the downloaded rules) lives in
~/Library/Application Support/Muzzleunder your macOS user.
The local certificate authority
To read HTTPS requests, the proxy uses a certificate authority created on your Mac at setup. Each Mac has its own.
| Name | Muzzle Local CA |
| Key | ECDSA P-256, generated on the Mac; the private key file is readable only by your user (0600, in a 0700 folder) and never leaves the Mac |
| Scope | Server authentication only; cannot sign other CAs (path length 0); valid 10 years |
| Trust | Added to your login keychain, not the System keychain |
| Site certificates | Made in memory per host, valid one year, never written to disk |
| Removal | Uninstalling from the app removes the certificate and its trust from the login keychain |
The proxy decrypts nothing without Pro. With Pro, it still passes through without decrypting the agents' own model APIs, sites you mark as trusted, and, for an hour, any host whose client rejects the certificate.
Package checks
When an agent installs a package, the Mac looks up the package name directly with OSV (api.osv.dev) for known malware and with its registry (npm, PyPI or crates.io) for its age. The request carries the package name and a Muzzle/<version> user agent; nothing reaches our servers. You can turn package checks off in Settings.
What reaches our servers
Without Pro or the Threat Feed, the app sends only the first two.
| Request | When | What it carries | What we keep |
|---|---|---|---|
Update checkusemuzzle.com/updates | At launch and every 6 hours | App version | A count by version and country |
Threat Feed rulesGET /v1/feed/rules | Once a day | No identifier | Nothing |
Licence check/v1/license/activate | On activation, then about daily | Licence key, device id, computer name (paid keys) | Salted hash of the device id, computer name, last seen |
Joining the Threat Feed/v1/email/start, /v1/community/join | When you turn contributing on | Email address, device id, a random install secret | Email, salted device hash, hash of the install secret |
Threat Feed signalsPOST /v1/feed | Every 6 hours while contributing | The fields shown below | Daily counts, salted device hash, salted network hash |
Contact address/v1/aliases | When you turn it on (Pro) | Forwarding email address, licence key | Address, status, forward count, hash of its secret |
The device id is a SHA-256 hash of the Mac's hardware UUID, computed on the Mac. Signals are posted with the Threat Feed key, not your email.
Threat Feed
Contributing is off until you turn it on. A post contains only signals of these five shapes:
// blocked request {"t":"block", "rule":"protected:email", "host_domain":"example.com", "context":"body"} // injection attempt: SHA-256 of the normalised text, never the text {"t":"injection", "kind":"exfiltration", "snippet_hash":"9f2c…e41a", "host_domain":"example.com"} // a false alarm you cleared {"t":"false_positive", "rule":"protected:name", "context":"header:user-agent"} // a site you trusted {"t":"trusted", "host_domain":"example.com"} // a secret format that matched {"t":"secret_format", "rule":"aws-access-key"}
- On the Mac: domains are cut to the registrable domain; IP addresses and local names (
.local,.internal,localhost) are never sent; every string is limited to 64 characters ofa–z 0–9 . _ : -with no runs of 7 or more digits; any signal containing one of your protected values, an email address or a secret is dropped. - On the server: a signal with any extra field, or a value outside those rules, rejects the whole post. At most 500 signals and 256 KB per post, one post per 10 minutes.
- Shared rules are built only from Macs that have contributed for 7 days, and only when enough separate Macs on separate networks agree (3 to 25 Macs, depending on the rule).
The rules file is signed with Ed25519. The app checks the signature before saving it and again every time it loads it; an unsigned or altered file is ignored. Public key:
KwLMyW65OqSK5AFtNoJByLspxHex8CC9MF7uQV7gWtI=
Licences
Pro is unlocked by a signed answer from our licence API, not by a local flag. The answer names the key's hash, its status, the device id and an expiry 14 days out, and is signed with Ed25519. The app verifies it against a built-in public key on every check and refreshes it about once a day; if our API can't be reached, the last answer keeps working until it expires. A paid key works on 3 Macs at a time.
hfHNRUQcU3Kq4UZm3HvziXv+wEfJ9nfChKHC+SANN8E=
App signing and updates
| Code signing | Developer ID Application: aabergkvist AB (team 8VRX6BSAXA), hardened runtime |
| Notarization | The app and the disk image are notarized by Apple and stapled |
| Updates | Fetched over HTTPS from usemuzzle.com. Each update archive is signed with our updater key; the app checks the signature against the public key built into it and refuses an update that doesn't match. |
| Download | Through a personal link sent by email. The installer URL it opens is signed and expires after 10 minutes. |
To check an installed copy: spctl -a -vv /Applications/Muzzle.app should report source=Notarized Developer ID.
Infrastructure and data location
| Website and API | Cloudflare Workers (usemuzzle.com, api.usemuzzle.com) |
| Database | Cloudflare D1, primary in Western Europe, no read replicas |
| Cloudflare Email Routing receives; Resend sends | |
| Payments | Stripe, which handles all card details |
| Analytics | Umami, run by us on Hetzner in the EU; no cookies |
The app and the website talk to our servers over HTTPS. The full list, with what each provider receives, is on Sub-processors.
Sign-in tokens, confirmation tokens and contact-address secrets are stored only as SHA-256 hashes. Device ids, abuse counters and networks are stored as salted hashes. Licence keys and email addresses are stored as they are, because we email and look them up.
Access
- The API's admin endpoints (quote requests, download requests, manual licence grants, test emails) require a bearer token kept as a Cloudflare secret.
- Signing keys for rules and licences, the session key and provider API keys are Cloudflare secrets, not in source code. The updater signing key is kept outside the repository and loaded only during a release.
- Stripe webhooks are rejected unless their signature verifies and is under 5 minutes old.
- The account page signs in with a one-time link that works for 15 minutes. The session cookie is
HttpOnly,SecureandSameSite=Laxand lasts 30 days.
Retention
A cleanup job runs every day at 03:17 UTC.
| Threat Feed signals | 91 days |
| Abuse counters | 3 days |
| Sign-in and confirmation links | 1 day |
| Unconfirmed contact addresses | 7 days |
| Macs on a key | 12 months after last seen |
| Contact addresses | 12 months after the last forward or status check |
| Licences | While the subscription exists, then 24 months after cancellation |
| Quote and download requests | 24 months |
| Confirmed emails and Threat Feed registrations | Until you ask us to delete them |
Mail forwarded through a contact address or to support is not stored in our database. Details are in the privacy policy.
For your security review
- Data Processing Agreement and sub-processors
- Muzzle has no SOC 2 or ISO 27001 report. For a security questionnaire, request it with a quote or email support@usemuzzle.com.
Report a vulnerability
Email support@usemuzzle.com. Scope and safe harbour are in our disclosure policy; machine-readable contact details are in security.txt.