Sievant docs
Threat model
This document grows with each milestone; M10 completes it. Each entry names the threat, what Sievant does about it, and what the operator configures.
Denial of service: connection and TLS-handshake exhaustion
Threat. Every TCP connection costs Sievant goroutines and memory, and
every TLS connection costs a handshake (asymmetric cryptography, the
most expensive CPU step a client can make the server do) before a single
request is admitted, authenticated or rate-limited. A client, a
misbehaving agent fleet, or an ordinary HTTP client starting cold (it
dials one connection per waiting request until its first connection has
negotiated HTTP/2: 1,000 concurrent calls made 600 to 2,100 connections
in tests on 2 cores) can exhaust the server before request-level limits
(max_inflight_calls, rate limits) ever apply. HTTP/2 adds a second
axis: one connection can carry many concurrent streams.
Mitigations.
| Control | Where | What it bounds |
|---|---|---|
server.limits.max_clients (required) |
accept | open connections in total |
server.http.limits.max_conns_per_ip |
accept | open connections per client address (IPv6 grouped by ipv6_prefix), so one host cannot take every slot |
server.http.limits.max_streams_per_conn (default: in-flight plus queued calls, at most 1,000) |
HTTP/2 | concurrent requests on one connection |
server.http.read_header_timeout, read_timeout, idle_timeout |
connection | slow or idle clients holding slots |
max_inflight_calls, max_queued_calls, rate limits |
request | admitted work |
Connection limits are checked when the connection is accepted, before the TLS handshake: a refused connection costs one accept and one close, never a handshake. Requests on accepted connections never touch the limiter (the 2 ms policy-evaluation budget is unaffected).
Detection. Refusals are counted in
sievant_connections_refused_total{reason="max_clients"|"per_ip"} and
summarized in at most one WARN per refusal_log_interval (default 10s),
naming the five busiest sources. The busiest sources are tracked with
the Space-Saving algorithm over 1,024 slots, so a heavy source stays
visible during a storm from many addresses, and the log cannot be
flooded by the storm itself.
Recommended values for HTTP deployments. sievant init writes stdio
configurations, where they do not apply and validation refuses a
server.http block; init --profile strict writes exactly these values
as a commented http block, to uncomment after switching to HTTP serve
(raising server.limits.max_clients to at least max_conns_per_ip
too):
server:
limits:
max_clients: 500 # size to the expected clients plus headroom
http:
read_header_timeout: 5s
limits:
max_conns_per_ip: 16 # HTTP/2 needs 1; 16 absorbs a cold-start burst
ipv6_prefix: 64 # one site or host usually holds a whole /64
max_streams_per_conn: 32 # one connection cannot take every worker slot
refusal_log_interval: 10s
Operator notes.
- Behind a TLS-terminating proxy (
tls.mode: upstream_terminated), the proxies intrusted_proxiesare exempt frommax_conns_per_ip, because every client arrives through them. Limit per client at the proxy instead. - Clients behind one NAT address share a per-address limit: raise
max_conns_per_ipfor such networks rather than removing it. - A refused client sees the connection closed. Well-behaved clients reuse one HTTP/2 connection and never reach the per-address limit once warm.
- The dashboard listener has its own fixed connection cap and is not
covered by
server.http.limits.
Residual risk. A distributed attack from many addresses can still
fill max_clients; the limits keep the server responsive and the attack
visible, but blocking it belongs upstream (network filtering, a load
balancer or DDoS protection).
Observe mode
Threat. mode: observe lets through what policy would stop:
denied tools, credentials in arguments, injection-laden results, calls
over rate limits and budgets. An installation left in observe mode is
monitored, not protected.
What Sievant does. Observe mode is never a default for an existing
configuration (absent mode is enforce); only sievant init writes it,
for new installs. Startup and any reload into observe mode log a warning,
and every would-be block is a WOULD_BLOCK audit event. Authentication,
size caps, the SSRF guard, memory budgets, connection limits and rug-pull
refusal stay enforced. Move to balanced or strict once sievant ui
shows what they would block.
Tool description changes (“rug pulls”)
Threat. An MCP server, or a new version of its package, changes a tool’s description or schema after the user approved it, to steer the model (instructions in the description) or to widen what the tool does.
What Sievant does. Agents are served the definitions read at
startup; within a run a changed definition is refused. With pinning on
first use (description-pinning), the first definitions seen are
recorded on disk, and a change seen on any later start withholds the tool
until an operator accepts it. Configured pins (tools[].sha256) hold a
tool to an exact definition.
Residual risk. The first definition is trusted as seen (trust on
first use): review it (sievant scan --inspect-tools flags
instruction-like descriptions) or pin it in the configuration. A server
can change behaviour without changing its definition; pins cover what
the model is told, not what the server does.
Path and URL arguments
Threat. An agent steered by injected text asks a filesystem tool for
~/.ssh/id_rsa or a fetch tool for an attacker’s URL (exfiltration by
egress).
What Sievant does. arguments.paths and arguments.urls (packs
fs-paths, egress) and the path_under / url_hosts constraints
refuse path arguments outside listed roots and URL arguments to unlisted
hosts, after cleaning paths and parsing URLs.
Residual risk. Sievant checks the argument text; it does not resolve symbolic links or know a server’s working directory (relative paths are refused by default for that reason). Data can still leave through arguments not named as paths or URLs (a query string inside a free-text argument): the rules narrow egress, they do not prove its absence.
Credentials in command lines and URLs
Threat. Some MCP servers are configured with the credential in the
command line (a token argument) or in a URL that is itself the credential
(a “capability URL”: a UUID or high-entropy path segment, or a token or
user ID in the query, as some providers issue). Command lines are visible
to other local users and processes (ps, /proc/<pid>/cmdline, Task
Manager), and URLs are copied into logs, shell history, screenshots and
support tickets.
What Sievant does.
sievant scanflags credentials in URLs (SEC003 passwords, SEC004 capability URLs, inurlfields and in arguments) and hard-coded secrets in arguments (SEC001, SEC002).- Everything Sievant prints masks URL credentials, keeping scheme, host
and shape (
https://mcp.example.com/server/{masked:36}/mcp?user_id={masked:8}): scan reports (terminal, JSON, HTML), wrap’s diff and dry run, every log line (a logger hook masks every logged string and error that contains a URL), the child server’s stderr, error messages andconfig show. - The parts of a configured URL that are credentials are also redacted from what the server sends back, like env and header values.
sievant wrapmoves header values into the entry’senv, never into arguments.
Residual risk. A wrapped stdio server keeps its original arguments:
they are passed on the sievant proxy … -- <command> <args> command line,
so a token or capability URL in them is visible in the process list
exactly as it was before wrapping (once for sievant proxy, once for the
server it starts). Wrapping does not make this worse, and does not fix
it. The planned fix is sievant wrap --externalize-secrets: credential
arguments and capability URLs move into the entry’s env and reach the
server through it, so no command line carries them. Until then, rotate
any credential that has appeared in a command line on a shared machine.
Per-agent keys on one machine
Threat. Several local agents (two editors, a CLI agent) share one Sievant configuration; one of them, steered by injected text, calls tools allowed only to another.
What Sievant does. With identity.provider: api_key
(sievant agent add), each agent presents its own key and is held to
its own policy. The configuration holds only SHA-256 digests; keys are
shown once and compared in constant time.
Residual risk. Over stdio a key is passed in the client’s server
entry (SIEVANT_API_KEY), so it is as safe as that client configuration
file: any process that can read the file, or launch the server with its
environment, can claim that agent. Give each client its own key, so a
client configuration that is shared or exposed costs only that key, and
rotate it then with sievant agent token <name> --replace (a running
sievant serve needs a restart to drop the old key). Keys separate
agents that do not read each other’s configuration; they do not
separate processes running as the same user with full access to the
home directory.
Plain HTTP for the in-pod sidecar (tls.mode loopback)
Threat. In Kubernetes sidecar mode the agent reaches Sievant over HTTP inside its pod. Without TLS or credentials, anything that can reach the listener can call tools as the pod’s agent.
What Sievant does. server.http.tls.mode: loopback is accepted only
when listen_addr is a loopback address (127.0.0.1, ::1 or localhost);
startup refuses a listener that did not bind to one, and every request
from a non-loopback peer is refused (403). Only processes in the pod’s
network namespace can connect, and with it identity.provider: static
names the one agent the pod runs (it needs identity.static.agent).
Every other configuration validates as before: static identity over a
network listener is still refused, and loopback cannot be combined with
mTLS, certificates or trusted proxies.
Residual risk. Every container in the pod shares the network
namespace, so each of them is the pod’s agent to Sievant: run only the
agent and the sidecar in a pod Sievant guards. A pod with
hostNetwork: true shares the node’s loopback, so the sidecar would be
reachable from other host-network processes on the node; the injector
refuses to inject into such pods. An agent that ignores
SIEVANT_MCP_URL and calls MCP servers directly bypasses Sievant. A
NetworkPolicy cannot stop it from the agent’s side: the sidecar and the
agent share the pod’s network identity, so their traffic looks the same.
Make the MCP servers accept connections from Sievant only: in gateway
mode, a NetworkPolicy on the servers’ ingress that admits only the gateway
pods; in sidecar mode, servers that authenticate their callers (the
sidecar’s connector credentials), which the agent does not hold.
The sidecar injector (sievant webhook)
Threat. A mutating admission webhook sits on the path of pod creation. If it is down it can block workloads, if it is compromised it can change pods, and its certificate and RBAC are themselves targets.
What Sievant does.
- Scope. Two webhooks, each narrowed by a CEL match condition, so only
pods that are to be injected reach it: those of labelled namespaces
(unless opted out) and annotated pods. The release’s own namespace and
the
kube-*namespaces are excluded, so the injector can never block its own restart. - What it changes. The injector only adds to a pod (a native sidecar, volumes, one environment variable, one annotation) and refuses host-network pods. Pods are parsed with exact keys, so its patch paths are the ones the API server applies; the parser is fuzzed.
- Failure policy.
Failonly where the operator chose the strict profile or set it;Ignoreotherwise. Two replicas and a PodDisruptionBudget by default. - RBAC (self-signed mode).
getandupdateon one Secret,createon Secrets in its own namespace, andgetandpatchon one webhook configuration, nothing cluster-wide beyond that. With cert-manager it has no API access and no service account token. - Certificates. An ECDSA P-256 CA and serving certificate, renewed well before expiry. A CA rotation publishes both CAs before the serving certificate moves to the new one. Replicas coordinate through the Secret’s resourceVersion.
Residual risk. Anyone who can update the webhook configuration or its
Secret can redirect or impersonate the injector: protect both like any
admission controller’s (cluster-admin only). With Ignore, a pod created
while the injector is unreachable runs without the sidecar; use Fail
(the strict profile) where that is not acceptable. The injector’s own
pods, and pods in excluded namespaces, are never injected.
MCP tasks (M2.6)
Threat. A task outlives the request that made it: its ID is a handle to upstream state and to a result not yet delivered. Another agent that learns or guesses it could read the result or cancel the work, and a late result could slip past the checks a direct call gets.
What Sievant does.
- IDs. Agents get Sievant’s own task IDs, 128 random bits from crypto/rand. The upstream’s ID, which may be sequential or guessable, stays in Sievant’s table and never reaches an agent.
- Binding. A task belongs to the agent identity that created it.
tasks/get,tasks/result,tasks/updateandtasks/cancelfrom any other identity get the same “Task not found” as a guessed or expired ID, andtasks/listshows the caller’s tasks only. Agents that share one identity (onestaticagent for every client of asievant proxy) share their tasks; give agents separate identities to keep them apart. - Checks on results. A result does not skip the guardrails because
it arrives later:
- delivering it (
tasks/result, ortasks/getof a finished task in 2026-07-28) runs the wholetools/callchain again for the task’s tool and arguments; - RBAC is checked again, so a tool denied after the task was created delivers nothing;
- the output checks, budgets and audit apply to each delivery, and a result is never cached unchecked.
- delivering it (
- Upstream claims. What an upstream says about a task is checked before use: the ID matches, the status is known, the timestamps are RFC 3339, the TTL is a non-negative number. Status messages are scrubbed and redacted like progress messages.
- Exhaustion. At most 100 live tasks per agent and 1,000 in total. Tasks are kept at most 24 hours. Polls spend from a per-agent rate-limit bucket.
Residual risk.
- an upstream that asks for input during a task never gets it (requests from upstreams are refused), so the task may wait until its TTL;
- task state lives in memory: a restart of Sievant forgets every task (agents get “Task not found”), and the upstream keeps the work until its own TTL.
From the Sievant repository: docs/threat-model.md at commit 8529db1.