Security
This document is for security and platform-engineering reviewers evaluating Milieu for production. It states the security model, the controls that enforce it, and — explicitly — the current limitations and non-goals. Where a control is planned but not yet shipped, it is called out and linked to ROADMAP; do not assume a planned control is in place.
Contents
- Security model & trust boundaries
- Sandboxing & command isolation
- Network egress control
- Identity & PKI
- Authentication & authorization
- Secrets management & the gateway
- Auditability & tamper-evidence
- Data isolation & multi-tenancy
- Supply chain & build integrity
- Production hardening checklist
- Reporting a vulnerability
- Known limitations & non-goals
Security model & trust boundaries
The central design assumption is that an agent may be compromised — by a prompt injection, a poisoned knowledge document, or a buggy skill. The architecture limits the blast radius of a compromised agent rather than trying to prevent compromise. An agent runs with least privilege and crosses three explicit, independently-audited trust boundaries:
flowchart LR
subgraph untrusted["Least privilege"]
AG["Agent (sandboxed)<br/>granted CLIs only · fixed PATH<br/>egress allowlist · no ambient secrets"]
end
subgraph host["Milieu host"]
API["API edge<br/>bearer key → 1 agent<br/>1-way TLS"]
end
subgraph trust["Trusted zone (separate deployment)"]
GW["Gateway broker<br/>holds secrets · mTLS · CN → agent"]
end
EXT["External program"] -->|API key / TLS| API --> AG
AG -->|local proxy / mTLS| GW -->|injects credential| UP["Upstream API/CLI/DB"]
| Boundary | Who is on each side | Control | Audit |
|---|---|---|---|
| Sandbox | the agent’s code/tools vs the host | OS sandbox + permission allowlist + egress allowlist + fixed PATH | var/audit/audit.jsonl |
| API edge | external programs vs milieu | one-way TLS + bearer API key → one agent; interaction-only | var/api/audit.jsonl |
| A2A edge | outside agents vs milieu | bearer API key → one agent, which must be the agent addressed | var/api/audit.jsonl (shared: same credential) |
| Admin portal | an operator’s browser vs milieu | TLS + session cookie from a host-created operator account; CSRF + origin checked on every mutation; administers the installation, so a session is as powerful as host access for those operations | var/web/audit.jsonl |
| Gateway | the agent vs high-value secrets | mTLS; secret injected gateway-side, never in the sandbox | var/gateway/audit.jsonl |
These are distinct trust domains with separate CAs — API clients (programs) and gateway proxies (agents) cannot impersonate one another.
Sandboxing & command isolation
Every command an agent runs is executed through a pluggable sandbox
(internal/platform/sandbox):
- Linux — Bubblewrap (
bwrap): explicit--ro-bind/--bind/--unshare-*; the agent home is bound read-only with the scratch directory bound writable over it. - macOS —
sandbox-exec(sbexec). none: no isolation — never auto-selected; you must setMILIEU_SANDBOX=nonedeliberately (used where the container/pod is the boundary).
Controls that hold regardless of backend:
- Command allowlist. An agent can invoke only the CLI patterns granted to it,
matched on argument tokens (
git(log:*)), not a flattened string. See the permission grammar. - Fixed sandbox PATH. Commands resolve against an operator-set PATH, never the
caller’s
$PATH, so the audited and executed binary is deterministic. Agents cannot change it; relative entries are ignored. - No ambient secrets. The sandbox gets
PATHandHOME=/agentonly. Host env vars reach an agent solely via anagent.toml [env]reference and only if the operator allowlisted that name inMILIEU_ENV_PASSTHROUGH— so anagent.tomlauthor cannot pull, e.g.,ANTHROPIC_API_KEYinto an agent. - Configuration is read-only to the agent it configures. A sandboxed
command may write only to
var/agents/<code>/work/(plus the usual temp directories). The rest of the agent’s home —agent.toml,skills/,personality.md,responsibility.md, the proxy identity — is readable but not writable, because a command that could write there could grant itself a command, widen its own[network] allow, or rewrite its instructions for the next run. Everything an agent legitimately writes (memory, transcripts, sessions) is written host-side by the built-in tools, which are permission-checked and audited. - Operator-authored mounts. Read-only host paths are exposed only via
[[mount]]blocks whose source lives outside the agent’s writable home, so an agent cannot self-expose a path by planting a symlink. - Pipeline staging. Each
thenstage is permission-checked and sandboxed independently.
Container caveat: bwrap needs nested namespaces / CAP_SYS_ADMIN, which
hardened pods drop. The default Kubernetes posture is MILIEU_SANDBOX=none with
the pod as the isolation boundary (one tenant per pod), keeping pods fully
hardened. See DEPLOYMENT → Kubernetes.
Network egress control
Outbound network is a deny-by-default per-host allowlist (agent.toml [network] allow); an empty/absent list means no network. Entries are host, host:port,
*:port, or *.
- macOS /
sbexec— enforced per-host by a per-invocation loopback egress proxy: the sandbox is confined to the proxy, which authorizes each HTTPS/CONNECTconnection against the allowlist. Direct connections that bypass the proxy are blocked. (Covers HTTPS / CONNECT-tunneled traffic; plain HTTP and non-proxy-aware clients are not supported.) - Linux /
bwrap— OS-level per-host filtering is unavailable, so egress is all-or-nothing: a restricted policy yields no network; full access needs["*"]. For high-value egress on Linux, prefer the gateway over a broad allowlist. Per-host egress filtering on Linux is on the roadmap. none— not enforced.
milieu lint flags a restricted policy and notes the backend’s enforcement
behaviour.
Identity & PKI
Identity is bound at each boundary by internal/platform/identity:
- API edge — an ECDSA P-256 CA issues the server’s TLS leaf (SANs:
localhost,127.0.0.1, and--host). Clients pinvar/api/ca/ca.crt. - Gateway — an Ed25519 CA (TLS 1.3, mutual TLS). The gateway is its own CA: it issues each proxy a client cert and trusts any cert the CA signed, so a fleet scales without per-proxy config. The proxy holds only a CA-signed leaf + the pinned CA fingerprint (a single value).
Rotation & revocation:
- API keys are revocable immediately (
milieu api key revoke <id>); they are the primary client credential and should rotate on a schedule. - API server cert —
milieu api initis idempotent and will not replace a cert that is present, so deletevar/api/server/first; the nextinitre-issues the leaf from the existing CA and clients keep their pin. - Gateway proxy identities — re-issue with
milieu gateway issue; to rotate the gateway CA itself, deletevar/gateway/and re-init, then re-issue every proxy (a fleet-wide re-pin — schedule a maintenance window).
There is no per-message agent signing key today; message/audit integrity rests on the hash-chained audit log and boundary-bound identity. Per-agent action signing is on the roadmap.
Authentication & authorization
| Surface | AuthN | AuthZ |
|---|---|---|
| REST API | bearer key (sk_…) over one-way TLS; stored only as a SHA-256 hash in var/api/keys.db; shown once at mint; revocable | the key maps to exactly one agent; every request runs as that agent; interaction-only — no agent/project management, no command exec |
| Gateway | mutual TLS; the gateway authenticates the proxy by its client cert | each call authorized by the client-cert CN (the agent identity); routes are an explicit allowlist (REST upstreams / CLI command patterns / named SQL queries) |
Admin portal (milieu web serve) | session cookie (HttpOnly, Secure, SameSite=Strict) issued after an argon2id password check; operators exist only if created on the host; sessions expire on a 30-minute idle and a 12-hour absolute deadline | an operator sees and administers the installation itself, not one agent; every mutation carries a per-session CSRF token and a same-origin check |
| Sandboxed CLI | caller identity from --as / MILIEU_AGENT | the agent’s permission allowlist; auditor role cannot send messages |
There is no master key. An API key is minted by milieu api key new or
milieu apply, both of which write var/api/keys.db on the host, so the
authority to mint is write access to that file — the OS account that owns
MILIEU_HOME. Milieu creates its databases 0600 under a 0700 directory (and
tightens an existing one on open), so run the daemon as a dedicated user and keep
MILIEU_HOME owned by it. A second bearer secret guarding the same file would
add no boundary, since anyone able to present it could write the table directly.
The admin portal is the one surface that reaches this authority over a network, and it is built so that it does not widen it:
- Operators are created on the host, never through the portal.
milieu web admin addis the only way to make an account, so compromising a session cannot mint a second way in that outlives revoking the first. - It refuses to start unprotected. With no operator account,
milieu web serveexits rather than serving; over a non-loopback address it requires TLS. - It is a different principal from an API key. Portal sessions live in
var/web/admin.dbunder their own CA; ansk_…key cannot reach a portal route and a session is not a credential any agent holds. - A session is short-lived and revocable. Removing an operator ends their live sessions in the same transaction rather than waiting for a cookie to lapse.
What a portal session can do. It administers the installation: create
agents, grant and revoke their command permissions, mint and revoke API keys,
create projects and change their membership, and — through Chat — cause an agent
to run. Say plainly what that means — a
stolen session is equivalent to shell access to MILIEU_HOME for those
operations, which is why the session is short-lived, revocable, bound by CSRF
and origin, and why operators are created only on the host.
Three deliberate limits on that authority:
- A minted key is shown once, in the response that mints it. It is never
redirected through a query string, never logged, and stored only as a SHA-256
hash — the same rule
milieu api key newfollows. - The gateway is read-only from the portal. It can see the broker’s CA fingerprint and issued identities and nothing else. The gateway holds secrets in a different trust boundary; a portal that could edit its routes from outside that boundary would be a way around it, not a view of it.
- Knowledge is read-only. Documents are authored as files under version control, and a portal that edited them would be a second, unreviewed path in.
- Chat grants nothing. A turn runs with the agent’s own permissions, sandbox and egress policy; the operator is recorded as the session’s principal for provenance, not authorization. It is a way to exercise an agent’s authority, never to exceed it.
Every change lands in var/web/audit.jsonl with the operator, the action, the
target and the client address, hash-chained like the agent log. The Audit screen
verifies both chains and reports a break loudly. A write that cannot be logged
is still performed — an operator locked out of revoking a key during an incident
is the worse failure — and the failure is reported to the server’s log.
Roles (agent.toml): administrator, associate, assistant, auditor. The enforced
guarantee is that auditors are read-only (reply attempts are rejected at
message send), making them safe to grant broad inspection access. Broader
role-based routing ACLs are convention plus this restriction, not a general policy
engine.
Secrets management & the gateway
There are two ways a secret can reach a tool, in increasing order of safety:
[env]passthrough — the secret rides in the agent’s sandbox environment, bounded byMILIEU_ENV_PASSTHROUGH(operator allowlist) and[network]. The secret is inside the sandbox; suitable for low-value tokens.- The gateway — the secret stays in the trusted zone and is injected
gateway-side, so it never enters the sandbox. The agent calls a local proxy
(confined to its loopback port); the proxy re-originates over mutual TLS;
MILIEU_GATEWAY_URLcarries a per-run token so another local process can’t use the port. A compromised agent can ask the gateway to act for it, but cannot read the credential.
Gateway hardening properties:
- Secrets are named env vars resolved at gateway startup — never written to disk, never in the manifest.
- A
cliroute runs with a curated environment (onlyPATH,HOME, and the route’s declared vars), so one route cannot read another’s secrets. - SQL routes accept a named, parameterized query + params — never raw SQL, never the DSN.
- The gateway records every brokered call (agent CN, route) in its own hash-chained log — never the secret.
milieu apply never stores generated secrets in the manifest: it mints API keys
/ CA fingerprints and reports them once (stdout or --secrets-out <file>; keep it
out of version control). On cloud VMs, instance metadata is not a secret store
— fetch ANTHROPIC_API_KEY from a real secret manager (AWS SSM/Secrets Manager,
GCP Secret Manager); see DEPLOYMENT → Artifacts & secrets.
Auditability & tamper-evidence
Every CLI invocation and built-in tool call appends one row to an append-only,
hash-chained log (internal/service/audit). Each record’s hash covers its
prev_hash, seq, and payload, so editing, reordering, inserting, or deleting
any record breaks every following hash. milieu audit verify recomputes the chain
and exits non-zero at the first break — run it in CI against a copy of the log.
- Appends take an exclusive file lock and read the current tail under it, so a sandboxed CLI run and the daemon extend one chain (no fork).
- The API and gateway keep their own separate hash-chained logs, verified with
milieu api audit verifyandmilieu gateway audit verify; the portal’s log is verified on its Audit screen. All four chains share one implementation (internal/platform/auditchain), so a verifier cannot drift from the writer. - Record fields: REFERENCE → Audit records.
Limitation: hash chaining cannot detect tail truncation (lopping records
off the end leaves a shorter-but-valid chain). To detect that, periodically anchor
the latest hash somewhere outside the log (a separate store, a notary, a SIEM).
Data isolation & multi-tenancy
- Per-tenant root. All state resolves from
MILIEU_HOME; a tenant is a separateMILIEU_HOME(separate VM/pod), so agents, knowledge, memory, and audit log are fully isolated. Hosts share only what you deliberately share (an embedding server). See DEPLOYMENT. - Membership gating. A project’s knowledge and memory layers are reachable
only by roster members; a non-member’s
--inrequest is rejected. - Blob store scope.
var/blobs/is content-addressed (SHA-256) and shared by digest across agents: possessing a digest implies already possessing the bytes, so a digest is not a capability to someone who lacks the content. There is no per-agent ACL on raw blobs — gating applies to knowledge/memory layers, not to blobs. Treat the blob store as shared within oneMILIEU_HOME. - Storage constraint. Put
var/on local disk: SQLite WAL needs proper POSIX locks — never NFS.
Supply chain & build integrity
- Static, CGO-free binaries. Built with
CGO_ENABLED=0and pure-Go SQLite (modernc.org/sqlite), so the artifacts are statically linked and portable, with no system-library linkage to track. - Minimal, pinned dependencies. No external web framework (stdlib
net/httponly); dependencies are pinned ingo.mod/go.sumand are few (TOML, YAML,gojq, SQLite, optional PostgreSQL driver). - Reproducible container image. The repo-root
Containerfileis a multi-stage build (Go → Debian slim) producing a non-root image (fixed UID 10001). Build it with Podman; pin the base image by digest and scan it in your registry. - Deterministic execution surface. The fixed sandbox PATH means the binary an agent executes for a given command name is fixed at build/deploy time, not resolved from a mutable environment.
Production hardening checklist
-
MILIEU_SANDBOXisbwrap/sbexec(notnone) — or the container/pod is the deliberate, documented isolation boundary. - Each agent’s
[network] allowis the minimal host:port set; no stray*. - High-value secrets go through the gateway, not
[env]passthrough. -
MILIEU_ENV_PASSTHROUGHlists only the env vars that must be passed. - The gateway runs in a separate trust boundary (its own VM/namespace),
never co-tenanted with an
apphost. - API served over TLS; keys scoped one-per-consumer and rotated; unused keys revoked.
- The admin portal (
milieu web serve) binds loopback or sits behind TLS with a trusted certificate; operator accounts are per-person, and leavers' accounts are removed (which ends their sessions). - The portal’s own log is reviewed alongside the agent log —
milieu webactions do not appear invar/audit/audit.jsonl, and a key minted through the browser is recorded only invar/web/audit.jsonl. -
ANTHROPIC_API_KEYand route secrets come from a real secret manager, not VM metadata or VCS. -
milieu audit verifyruns in CI/cron; the latesthashis anchored externally (tail-truncation defence). - The daemon runs as a dedicated user;
MILIEU_HOMEis0700owned by it (write access tovar/api/keys.dbis the authority to mint an API key). -
var/is on local disk with restrictive permissions; backups covervar/{agents,projects,global,audit}(indices are derived). -
--secrets-outfiles frommilieu applyare stored securely and kept out of version control. -
milieu lintpasses for every agent (no ungranted tools, valid config). - SSH / management ports are CIDR-restricted; only the gateway (and a shared embedder) accept inbound. See DEPLOYMENT → Networking & ports.
- Container base image pinned by digest and scanned; pods run non-root with
drop: [ALL].
Reporting a vulnerability
Please report suspected vulnerabilities privately to the maintainers rather than
opening a public issue. Include affected version/commit, a description, and
reproduction steps; we aim to acknowledge promptly and coordinate a fix and
disclosure. (Maintainers: replace this paragraph with your security contact —
e.g. a security@ address or a GitHub private security advisory link — before
publishing.)
Known limitations & non-goals
Current limitations (see ROADMAP for status):
- No per-agent message/action signing — integrity rests on the hash chain and boundary identity.
- No per-host egress filtering on Linux — egress is all-or-nothing under
bwrap. - Audit tail-truncation is not self-detected — needs an external anchor.
- Blob store has no ACL and no GC — shared by digest within a
MILIEU_HOME, and grows unbounded with attachments. bwrapis unavailable in hardened pods — Kubernetes uses pod-as-boundary.
Deliberate non-goals (left to the operator / platform):
- TLS termination / ACME, DNS, load balancing, autoscaling, and backups of
var/are out of scope for the bundled deploy configs — see DEPLOYMENT → Out of scope. - Milieu does not attempt to prevent prompt injection or model misbehaviour; it contains a misbehaving agent via the boundaries above.