Skip to content

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

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"]
  
BoundaryWho is on each sideControlAudit
Sandboxthe agent’s code/tools vs the hostOS sandbox + permission allowlist + egress allowlist + fixed PATHvar/audit/audit.jsonl
API edgeexternal programs vs milieuone-way TLS + bearer API key → one agent; interaction-onlyvar/api/audit.jsonl
A2A edgeoutside agents vs milieubearer API key → one agent, which must be the agent addressedvar/api/audit.jsonl (shared: same credential)
Admin portalan operator’s browser vs milieuTLS + 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 operationsvar/web/audit.jsonl
Gatewaythe agent vs high-value secretsmTLS; secret injected gateway-side, never in the sandboxvar/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 set MILIEU_SANDBOX=none deliberately (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 PATH and HOME=/agent only. Host env vars reach an agent solely via an agent.toml [env] reference and only if the operator allowlisted that name in MILIEU_ENV_PASSTHROUGH — so an agent.toml author cannot pull, e.g., ANTHROPIC_API_KEY into 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 then stage 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/CONNECT connection 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 pin var/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 certmilieu api init is idempotent and will not replace a cert that is present, so delete var/api/server/ first; the next init re-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, delete var/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

SurfaceAuthNAuthZ
REST APIbearer key (sk_…) over one-way TLS; stored only as a SHA-256 hash in var/api/keys.db; shown once at mint; revocablethe key maps to exactly one agent; every request runs as that agent; interaction-only — no agent/project management, no command exec
Gatewaymutual TLS; the gateway authenticates the proxy by its client certeach 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 deadlinean operator sees and administers the installation itself, not one agent; every mutation carries a per-session CSRF token and a same-origin check
Sandboxed CLIcaller identity from --as / MILIEU_AGENTthe 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 add is 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 serve exits 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.db under their own CA; an sk_… 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 new follows.
  • 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:

  1. [env] passthrough — the secret rides in the agent’s sandbox environment, bounded by MILIEU_ENV_PASSTHROUGH (operator allowlist) and [network]. The secret is inside the sandbox; suitable for low-value tokens.
  2. 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_URL carries 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 cli route runs with a curated environment (only PATH, 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 verify and milieu 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 separate MILIEU_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 --in request 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 one MILIEU_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=0 and 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/http only); dependencies are pinned in go.mod / go.sum and are few (TOML, YAML, gojq, SQLite, optional PostgreSQL driver).
  • Reproducible container image. The repo-root Containerfile is 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_SANDBOX is bwrap/sbexec (not none) — or the container/pod is the deliberate, documented isolation boundary.
  • Each agent’s [network] allow is the minimal host:port set; no stray *.
  • High-value secrets go through the gateway, not [env] passthrough.
  • MILIEU_ENV_PASSTHROUGH lists 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 app host.
  • 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 web actions do not appear in var/audit/audit.jsonl, and a key minted through the browser is recorded only in var/web/audit.jsonl.
  • ANTHROPIC_API_KEY and route secrets come from a real secret manager, not VM metadata or VCS.
  • milieu audit verify runs in CI/cron; the latest hash is anchored externally (tail-truncation defence).
  • The daemon runs as a dedicated user; MILIEU_HOME is 0700 owned by it (write access to var/api/keys.db is the authority to mint an API key).
  • var/ is on local disk with restrictive permissions; backups cover var/{agents,projects,global,audit} (indices are derived).
  • --secrets-out files from milieu apply are stored securely and kept out of version control.
  • milieu lint passes 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.
  • bwrap is 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.