| title | Security Guidelines |
|---|---|
| audience | internal-developer |
| description | Rules for writing security-sensitive code in Monad. The threat model is specific: Monad is a local, single-user daemon that listens on the loopback. |
Rules for writing security-sensitive code in Monad. The threat model is specific: Monad is a local, single-user daemon that listens on the loopback interface and runs a tool-using LLM agent. That shapes everything below — the adversary is not (yet) a remote network attacker; it is the user's own web browser and the model's own tool calls.
These rules are derived from a security audit of the current code. Each section states the rule, the why, and how to apply it. Treat the checklist at the end as a PR gate for any change that touches a network boundary, the filesystem, a credential, or tool dispatch.
- The browser (primary, today). Monad binds
127.0.0.1. Any web page the user visits can issue requests tohttp://127.0.0.1:<port>and openws://127.0.0.1:<port>. The loopback IP allowlist does not identify the caller — only that the packet came from this machine. A malicious origin is "localhost" as far as the socket is concerned. - The model (primary, once tools land). Tool arguments are attacker-controlled
data: prompt injection from a fetched web page, a file, or a connector can make
the model emit
file_read("/etc/shadow")ornet_fetch("http://169.254.169.254/…"). Treat every tool argument as hostile input, never as trusted intent. - Other local users (shared host). Files and sockets under
~/.monad/must not be readable by siblings on a multi-user machine. - The remote network (only when
remoteAccess.enabled). Opt-in, binds0.0.0.0, bearer-token gated. Lowest-frequency but highest-blast-radius.
The IP/loopback check answers "did this come from this machine?" — never "is this caller allowed?" Those are different questions. Authentication and authorization need their own controls (Origin checks, tokens, capability scoping).
A loopback peer address does not authenticate the caller. Any browser tab can be a loopback peer.
- REST writes are CSRF-exploitable. A page can POST to
127.0.0.1without reading the response (no CORS needed to send). Any state-changing endpoint (session.create,session.send, settings mutations) must reject requests whoseOrigin/Sec-Fetch-Siteindicate a cross-site browser caller. Maintain an allowlist of trusted origins (the bundled web UI,tauri://,nullfor native); reject everything else, even from loopback. - WebSocket has no same-origin protection at all. Browsers send no preflight and
enforce no CORS on
ws://. The/v1/streamhandler currently accepts any connection and dispatches full JSON-RPC — this is Cross-Site WebSocket Hijacking (CSWSH). Validate theOriginheader in the WS upgrade handler and refuse mismatches. This is the single most important control to add. - Defend against DNS rebinding. Validate the
Hostheader against an allowlist (127.0.0.1:<port>,localhost:<port>). Rebinding turns "loopback only" into "any website, eventually." - Never reflect
OriginintoAccess-Control-Allow-Origintogether withAccess-Control-Allow-Credentials: true. That combination tells the browser every origin is trusted and may read credentialed responses. Echo only origins from the allowlist; if an origin isn't allowed, omit the header — don't reflect it.
When remoteAccess.enabled (bind 0.0.0.0):
- Require a bearer token for every non-loopback request (current behavior — keep it).
- Compare tokens in constant time (
crypto.timingSafeEqualover equal-length buffers), not===. String compare leaks length and prefix via timing. - Generate tokens with a CSPRNG (
crypto.randomBytes/randomUUID), ≥128 bits. - Document that plain-HTTP remote access is insecure: the token travels in
cleartext. Require a TLS-terminating reverse proxy, SSH tunnel, or VPN. Never
advertise
http://0.0.0.0:<port>as a usable remote endpoint. - The loopback bypass (
if (LOCALHOST.has(addr)) return) is acceptable for network auth but does not replace the Origin/Host checks from §1 — both are required.
Default umask leaves files world-readable (0644). Secrets need explicit perms.
- Any file holding a secret should be owner-only.
auth.jsonis written0600; native credentials now live inconfig.json,agents.json, andmesh.json, so users must protect those files and installers should apply owner-only permissions where the platform supports them. - The Unix control socket needs
chmod 0o600(or 0o700) too.Bun.serve({ unix })does not set restrictive perms; the socket grants full unauthenticated RPC to anyone who canconnect()it. Do not rely on the parent directory's mode alone — set it on the socket explicitly after creation, and create~/.monad/runtime/as 0o700. - Windows has no
chmod; the current code no-ops there. Document the gap and rely on per-user profile ACLs; don't pretend the file is protected. - Treat the socket's filesystem permissions as its only authentication — that is by design ("filesystem-permission gated"), so the permissions must actually be set.
Tool dispatch is live. withSandboxConstraints (apps/monad/src/capabilities/protection.ts,
applied in capabilities/lifecycle.ts) attaches the effective sandbox roots to each tool, and
invokeTool injects them into ToolContext — but attaching a constraint is not enforcing it.
The call-time guards are tested primitives in @monad/sandbox
(packages/sandbox/src/security.ts): assertPathWithinRoots, assertUrlAllowed,
isBlockedIp, ToolSecurityError. Tool run() bodies MUST call them. The rules below apply
to every tool, and to every new one:
- Validate every argument at the dispatch boundary. Declare
Tool.inputSchema(a zod schema, or anyToolInputSchema);invokeToolparses the raw input before the gate orrun(), rejecting bad input withToolInputErrorand passing the coerced result torun. Never cast attacker-controlled input. The three built-in tools each carry a schema. - Enforce filesystem sandboxing at call time, not declaration time. The agent's
effective roots arrive on
ToolContext.sandboxRoots(injected byinvokeTool); file tools callassertPathWithinRoots(path, ctx.sandboxRoots)—file_readalready does. It resolves and rejects../absolute escapes lexically. A constraint the tool doesn't check is decoration. Caveat: when opening an EXISTING file, alsorealpathand re-check, since a symlink inside the sandbox can point out of it. - SSRF-filter every outbound fetch.
assertUrlAllowed(url)denies loopback, link-local (169.254.0.0/16, cloud metadata), private ranges (RFC 1918), and non-http(s)schemes by literal host. It CANNOT catch a public name that resolves to a private IP, so the fetch impl must also resolve DNS and re-check the address withisBlockedIp— plus a redirect cap, timeout, and response-size limit. The daemon's own API on127.0.0.1is a prime SSRF target. - Route high-risk tools through the approval gate. Every tool call goes through
invokeTool(apps/monad/src/capabilities/tools/invoke.ts), which sendshighRisktools to the host-suppliedToolGatebefore running and is fail-closed: high-risk + no gate configured → denied. The daemon ships a real gate —OversightService(apps/monad/src/services/oversight.ts): it emits atool.approval_requestedevent, blocks the turn, and resolves only when a client answers via thetools.approveRPC, or auto-denies after a timeout. Never calltool.run()directly from the loop — go throughinvokeToolso the gate and sandbox context are always applied. - No shell string interpolation. If a tool ever shells out, use
Bun.$with array args / tagged-template escaping — never build a command string from model output. Prefer native APIs (Bun.file,fetch) over spawning.
Every HTTP/WS/disk boundary parses with zod (the schema is the type — see code conventions). Add the limits the schemas omit:
- Size-cap user strings —
z.string().max(N).createSessionRequestSchema.titleandsendMessageRequestSchema.textare currently unbounded → memory-exhaustion DoS. - Cap request bodies at the server (Elysia/Bun limit) regardless of schema.
- Rate-limit per connection on
session.create/session.send— unbounded calls exhaust disk, the DB, and trigger unbounded paid model inference. - Never
parseexternal data withascasts; never trust a header's claim about who the caller is.
- Mask secrets in every API response (
maskSecret/…last4) — never return a raw token, API key, or access token. This is done today for model credentials; hold the line for any new credential surface. - Mask secrets in local reads too, not just API responses. A command that parses a settings
file directly (
monad config) bypasses the daemon's masking entirely. Mask by key name, not by a list of known paths, so a new secret field is covered the day it lands; gate any--revealescape hatch on an interactive TTY, since redirects, pipes, and--jsoncaptures are how a revealed secret actually leaks. - Never accept a secret as a command-line argument. argv is readable by every local user via
psand is written to shell history — the same exposure class as logging it. Take secrets on stdin (-) or from a file (--token-file), and document only those forms. - Never log secrets. No tokens, API keys, auth headers, or credential payloads in
logs — including the debug log under
tmpdir()(world-readable on some hosts) and webhook/connector logs. Scrub before logging. - Keep dev-only secret loading dev-only. Live model tests may read
OPENROUTER_API_KEYfrom the environment; keep that path gated to development and test use, and ensure all Monad settings files are ignored so a persisted key can never be committed.
- All SQL stays parameterized. The store uses Drizzle + bound
?/$namedplaceholders and escapes FTS phrase input — no string-interpolated untrusted data. Any new raw query must bind parameters; never interpolate IDs, search terms, or session ids into SQL text. - Keep
OperationSourceprovenance-only and server-stamped. Persist onlysurface,client, optional client/instance identifiers, and the server-derived semantictransport. Do not restore the removedorigin.envPII snapshot, openorigin.extbag, or client-configurablewritableBy/branchableByarrays. - Do not treat provenance as identity. Connection admission, scoped runtime capabilities,
and tool approval own authorization.
The temporary
assertTransportAuthoritycomparison is a narrow containment rule for session mutations while the trusted-native versus model-child boundary is completed; do not reuse it as a general RBAC mechanism.
The shipped OS-level confinement and its known gaps are documented with the code that
implements them: packages/sandbox/docs/hardening.md.
Read it before changing a launcher, a policy field, or an egress rule; update it in the
same change.
Summary of what enforces what today:
| Platform | Mechanism | net:'none' |
Credential read-deny |
|---|---|---|---|
| macOS | Seatbelt (sandbox-exec) |
kernel-enforced | enforced (readDenyRoots) |
| Linux | bwrap when installed, else Landlock + seccomp-bpf | kernel-enforced (seccomp blocks socket(AF_INET/6)) |
bwrap only — Landlock cannot express deny |
| Windows | AppContainer, Low Integrity fallback | enforced (no network capability SIDs) | AppContainer only (deny ACE) |
net:'filtered' is application-layer on every platform — the muxed HTTP/SOCKS5 egress
proxy in @monad/sandbox plus injected HTTP(S)_PROXY/ALL_PROXY; a child that opens a
raw socket bypasses the domain allowlist everywhere except macOS. Optional TLS termination
and Agent Credential protected execution build on that proxy. The heavy backends
(docker/e2b/vm) live in @monad/monad-power-pack, not the built-in set; the VM backend's
evidence rules are in
packages/sandbox-vm/docs/conformance.md.
Touching a network boundary, the filesystem, a credential, or tool dispatch? Confirm:
- New/changed HTTP+WS endpoints validate Origin and Host (not just IP).
- WebSocket upgrade rejects untrusted origins (CSWSH).
- No
Access-Control-Allow-Origin: <reflected>paired withallow-credentials: true. - Remote-auth token compared with
timingSafeEqual; tokens are CSPRNG ≥128-bit. - Every new secret-bearing file is
chmod 0o600in the same function that writes it. - New Unix sockets / their parent dirs have restrictive perms set explicitly.
- Every external input (HTTP/WS/disk) is zod-
parsed, never cast; user strings.max()-bounded. - Request bodies size-capped; hot endpoints rate-limited.
- Tool arguments schema-validated at dispatch; fs paths resolved+sandbox-checked at call time.
- Outbound fetches SSRF-filtered (post-DNS), with timeout/redirect/size caps.
- High-risk tools pass through the approval gate before executing.
- No shell command built from model/user output.
- Secrets masked in responses and in any local file the CLI reads itself; never written to any log.
- No command takes a secret as a positional argument or flag value; stdin/file forms exist and are what the docs show.
- New SQL binds parameters; no interpolated untrusted data.
- Code spawning a child process uses
sandboxedSpawn, not bareBun.spawn; newwritableRootsare intentional and minimal. - Changes to
SandboxPolicyorbuildSeatbeltProfile/apply_landlock/apply_seccompare reflected inpackages/sandbox/docs/hardening.mdand covered by a confinement test.