Security
muaz is designed to be run locally, by one person, on their own machine. There is no hosted muaz service: your prompts, files, sessions, and secrets never leave your computer except to reach the LLM providers and MCP servers you configure. This page is the reference to hand a security team evaluating muaz for use inside a regulated organization.
Data-flow at a glance
Section titled “Data-flow at a glance” ┌─────────────────────────── your machine ────────────────────────────┐ │ │ you ──┼─▶ browser UI ──▶ local gateway (127.0.0.1) ──▶ the runtime ──┐ │ │ or REPL (token-gated) │ │ │ ▼ │ │ ┌──────── tools ────────────────────┐ │ │ ~/.muaz/ ◀──────────┤ files · shell · web · MCP servers │ │ │ (config, sessions, └───────────────────────────────────┘ │ │ secrets, memory, audit) │ │ │ └────────────────────────────────────┼────────────────┼───────────────┘ ▼ ▼ LLM provider API MCP servers you added (Anthropic/OpenAI/…) (stdio child / HTTPS)Outbound network happens only to (1) the LLM provider bound to the agent and (2) the web tools / MCP servers you explicitly enable. Both are governed by the controls below.
Secrets storage
Section titled “Secrets storage”Provider and plugin API keys are never written into config files. Config
references a key by name (api_key: ${ANTHROPIC_API_KEY}); the value is stored
outside the config, in this order:
- OS keychain first — Keychain (macOS), Credential Manager (Windows), or Secret Service (Linux).
- Fallback file —
~/.muaz/secrets.json, createdchmod 600(owner-only), used only when no keychain is available. - Plugin secrets live under a namespaced key (
plugin:<id>:<key>), same backends.
Secret values are only ever read server-side to construct a provider or MCP
transport — they are never returned over the gateway HTTP API. ${VAR} resolves
store-first, then process environment. Setting or deleting a secret is recorded in
the audit log by name only.
The local gateway trust boundary
Section titled “The local gateway trust boundary”The gateway serves the browser UI and exposes the full agent runtime — including shell execution — over HTTP, so it is gated:
- Loopback by default. It binds
127.0.0.1; exposing it to the network (--host 0.0.0.0) is an explicit opt-in that prints a warning and still requires the token. - Token on every
/apirequest. Presented as anAuthorization: Bearerheader, anHttpOnly; SameSite=Strictmuaz_tokencookie (set once on the?token=handshake), or a?token=query param (forEventSource). Comparison is constant-time. The token defaults to$MUAZ_GATEWAY_TOKENor a freshly generated secret persisted at~/.muaz/gateway-token(chmod 600). - Same-origin only. No CORS is configured, so no other web page can read the (cookie-authenticated) API.
- Security headers on every response:
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, and aContent-Security-Policy: frame-ancestors 'none'. - Request-size cap (32 MiB) so an unauthenticated body can’t exhaust memory.
- Read-only mode.
muaz gateway --readonlyrefuses every state-changing request (POST/PUT/DELETE/PATCH →403, audited) — for auditors and demos who should browse config, sessions, and the audit log without being able to run agents or mutate anything. - Failed auth attempts are audited (
auth_failure, path only — never the token).
Tool execution & approval
Section titled “Tool execution & approval”Every tool call passes through a per-tool access policy — off (hidden), ask
(prompt each call), or auto (run freely) — resolved most-specific-first from the
agent and global config. File-mutating tools (edit_file/write_file/patch_file)
show a diff preview at the approval prompt, so you approve the exact change rather
than opaque JSON. File tools are confined to the session’s
working directory — which a
deployment can pin, so a hosted session cannot wander its container; ~/.muaz
and the home muaz directory are always protected from writes.
Network egress controls
Section titled “Network egress controls”Two independent locks, both off by default:
- Allowlist-only egress (
web_tools.allowlist_only, global or per-agent): when on, the web tools may fetch only URLs matching the allowlist — and an empty allowlist blocks all web access. network: none: a hard local egress lock that strips the built-in web tools and refuses to attach HTTP MCP servers. Set it on an agent to lock that agent, or at the top level ofconfig.yamlto lock the whole install — the global setting is a floor, so an agent can tighten it but never re-open it.
Honest limitation. A stdio MCP server is a child process muaz launches; muaz cannot prevent that process from opening its own network sockets.
network: nonerefuses HTTP MCP and the web tools, but stdio MCP children are outside that boundary. That risk is mitigated by only installing trusted, signed plugins/connectors (see below) and by the OS-level sandboxing you already run muaz under — not by muaz enforcing egress on child processes.
Plugin & connector trust
Section titled “Plugin & connector trust”- Signing. Plugins from a remote registry are verified with minisign: a valid
signature marks the install verified, an unsigned plugin is refused under the
default
require_signed: true, and a present-but-invalid signature is a hard tamper error regardless.muaz plugins install --allow-unsignedwaives the first of those for one install and is CLI-only — the gateway API has no parameter for it, so a browser session cannot be talked into an unsigned install. Connector-registry entries may only present as the muaz-verifiedcoretier with a valid signature; unsigned/invalid remote entries are downgraded tocommunity. - Per-registry trust anchors. Registries are configured as a list
(
plugin_registries:), each with its owntrusted_keys. An organization running an internal registry signs with its own key and gets verified-tier installs from it, while that key is trusted for that registry only — compromising one registry’s key does not forge plugins for another. A registry declaring no keys inherits muaz’s embedded publisher keys. Malformed keys, duplicate registry names, and un-opted-inhttp://index URLs are rejected at config load, so a typo’d anchor fails loudly instead of quietly verifying nothing.$MUAZ_PLUGIN_REGISTRYcan redirect the first registry’s URL but cannot change its key set or relax its strictness — an env var is not a path to downgrading verification. - Update provenance. A plugin’s recorded
sourcenames the registry it came from, and updates are re-fetched only from that registry. A plugin installed internally is never silently replaced by a same-named entry in the public registry. - The index is signed. Signing only the zips would leave the document that says which
zip to fetch unauthenticated — strip a signature, swap a
url, and a verified install becomes a community-tier one. Registries publish<index_url>.minisig; muaz verifies it over the raw index bytes against that registry’s keys and, underrequire_signed, refuses an index without one. A registry that has signed before can never go back to unsigned. - Rollback and freeze. A signature proves who published an index, never when. The
index carries a monotonic
serial; muaz records the highest it has accepted per registry in~/.muaz/registry-state.json(chmod 600) and refuses one that goes backwards, or that drops the field. The serial is recorded only after the signature check, so a forged index cannot poison the mark and lock out the real registry. Apublished_atolder than 30 days warns — a freeze is not provable from a single fetch.muaz plugins forget-registry <name>is the deliberate reset, and is treated as a security decision. - Signed plugins stay signed. A plugin installed from a verified download can never be
replaced by an unsigned one, with no override — not
require_signed: false, not--allow-unsigned. This is what keeps one relaxed setting from letting an unsigned build take over a plugin already established as signed, a substitution that otherwise looks exactly like a normal upgrade. - Key rotation. Two publisher keys ship: an active key held by release CI, and an offline reserve that signs nothing until it’s needed, so a compromised signing key can be replaced the same day instead of after an upgrade wave. The runbook, and what rotation does and does not revoke, is under Key rotation below.
- Withdrawn versions. A publisher retracts a bad version with a
yankedflag in the index, never by deleting the release — pinned installs and recorded hashes depend on the bytes staying reachable, and deleting them punishes honest users while doing nothing to anyone who already has the plugin. muaz skips a yanked version when resolving the newest, refuses it when pinned, hides it from search, and warns when a version you have installed has been withdrawn. That warning is reported apart from unreachable registries, so “we checked and found something” never reads like “we couldn’t check”. - Reproducible packing.
muaz plugins packproduces the same bytes from the same sources every time (sorted entries, pinned timestamps, fixed modes). Without that, the hash CI records and the hash a client computes would drift apart, and a routine mismatch would be indistinguishable from tampering — training people to ignore the one signal that matters. Signing stays out of the binary entirely: muaz verifies signatures and never holds a signing key. - Declared permissions. A plugin’s manifest declares the MCP servers, network use, and built-in tools it wants; these are shown at the trust prompt.
- Trusted-only capabilities. A plugin’s lifecycle hooks and command-backed custom tools run only after you explicitly trust it; hook commands are shown verbatim at trust time and every run is audited.
Registry threat model
Section titled “Registry threat model”Each control above answers a specific attack. What they cover, and what they don’t:
| An attacker who… | would try to… | is stopped by… |
|---|---|---|
| sits on the network path (hostile Wi-Fi, a proxy, a compromised CDN) | serve a modified index, or swap the archive behind a url | HTTPS with no https://→http:// redirect; the detached index signature, checked over the raw bytes before they are parsed; the per-archive signature; the recorded sha256 |
| takes over the registry host (bucket, repo, account) | publish a malicious version as verified | The signing key never touches the host — it lives in a CI environment behind a required reviewer. An index the attacker rewrites fails verification at every client. |
| holds a genuinely signed old index | replay it to hide a security fix | The monotonic serial, recorded per registry only after the signature verifies |
| controls the host but has no key | quietly stop publishing — a freeze | Nothing, fully. A single fetch cannot prove freshness; the published_at staleness warning is the signal, and it stays a warning rather than a refusal for that reason |
| steals the publisher signing key | sign anything as verified | Nothing client-side. This is a custody boundary, not a code one — see Key rotation |
| contributes a plugin | get malicious code published, correctly signed | Nothing in the signature. Source review is the control, plus the declared permissions shown at the trust prompt |
| already has a plugin you trust | ship an update that adds a hook or a custom tool | The capability digest recorded when you trusted it: a mismatch revokes trust and skips post_update rather than running it |
can write to your ~/.muaz | flip trusted: true, or lower the recorded serial | Nothing. Code running as your user has already won; the file modes are hygiene, not a boundary |
Two of those deserve restating, because a green verified badge invites the opposite reading:
- A signature proves origin, not safety. It says these bytes came from the holder of that key and were not altered on the way to you. It says nothing about what the plugin does once you run it. Review and the trust prompt are what cover that.
- Freshness is weaker than authenticity. Rollback is refused outright; a freeze
can only be flagged. If a registry matters enough that silence is a threat, monitor
it — the
serialandpublished_atof an index you fetch yourself are the check.
Key rotation
Section titled “Key rotation”Two publisher keys ship in every binary: an active key held by release CI, and an offline reserve that signs nothing until it is needed. Because trust anchors are compiled in, a binary only learns a new key when its user upgrades — which is why the reserve ships before there is any reason to use it. That buys continuity, not revocation: until a user upgrades, their binary still accepts a signature from the compromised key.
If the active key leaks:
- Remove
MINISIGN_SECRET_KEYfrom the publish environment — before anything else, so no further release signs with it. - Re-sign the current index with the reserve key and publish it. Clients already trust that key, so verified installs keep working the same day; the serial bump also means an index signed by the leaked key can no longer be replayed to anyone who fetches the new one.
- Promote the reserve to active in CI, and mint a fresh reserve offline — never in CI, never on a laptop that builds.
- Ship a muaz release whose
PUBLISHER_KEYSlists the new active and new reserve, and drops the compromised key. Only at this point is the leaked key actually revoked, and only for users who have upgraded. - Publish an advisory naming the versions that were signed by the compromised key.
A planned rotation is the same sequence without step 1’s urgency: sign with the reserve, promote it, mint a new reserve, and drop the retired key one minor release later so clients have an overlap window.
A self-hosted registry rotates far more cheaply. Its anchors live in each
client’s config.yaml, not in the binary — so a rotation is a config change (list
both keys during the overlap, then drop the old one), with no release and no upgrade
wave. That is one more reason an internal registry should always declare its own
trusted_keys rather than inheriting muaz’s.
Audit log
Section titled “Audit log”An append-only JSON-Lines log at ~/.muaz/audit/audit.jsonl (chmod 600) records
security-relevant events. It records what happened, never secret values — tool
arguments are stored as a SHA-256 hash, and secret events carry only the name.
event | Fields (besides ts) |
|---|---|
tool_execution | agent, session, tool, args_sha256, decision |
secret_change | action (set/delete), name |
change | category (config/plugin/connector), action, target |
plugin_hook | plugin, phase, command, exit_code |
auth_failure | detail (method + path + reason) |
Inspect it with muaz audit tail [--count N] [--json] or dump the whole file with
muaz audit export. Each line is one self-contained JSON object with an RFC-3339 UTC
ts, so it ingests cleanly into a SIEM.
$ muaz audit tail --json{"event":"secret_change","action":"set","name":"ANTHROPIC_API_KEY","ts":"2026-07-05T18:22:04+00:00"}{"event":"tool_execution","agent":"@local/coding","tool":"built_in__shell","args_sha256":"9f2c…","decision":"approved","ts":"2026-07-05T18:22:31+00:00"}Pipeline and subagent (non-interactive, policy-only) tool calls are not individually logged; the interactive
spawn_agentcall that launches a subagent is.
Data at rest
Section titled “Data at rest”Everything muaz stores lives under ~/.muaz/, created owner-only where it holds
sensitive data: config.yaml, models.yaml, mcp.json, secrets.json, the
gateway-token, per-session transcripts under sessions/, per-agent notebooks under
memory/, and audit/audit.jsonl are all written chmod 600. muaz never transmits
this directory anywhere.
Settings a deployment can fix
Section titled “Settings a deployment can fix”On a personal install you own every setting. On a shared one — a team server, a
container, a sandbox per user — the person using muaz must not be able to relax the
policy the deployment guarantees, and “we wrote a default into the image” does not
achieve that: they can edit their own config.yaml.
So a deployment can pin a documented set of settings through
MUAZ_CONFIG_FILE (or the matching MUAZ_* variables): plugin_registries,
tools.blocked, web_tools.allowlist_only, web_tools.allowed_urls,
web_tools.denied_urls, and network. Nothing else is pinnable, and anything else in
that file is an error rather than a silently ignored line.
Two properties are worth being precise about:
- The user can always tighten, never loosen. Each pinned setting composes as the
stricter of the two values — blocked tools union,
allowlist_onlyORs,networktakes the stricter posture. A user who wants more restriction always gets it. - Enforcement is at load, not at save. The pin is re-applied every time config is read, so a value edited past it — by hand, by CLI, or through the API — has no effect on what runs. A save that would loosen a pin is also refused outright, naming the setting, so the user learns immediately rather than trusting a value that was quietly discarded.
muaz doctor and the UI’s Settings page both show which settings are fixed and where
they came from. See Configuration → Deployment settings.
Tenancy is enforced the same way rather than by filtering: each deployment process
serves one MUAZ_SCOPE, and the scope is part of the storage key at rest, so one
shared store behind many single-user processes cannot leak between them. That is
structural in every backend — a directory under the filesystem store, a DynamoDB
partition key, a MongoDB field that leads every index — and it is checked rather than
assumed: the same conformance suite runs against each backend in CI, including a case
that writes one scope’s data and asserts another cannot read or list it.
Two things stay out of the shared store whatever the backend is:
- Secrets never travel through it. Provider API keys and MCP OAuth tokens go to
the OS keychain or a
chmod 600file, never through the object store, so a shared database cannot receive one even by accident. Each scope gets its own secret store. - Transcripts are protected in transit. muaz refuses an unencrypted MongoDB connection to a remote host unless the deployment explicitly overrides it; DynamoDB is TLS-only and encrypted at rest by the service.