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, this document says so and links to ROADMAP. Don’t 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. It doesn’t try to prevent compromise. An agent runs with least privilege and crosses three explicit, independently audited trust boundaries:

A request from an external program passes the API edge on the Milieu host to a sandboxed agent. The agent's call goes over mTLS to the gateway broker in the trusted zone, which injects the credential and calls the upstream API, CLI, or database.
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)
Built-in issuera person signing in vs Milieupassword (argon2id) for a host-created person, PKCE-bound codes, and ES256 tokens for the agents granted to them. Five failures lock a name for 15 minutesvar/auth/audit.jsonl
MCP edgea person’s assistant vs Milieubearer API key → one agent, or an OAuth access token → the agents its milieu:agent:* scopes namevar/api/audit.jsonl (shared), with the person recorded
Admin portalan operator’s browser vs MilieuTLS + session cookie from a host-created operator account. Cross-site request forgery (CSRF) + origin checked on every mutation. The portal administers the installation, so a session is as powerful as host access for those operationsvar/web/audit.jsonl
Gatewaythe agent vs high-value secretsmutual TLS (mTLS). The secret is injected gateway-side, never in the sandboxvar/gateway/audit.jsonl

These are distinct trust domains with separate certificate authorities (CAs). API clients (programs) and gateway proxies (agents) can’t impersonate one another.

Sandboxing & command isolation

Every command an agent runs goes 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, and never auto-selected. You must set MILIEU_SANDBOX=none explicitly (used where the container or pod is the boundary).

Controls that hold regardless of backend:

  • Command allowlist. An agent can invoke only the CLI patterns granted to it. Matching is on argument tokens (git(log:*)), never on 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 can’t change it, and relative entries are ignored.
  • No ambient secrets. The sandbox gets PATH and HOME=/agent only. Host environment variables reach an agent only through an agent.toml [env] reference, and only if the operator allowlisted that name in MILIEU_ENV_PASSTHROUGH, so an agent.toml author can’t pull, for example, ANTHROPIC_API_KEY into an agent.
  • Configuration is read-only to the agent it configures. A sandboxed command can 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. The built-in tools write everything an agent legitimately writes (memory, transcripts, sessions) host-side, and those tools are permission-checked and audited.
  • Operator-authored mounts. Milieu exposes read-only host paths only through [[mount]] blocks whose source lives outside the agent’s writable home, so an agent can’t self-expose a path by planting a symlink.
  • Pipeline staging. Each then stage is permission-checked and sandboxed independently.

One caveat applies to containers: 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. Under Podman on a VM, bwrap works without CAP_SYS_ADMIN after /proc is unmasked and the Linux Security Module (LSM) label is relaxed. The Quadlet units do that and keep the rest of the container hardened. See DEPLOYMENT → Running under Quadlet.

Network egress control

Outbound network access is a deny-by-default, per-host allowlist (agent.toml [network] allow). An empty or absent list means no network. Entries are host, host:port, *:port, or *.

OS sandboxes can’t filter by host, so a restricted policy is enforced by a per-invocation egress proxy outside the sandbox. The sandbox can reach only the proxy. The command gets HTTPS_PROXY/HTTP_PROXY, and the proxy authorises each CONNECT against the allowlist before it dials. Names are resolved by the proxy, on the host. ["*"] skips the proxy and shares the host network.

  • macOS / sbexec: the proxy listens on the host loopback, and the profile allows outbound connections only to its port.
  • Linux / bwrap: the sandbox has its own network namespace, whose loopback is not the host’s. The proxy listens on a Unix socket in a private (0700) per-invocation directory, bound read-only into the sandbox. A small bridge, started inside the sandbox before the command, listens on the namespace’s 127.0.0.1 and forwards each connection to that socket. The namespace has no other route out. An empty list gets no egress bridge and no network.
  • none: not enforced.

The bridge is the milieu binary, bound read-only at /run/milieu/netbridge, outside the sandbox PATH. Run from that path, under any name, it does only one thing: forward the loopback ports it was given to sockets in /run/milieu/net. An agent could copy the binary and run the full CLI, but only with the sandbox’s own reach: no state directory, no credentials, and no network beyond the bridged sockets, as with any program it could write itself.

The proxy carries HTTPS and other CONNECT-tunnelled traffic. Plain HTTP through the proxy is refused, and a client that ignores the proxy variables gets no network on either backend.

