Skip to content

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.

┌─────────────────────────── 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.

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, created chmod 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 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 /api request. Presented as an Authorization: Bearer header, an HttpOnly; SameSite=Strict muaz_token cookie (set once on the ?token= handshake), or a ?token= query param (for EventSource). Comparison is constant-time. The token defaults to $MUAZ_GATEWAY_TOKEN or 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 a Content-Security-Policy: frame-ancestors 'none'.
  • Request-size cap (32 MiB) so an unauthenticated body can’t exhaust memory.
  • Read-only mode. muaz gateway --readonly refuses 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).

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.

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 of config.yaml to 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: none refuses 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.

  • 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-unsigned waives 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-verified core tier with a valid signature; unsigned/invalid remote entries are downgraded to community.
  • Per-registry trust anchors. Registries are configured as a list (plugin_registries:), each with its own trusted_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-in http:// index URLs are rejected at config load, so a typo’d anchor fails loudly instead of quietly verifying nothing. $MUAZ_PLUGIN_REGISTRY can 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 source names 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, under require_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. A published_at older 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 yanked flag 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 pack produces 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.

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 urlHTTPS 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 verifiedThe 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 indexreplay it to hide a security fixThe monotonic serial, recorded per registry only after the signature verifies
controls the host but has no keyquietly stop publishing — a freezeNothing, 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 keysign anything as verifiedNothing client-side. This is a custody boundary, not a code one — see Key rotation
contributes a pluginget malicious code published, correctly signedNothing in the signature. Source review is the control, plus the declared permissions shown at the trust prompt
already has a plugin you trustship an update that adds a hook or a custom toolThe capability digest recorded when you trusted it: a mismatch revokes trust and skips post_update rather than running it
can write to your ~/.muazflip trusted: true, or lower the recorded serialNothing. 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 serial and published_at of an index you fetch yourself are the check.

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:

  1. Remove MINISIGN_SECRET_KEY from the publish environment — before anything else, so no further release signs with it.
  2. 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.
  3. Promote the reserve to active in CI, and mint a fresh reserve offline — never in CI, never on a laptop that builds.
  4. Ship a muaz release whose PUBLISHER_KEYS lists 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.
  5. 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.

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.

eventFields (besides ts)
tool_executionagent, session, tool, args_sha256, decision
secret_changeaction (set/delete), name
changecategory (config/plugin/connector), action, target
plugin_hookplugin, phase, command, exit_code
auth_failuredetail (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_agent call that launches a subagent is.

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.

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_only ORs, network takes 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 600 file, 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.