The gateway broker’s proxy ([gateway]) is reached the same way: on the host loopback under sbexec, and through the bridge under bwrap, whatever the [network] policy. MILIEU_GATEWAY_URL points at it, and NO_PROXY keeps proxy-aware clients from sending broker calls to the egress proxy.

milieu lint notes a restricted policy and what it covers.

An outbound connection under sbexec or bwrap connects directly if the policy is a star. Otherwise it goes through the egress proxy and connects only if the host is in the allowlist. Under none it isn't enforced.

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 (subject alternative names (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 certificate and trusts any certificate the CA signed, so a fleet scales without per-proxy configuration. The proxy holds only a CA-signed leaf and the pinned CA fingerprint (a single value).
Two separate certificate authorities. On the API edge, an ECDSA P-256 CA issues the server TLS leaf and API clients pin its certificate. On the gateway, an Ed25519 CA issues one client certificate per proxy and trusts any it signed, and each agent proxy pins the CA fingerprint.

Rotation and revocation

  • API keys are revocable immediately (milieu api key revoke <id>). They’re the primary client credential, so rotate them on a schedule.
  • API server certificate: milieu api init is idempotent and doesn’t replace a certificate that’s 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 (this is a fleet-wide re-pin, so schedule a maintenance window).

Message signing

Each agent has an Ed25519 signing key in var/signing/<code>/message.key (0600), made when the agent is created, or on its first send for an agent created before signing. It sits under var/, outside the agent’s directory, so no sandbox can read it. Every message is signed as it is queued, over a canonical encoding of the sender, recipient, subject, the SHA-256 of the stored body, reply depth, the message answered and the time it was produced. The router checks the signature against the sender’s key before delivering:

  • A message that fails (edited in the outbox, inserted for an agent with no key, or claimed by the wrong sender) is refused: it stays in the sender’s outbox with the reason, isn’t delivered or retried, and gets a route refused row in the agent audit log. milieu route refused lists them.
  • An unsigned message (queued before signing) is delivered with a warning and a route unsigned-delivered audit row, so an upgrade loses nothing. Set messages.unsigned = "refuse" (MILIEU_UNSIGNED_MESSAGES=refuse) once the upgrade-time queues have drained, and an unsigned message is then refused the same way as a failed one. For the upgrade, see OPERATIONS → Message signing.

This protects delivery against someone who can write an agent’s out.db but can’t read var/signing/. Signing happens on the Milieu host, so a signature shows that the host queued the message for that agent. It doesn’t show which model turn produced it. Audit records aren’t signed per agent.

Authentication & authorisation

SurfaceAuthenticationAuthorisation
REST APIbearer key (sk_…) over one-way TLS, stored only as a SHA-256 hash in var/api/keys.db, shown once at mint, and revocablethe key maps to exactly one agent, and every request runs as that agent. Interaction-only: no agent or project management, no command execution
A2A endpoint (milieu a2a serve)the same bearer keys as the REST API, from the same var/api/keys.db. TLS when served with --tls, and plain HTTP off loopback only with --insecure-http behind a terminating proxythe key maps to exactly one agent and can address only that agent. A turn runs as that agent under its session quota. Every request, refused or not, lands in the API access log as --edge a2a
MCP endpoint (milieu mcp serve)the same bearer keys, or, with [oauth] issuer set, a JWT access token from that issuer, checked against its published keys (asymmetric algorithms only), its exact issuer, an audience bound to the agent’s endpoint (or [oauth] audience), and a required expirya key addresses its own agent only. A token addresses the agents its milieu:agent:<code> scopes name, and its holder continues only the conversations they opened. Refusals answer with RFC 6750 / RFC 9728 challenges, and every request lands in the API access log as --edge mcp, a token’s with the person named
Built-in issuer (milieu auth serve)a person’s name and password, checked against an argon2id hash. People exist only if created on the host, and aren’t operatorsopen client registration grants nothing. A token carries an agent’s scope only if the person is granted that agent, is bound to that agent’s endpoint, and lives 15 minutes. Refresh tokens rotate, a replay ends the sign-in’s tokens, and grants are rechecked on every refresh. The sign-in page can’t be framed and refuses cross-site posts
Gatewaymutual TLS. The gateway authenticates the proxy by its client certificatethe certificate’s common name (CN) names the agent (proxy-<agent>), and a route admits only the agents it lists (agents, or ["*"], and none listed admits none). Within a route, an explicit allowlist applies (REST upstreams / CLI command patterns / named SQL queries / MCP tools)
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, within their role (admin everything, chat pages and chat, viewer pages only), set only on the host. Every mutation carries a per-session CSRF token and a same-origin check
Sandboxed CLIcaller identity from --as / MILIEU_AGENTthe agent’s permission allowlist. Within a skill run, also the skill’s allowed-tools, for every call the model or a code script makes. The auditor role can’t send messages

There’s no master key. milieu api key new and milieu apply mint API keys, and both 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, because 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’s built so that it doesn’t widen it:

  • Operators are created only on the host. milieu web admin add is the only way to make an account, so compromising a session can’t mint a second way in that outlives revoking the first.
  • It refuses to start unprotected. With no operator account, milieu web serve exits. Over a non-loopback address it requires TLS.
  • It’s a different principal from an API key. Portal sessions live in var/web/admin.db under their own CA. An sk_… key can’t reach a portal route, and a session isn’t a credential any agent holds.
  • A session is short-lived and revocable. Removing an operator ends their live sessions in the same transaction.

A portal session 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. A stolen session is therefore equivalent to shell access to MILIEU_HOME for those operations. For this reason, the session is short-lived, revocable, and bound by CSRF and origin, and operators are created only on the host.

Limits on that authority:

  • A minted key is shown once, in the response that mints it. It’s 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. The portal 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.
  • 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 only. The record confers no authorisation.

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 shows any break as an error, with the reason. A write that can’t 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, and auditor. Auditors are read-only (reply attempts are rejected at message send, and a consult is refused), so you can safely grant them broad inspection access. Consulting is granted per target (consult(<agent>:*)) and is one level deep: an agent being consulted can’t consult another, so a skill can’t start a chain or a loop of agents asking each other.

Policies and approvals

Access decisions also go to a Cedar policy engine, with Milieu’s built-in rules written as Cedar policies and yours in etc/policies/*.cedar. It covers commands and built-in tools, consulting, messaging, project access, a credential reaching an agent, conversations, the admin portal, and on the gateway route admission and what an agent does within a route. A role is an entity a policy can name (Role::"associate", OperatorRole::"viewer"), so rules by role go here.

  • Shadow mode (policy.mode = "shadow"): Milieu’s own checks decide, and every disagreement with the policies is logged. Use it to try a policy before it decides anything.
  • Enforce mode (policy.mode = "enforce"): the policies decide. It fails closed: a policy that fails to evaluate denies, and a file that doesn’t parse refuses everything the policies decide until it is fixed.
  • Approvals: a permit annotated @approval("operator") allows what it matches only after a person approves it (milieu approval, the portal, or a webhook). Each grant lets one action through.

A permit widens access beyond Milieu’s checks, so milieu policy lint warns about every one. See OPERATIONS → Policies.

Secrets management & the gateway

A secret can reach a tool in two ways, 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, so this way is 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), and 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 it can’t read the credential.
The agent's tool sends a request with the per-run token to its local proxy, which checks and strips the token and re-originates the call over mTLS to the gateway. The gateway matches the route, injects the secret, calls the upstream, records the agent CN and route, and returns the response.

Gateway hardening properties:

  • Secrets are named environment variables resolved at gateway startup. They’re never written to disk and never in the manifest.
  • A cli route runs with a curated environment (only PATH, HOME, and the route’s declared variables), so one route can’t read another’s secrets.
  • SQL routes accept a named, parameterised query and its parameters, never raw SQL, and never the data source name (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 and CA fingerprints and reports them once (to stdout or --secrets-out <file>). Keep that file out of version control. On cloud VMs, instance metadata isn’t a secret store. Fetch ANTHROPIC_API_KEY from a real secret manager (AWS SSM/Secrets Manager, GCP Secret Manager). For more information, 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/domain/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 nonzero 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 REST API and A2A edges share one access log, and the gateway keeps its own. Both are hash-chained and 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 can’t drift from the writer.
  • Record fields: REFERENCE → Audit records.
Four hash-chained logs: CLI and built-in tools append to var/audit/audit.jsonl, the REST API and A2A edges to var/api/audit.jsonl, the admin portal to var/web/audit.jsonl, and the gateway broker in the trusted zone to var/gateway/audit.jsonl. Each has its own verifier.

Keyed chain

The hash chain alone is unkeyed: anyone who can write a log can also recompute it end to end and leave a chain that verifies. Give the installation an operator-held key and every new record also carries a mac, an HMAC-SHA256 of its hash under that key. Someone who can write the log but can’t read the key can no longer produce records that verify.

  • The key comes from MILIEU_AUDIT_KEY (hex or base64, 32 bytes or more) or from a file named by audit.key_file / MILIEU_AUDIT_KEY_FILE. Milieu refuses a key file inside MILIEU_HOME or one that group or other can read: a key beside the log it protects adds nothing. Setting both is an error.
  • One key covers all five logs (var/audit, var/api, var/gateway, var/web, var/auth). Each process that writes one needs it, including the gateway host.
  • The mac is an extra field. The hash is computed the same way as before, so old logs still verify, and a log keyed part-way has an unkeyed prefix followed by keyed records.
  • No key, keyed log: a writer without the key keeps appending unkeyed records, so nothing stops working, and says once on stderr that the log holds keyed records. A key that disappears is suspicious, and verify reports any unkeyed record after a keyed one as a downgrade, with or without the key.
  • A refused key (bad encoding, too short, a key file in the wrong place or with loose permissions) makes every append to and verify of a keyed log fail with the reason. Milieu doesn’t fall back to writing unkeyed records.
  • With the key, verify checks every MAC and reports how many records are keyed and from which seq. Without it, verify checks the hash chain as before and says the MACs were not checked.

Neither the chain nor the MAC detects tail truncation (lopping records off the end leaves a shorter-but-valid chain), and an attacker who rewrites the whole log as unkeyed records leaves a chain that verifies with 0 keyed. Record the seq that keying starts from, and periodically anchor the latest hash somewhere outside the log (a separate store, a notary, a security information and event management (SIEM) system).

Data isolation & multi-tenancy

  • Per-tenant root. All state resolves from MILIEU_HOME. A tenant is a separate MILIEU_HOME (separate VM or pod), so agents, knowledge, memory, and audit log are fully isolated. Hosts share only what you configure them to share (an embedding server). See DEPLOYMENT.
  • Membership gating. A project’s knowledge and memory layers are reachable only by roster members, and 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 isn’t a capability to someone who lacks the content. There’s no per-agent access control list (ACL) on raw blobs. Membership gating applies to the knowledge and memory layers only. Treat the blob store as shared within one MILIEU_HOME.
  • Storage constraint. Put var/ on local disk, never on NFS: SQLite write-ahead logging (WAL) needs proper POSIX locks.
Tenant A and tenant B each have their own MILIEU_HOME on a separate VM or pod and share only an embedding server, if configured. Inside a tenant, a member agent reaches the project's knowledge and memory, a non-member's --in request is rejected, and both reach var/blobs/, which is shared by digest.

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 (standard library net/http only). Dependencies are pinned in go.mod / go.sum and are few (TOML, YAML, gojq, SQLite, and an optional PostgreSQL driver).

  • Pinned container image. The repo-root Containerfile is a multi-stage build (Go → Debian slim) producing a non-root image (fixed UID 10001). Its base images, and the third-party images under deploy/k8s, are pinned by digest, with the tag each stands for in a comment. deploy/image-digests says which pins are behind their tag.

  • Scanned, signed, attested image. The image workflow (.github/workflows/image.yml, run by hand for now) builds the image, writes an SPDX SBOM of it, and scans that before anything is pushed: a high or critical vulnerability with a fix available fails the build. The pushed image is signed keyless with cosign, and carries a build-provenance attestation and an SBOM attestation, all bound to its digest and to the workflow’s GitHub OIDC identity, so there is no signing key to keep. Verify an image before deploying it:

    cosign verify ghcr.io/geordee/milieu@sha256:<digest> \
      --certificate-identity-regexp '^https://github.com/geordee/milieu/\.github/workflows/image\.yml@' \
      --certificate-oidc-issuer https://token.actions.githubusercontent.com
    gh attestation verify oci://ghcr.io/geordee/milieu@sha256:<digest> --owner geordee
  • Reachable vulnerabilities. make vuln runs govulncheck, which reports the known vulnerabilities the code can reach in its modules and in the standard library of the Go it is built with. CI runs it against the newest Go 1.26, which the image is built with, and make release refuses to start while it finds any.

  • Pinned workflow actions. Every GitHub Action is pinned by commit SHA, with its release in a comment, because a tag can be moved to other code. Dependabot proposes updates to the actions and the Go modules once a month.

  • Deterministic execution surface. The fixed sandbox PATH means the binary an agent executes for a given command name is fixed at build or deploy time. The environment at run time can’t change it.

Production hardening checklist

  • MILIEU_SANDBOX is bwrap/sbexec (not none), or the container or pod is the documented isolation boundary.
  • Each agent’s [network] allow is the minimal host:port set, with no stray *.
  • High-value secrets go through the gateway, never [env] passthrough.
  • MILIEU_ENV_PASSTHROUGH lists only the environment variables that must be passed.
  • The gateway runs in a separate trust boundary (its own VM or namespace), never co-tenanted with an app host.
  • The API is served over TLS, keys are scoped one per consumer and rotated, and unused keys are revoked.
  • The admin portal (milieu web serve) binds loopback or sits behind TLS with a trusted certificate. Operator accounts are per person, and accounts of operators who leave are removed (which ends their sessions).
  • The portal’s own log is reviewed alongside the agent log: milieu web actions don’t 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 and are never in VM metadata or version control.
  • The image deployed is named by digest, and its signature and provenance are verified (cosign verify, gh attestation verify) before it runs.
  • Your etc/policies/*.cedar pass milieu policy lint, and ran in shadow mode with only the disagreements you meant before policy.mode = "enforce".
  • An audit key is set (MILIEU_AUDIT_KEY from a secret manager, or a 0600 key file outside MILIEU_HOME) on every host that writes a log.
  • milieu audit verify runs in CI/cron with the key. The latest hash and the seq keying starts from are anchored externally (tail-truncation defence).
  • The daemon runs as a dedicated user, and 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, and 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).
  • messages.unsigned is set to refuse after upgrade-time queues have drained (the default, deliver, is for the upgrade), and milieu route refused is empty or explained.
  • 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, and pods run non-root with drop: [ALL].

Reporting a vulnerability

Report suspected vulnerabilities privately to the maintainers. Don’t open a public issue. Include the affected version or commit, a description, and reproduction steps. The maintainers aim to acknowledge reports promptly and coordinate a fix and disclosure. (Maintainers: replace this paragraph with your security contact (for example, a security@ address or a GitHub private security advisory link) before publishing.)

Known limitations & non-goals

Current limitations (for status, see ROADMAP):

  • Audit records aren’t signed per agent: messages are signed by their sender and checked at delivery, but audit records rest on the (optionally keyed) hash chain and boundary identity. A signing key is read from disk with no rotation command. Replacing one makes the agent’s queued messages fail.
  • Per-host egress covers proxy-aware HTTPS only: plain HTTP, and clients that ignore HTTPS_PROXY, get no network under a restricted policy.
  • Audit tail truncation isn’t self-detected, even with the audit key: it needs an external anchor. Without the key the chain is unkeyed, and anyone who can write a log can recompute it.
  • The built-in issuer’s signing key doesn’t rotate: replacing it invalidates every outstanding token. Its sign-in limit is per name, in memory, and per process, so a restart forgets it, and someone who knows a person’s name can lock them out for 15 minutes. Open registration isn’t rate-limited.
  • Blob store has no ACL and no garbage collection: it’s shared by digest within a MILIEU_HOME and grows unbounded with attachments.
  • bwrap is unavailable in hardened pods: Kubernetes uses pod-as-boundary. Quadlet VMs keep it (DEPLOYMENT §1.2).
  • An agent with unrestricted network can reach the cloud metadata server on a GCP VM, and with it the VM’s service account: its image pull, the Anthropic secret or Vertex AI, whichever that deployment grants. A restricted policy can’t, unless an entry matches the metadata server (its address, or *:80).

Non-goals (left to the operator or platform):

  • TLS termination / ACME, DNS, load balancing, autoscaling, and backups of var/ are out of scope for the bundled deploy configs. For more information, see DEPLOYMENT → Out of scope.
  • Milieu doesn’t attempt to prevent prompt injection or model misbehaviour. It contains a misbehaving agent through the preceding boundaries.