Spec: RFC 0012 — Security posture. Binding decisions:
docs/design/00-architecture-assessment.md. Milestones:docs/design/PLAN.md.
agentd connects an LLM-driven loop to arbitrary, operator-declared MCP servers. It runs
no local code of its own — there is no exec/shell tool and no way for the model to run
a command; every capability it has is a tool from a declared MCP server. That still leaves
the one unsolved problem in agent security: prompt injection — and its acute form,
Willison's lethal trifecta, where a single agent simultaneously (1) reads untrusted
content, (2) holds sensitive data or tools, and (3) can communicate externally. Any agent
holding all three legs is a one-injected-prompt exfiltration tool.
Prompt injection is not patchable. A 95%-effective guardrail is a failure in security terms. So agentd does not pretend to solve it with a classifier or a policy DSL. It contains it structurally.
agentd ships no policy engine, no request signing, no auth, no RBAC in core — not behind a feature gate, not anywhere. This is a conscious reversal of the retired "governance is the moat" design (a Rego-style DSL, ed25519 signing, JWT/x509). The reasoning:
- A policy engine living inside an injectable model loop is theatre. The model can be steered to emit policy-compliant-but-malicious actions, and the engine itself is pure binary weight and new attack surface.
- agentd's security is instead the OS process tree, the granted MCP subset read as a trust budget, and distilled structured returns as an injection firewall. These cost near-zero binary weight, which keeps them consistent with the minimalism bar that is the product moat.
Concretely, the posture has eight load-bearing parts:
- The outer boundary is the sandbox — container / VM / microVM / enclave.
- Capability scoping = the granted MCP subset, interpreted as a Rule-of-Two trust budget.
- Process isolation + distilled returns form an injection firewall.
- All MCP server content is untrusted — including tool descriptions (tool poisoning).
- SSRF defenses live in the one hand-rolled HTTP client.
- No local execution. agentd runs no code of its own — no
exec/shell tool exists; the only process it launches is a re-exec of the trusted agentd binary itself (a subagent), never a user- or model-supplied argv. - Every network surface is HTTPS with authenticated identity — intelligence, the MCP
client, the served self-MCP, A2A, and operator control are all HTTP(S) with mTLS/bearer
auth (loopback
http://for dev); agentd links no unix/vsock transport. - Secrets are env/flag only, behind a
resolve()front door, never logged.
agentd is sandbox-aware, never sandbox-providing. It does not seccomp, chroot, or namespace itself. Confinement, egress policy, filesystem scope, and aggregate resource limits are the deployment's job:
- Confinement / sandboxing — container, VM, microVM, or enclave.
- Egress network policy — which hosts the whole pod may reach is a NetworkPolicy / firewall concern. The SSRF guards (§5) are a second line, not the only one. The recommended container shape terminates TLS at a sidecar, so most builds link no TLS at all.
- Aggregate memory — cgroups v2 (
memory.max,pids.max,cgroup.kill). agentd enforces only the token ceiling and per-childRLIMIT_AS/CPU in-binary; aggregate subtree memory is a cgroup concern. agentd is cgroup-aware, never cgroup-requiring.
Run agentd as if the process itself could be compromised, because under a successful injection it effectively is. The blast radius is whatever the surrounding sandbox permits.
agentd ships no task tools of its own — only its self/control orchestration tools
(subagent.*, resource read/subscribe). Every other capability comes from operator-declared
MCP servers. A subagent's capability set is exactly the MCP subset it was granted, and scope
narrows monotonically down the subagent tree (RFC 0009) — a child can never hold more than
its parent.
The trust budget is built on three operator-declared tags per tool. They come from operator config, never from server-supplied metadata (which is untrusted, §4):
| Tag | Meaning |
|---|---|
untrusted_input |
tool returns content from an uncontrolled source — web pages, inbound email, issue text, arbitrary files |
sensitive |
tool exposes private data or privileged systems — secrets store, internal DB, prod control plane |
egress |
tool can move data out of the trust boundary or change external state — HTTP POST, send mail, open PR |
Untagged tools default to untrusted_input: true — the safe assumption is that any tool's
output may carry an injection; operators downgrade explicitly. The self/control tools
carry fixed tags: resource read/subscribe inherit from the underlying server; subagent.* is
untagged (it is the chokepoint, not a leaf
capability).
The tag set is a budget, not an allow/deny rule. It bounds what a single isolation unit (one subagent process) may simultaneously hold.
Status. Per-tool tagging via MCP server config (
--mcp-tags) and the Rule-of-Two check below are implemented (sec/scope.rs). The tags JSON below and--allow-trifectaare on the CLI today; the full flag set is listed in §8.
Intended config shape (tags attach by tool-name glob, longest-glob-wins, so one server can be
split into e.g. a read_* subset that is sensitive but not egress):
{
"mcp": {
"web": { "cmd": ["mcp-fetch"], "tags": { "*": ["untrusted_input"] } },
"vault": { "cmd": ["mcp-vault"], "tags": { "*": ["sensitive"] } },
"mail": { "cmd": ["mcp-smtp"], "tags": { "send_*": ["egress"] } }
}
}The trifecta check lives inside Config::validate() — the single validation authority (RFC
0017 §7) that both startup and --validate-config run — over the root grant, the OR of the
capability tags across every granted MCP server (an untagged server counts conservatively as
untrusted_input). Because it is part of validate(),
--validate-config and startup can never disagree: a trifecta-only config that startup refuses
is also reported config.invalid (exit 2) by the admission gate. Because scope narrows
monotonically down the tree (a child's grant is always a subset of its parent's, RFC 0009),
bounding the root bounds every descendant — so the single root check suffices and the per-spawn
path never has to re-evaluate it. On a hot reload the check re-runs over the new config: a
reload that would newly form a complete trifecta without --allow-trifecta is rejected
(config.reload_rejected{reason:"trifecta_required"}) and the running config is kept verbatim —
the live capability set can never be widened into a trifecta without a restart.
-
Refuse (default): a root grant that co-locates all three trifecta legs makes agentd refuse to start —
validate()rejects it as a config error, so it prints the reason and exits2(a config-usage refusal; the daemon never comes up):agentd: refused — this grant gives one agent all three lethal-trifecta legs (untrusted input + sensitive data + egress). Split the capabilities across subagents, or relaunch with --allow-trifecta. -
Warn (with
--allow-trifecta): startup proceeds and the supervisor emits an auditable log event so the override is never silent (also emitted if a reload lands an allowed trifecta):{"level":"warn","event":"scope.trifecta_grant","allowed":true, "legs":["untrusted_input","sensitive","egress"]}--allow-trifectais process-global and does not propagate into spawn payloads — a child cannot re-grant itself the override. -
Ok: silent.
The check is purely structural. It never inspects content and never asks the model to judge
safety. The per-spawn subagent.spawn chokepoint does not re-run it: it only narrows
scope by intersection (a child requesting a tool its parent doesn't hold is refused as an
isError tool result) and clamps limits. Because a child's tag union can never exceed its
parent's, the one root-startup check bounds the whole tree.
The recommended pattern (encoded in the subagent.spawn tool description) is to split a
trifecta task into a reader (no sensitive, no egress) that returns a distilled summary, and
an actor (no untrusted input) that consumes it — which is exactly the firewall in §3.
This is the load-bearing structural defense, and it falls out of the subagent result contract for free. A child subagent returns a distilled, structured value (~1–2k tokens) + terminal status + usage up the length-framed control channel. The parent appends the distillate — never the child's raw transcript. Two security properties follow with zero extra mechanism:
-
Content quarantine. Raw untrusted bytes — a poisoned web page, a malicious tool description echoed back in a tool result — live only inside the reader subagent's context and are deleted when that process exits. The parent's context (which holds the sensitive/egress tools) never ingests them, so an injection in that content cannot author actions in the parent. This is CaMeL's trusted-planner / untrusted-data split, realized as OS process isolation rather than a taint-tracking interpreter.
-
Bandwidth limiting. A 1–2k-token distillate is a low-bandwidth channel. Exfiltrating a secret through the summary requires the reader to encode it — but the reader has no sensitive tools and therefore holds no secret to leak. With scopes split per §2, exfiltration is structurally, not statistically, prevented.
untrusted source ──▶ READER subagent ACTOR subagent ──▶ egress
(web/email/files) tags: untrusted_input tags: sensitive,egress
NO sensitive, NO egress NO untrusted_input
│ ▲
└── distilled summary ───┘
(~1–2k tokens, no raw bytes cross the line)
Defense-in-depth (recommended, not enforced in v1): the parent specifies the child's output contract as a constrained shape (enum/struct fields, not free prose), so injected instructions in the child's input have no syntactic place to surface in the return. agentd does not enforce schema-constrained returns in v1 (that needs provider strict-mode plumbing); the firewall holds on isolation alone, and the constrained shape is a recommendation on top.
Every byte that originates from an MCP server is untrusted model input — including the parts
the protocol presents as trusted metadata: a tool's name / description / inputSchema,
its annotations, resource description / mimeType, prompt text, and of course tool results.
This is tool poisoning (OWASP ASI01): a malicious server ships a description that carries an
injection, or quietly mutates it after first connection (a "rug pull").
Concrete rules:
- No auto-trust of server metadata. Tool descriptions and annotations are passed to the
model as the tool catalogue, but are never used to make a security decision. Tags come
from operator config (§2), never from
annotations. ThereadOnlyHint/destructiveHintannotations are treated as untrusted hints — surfaced for audit, never load-bearing. - Audit surface. On
tools/list, agentd logs each tool's{server, name, description_hash, description_len}atinfo(event:"mcp.tool.listed"). A description whose hash changes between connections logsevent:"mcp.tool.description_changed"atwarn— rug-pull / TOCTOU detection. - Endpoints are never model- or server-derived. The set of MCP servers and their endpoints
come only from operator config (
--mcp), validated at startup (bad config → exit 2). The model cannot add a server, edit an endpoint, or make agentd connect to a URL it produced. agentd never spawns a server — it connects to a declared HTTPS endpoint; the only process it launches is a re-exec of its own trusted binary (subagent.spawnre-execsargv[0]— §6).
Declaring an MCP server is an operator trust decision.
--mcp name=https://…points agentd at a remote tool endpoint you have chosen; trust it the way you trust any dependency you call. Its tools run in its sandbox, over the network — agentd runs none of its code.
- The MCP transport is HTTPS. A remote MCP server is reached over TLS with per-server auth
headers (loopback
http://for a same-host dev sidecar); agentd links no unix/vsock dialer.
agentd's single hand-rolled HTTP/1.1 + SSE client is the only outbound network primitive,
and therefore the only SSRF chokepoint. It carries every network surface — the https://
intelligence transport, the MCP client, A2A, and the served self-MCP. Guards apply after DNS
resolution and on every redirect hop:
- HTTPS in prod. Plaintext
http://targets are rejected by default; loopback dev may relax this. - Block private / loopback / link-local by default — RFC-1918 (
10/8,172.16/12,192.168/16),127/8,169.254/16,0.0.0.0/8, IPv6::1/fc00::/7/fe80::/10, and v4-mapped-v6 forms. The169.254/16block specifically denies the169.254.169.254cloud-metadata SSRF; the v4-mapped and0.0.0.0/8cases close the usual bypasses. - DNS pinning / anti-rebinding. Resolve once, vet the resolved IP(s), then connect to the vetted IP — not a fresh re-resolution. This closes the DNS-rebinding TOCTOU between the policy check and the connect.
- Validate redirects. Each
3xxLocationis parsed, re-vetted (scheme + resolved IP), and counted against a redirect cap. A cross-host or downgrade (https→http) redirect is refused, not followed, and surfaced as the request error. - CR/LF-injection-rejecting headers. Header names/values containing
\r,\n, or NUL are rejected at construction, so no string (including a model-produced one) can split a request or inject a header. Secret-bearing header values are resolved after this check and the resolved secret is itself CR/LF-validated.
These are a few tens of lines of checks, not a library — consistent with the no-url-crate /
no-ICU dependency stance.
Status. The SSRF guards are implemented (
net/ssrf.rs). The HTTPS/private-range policy knobs described in RFC 0012 (e.g. allowing localhost/plaintext for dev) are not yet exposed as CLI flags.
agentd runs no code of its own. There is no exec tool, no shell tool, no plugin loader —
nothing the model can call that runs a local command. Every capability the agent has is a tool
served by a declared MCP server, reached over the network (HTTPS). This removes the strongest
egress leg of the lethal trifecta by construction: an injected prompt cannot make agentd run
a binary, because agentd has no code path that runs one.
The one and only process agentd ever launches is a re-exec of its own trusted binary — a
subagent (subagent.spawn re-execs argv[0], the agentd executable). That path never takes a
user- or model-supplied command:
- The executable is fixed to agentd's own path, passed by the supervisor at startup
(
current_exe()), never derived from a request. Freezing this is a load-bearing invariant — ifargv[0]ever became request-controlled, subagent-spawn would become arbitrary-exec. - The child's work — its instruction, tool scope, limits — arrives as a serialized payload over the child's stdin pipe, i.e. data to a model loop, never code to a shell.
- Every child is its own process group (
setpgid), carries a finite deadline, counts against the subtree token/depth/breadth caps, and is torn down by the bounded SIGTERM→SIGKILL kill ladder (RFC 0003).
If a workflow genuinely needs to run a command (build a project, run a test suite), that belongs behind an MCP server the operator declares and scopes — where it carries capability tags and is subject to the same Rule-of-Two budget as any other tool, and where the blast radius is the server's own sandbox, not agentd's process.
Secrets are config, never model/server data, and never durable agentd state.
- Sources: env and flag only. The intelligence credential comes from
AGENT_INTELLIGENCE_TOKEN(or--intelligence-token). Secrets resolve through a singleresolve(name)front door. The config file is never a secret source. The retiredcommand/oauth2resolvers are dropped. - The carrier is
Config.intelligence_token. TheConfigDebugimpl maps it to***, so a secret cannot accidentally enter the JSON-lines log, a spawn payload, an MCP_metablock, or a checkpoint. The logger uses a field allowlist: secret-bearing fields are simply absent from the schema, so even content-logging cannot emit them. - Use site. The intelligence credential is materialized only at the instant of writing the
wire bytes (after CR/LF validation, §5) — set on the LLM endpoint's authorization /
x-api-keyheader — and is not retained longer than the request. - Never persisted, never in a transcript. A secret value never appears in a tool-call transcript fed back to the model, in a distilled return, or on disk.
This is already visible in the foundation today: the Config Debug impl redacts the token to
***, and there is a test (token_redacted_in_debug) asserting the value never reaches a
debug string. Pass secrets via the environment:
export AGENT_INTELLIGENCE_TOKEN="$(cat /run/secrets/intel-token)"
agentd --instruction "…" --intelligence https://api.example/v1 --model my-modelThese security-relevant knobs exist in the binary today
(crates/agentd/src/config.rs).
| Flag | Env | Default | Purpose |
|---|---|---|---|
--allow-trifecta |
— | off | permit all three capability legs in one subagent (audited override) |
--mcp-tags name=tag,tag |
— | — | tag a server's tools untrusted_input / sensitive / egress for the Rule-of-Two |
--intelligence-token <T> |
AGENT_INTELLIGENCE_TOKEN |
— | bearer/key for the intelligence endpoint (redacted in logs) |
--intelligence <URI> |
AGENT_INTELLIGENCE |
— | https://host/… (loopback http:// for a same-host dev gateway; any other scheme is exit 2) |
--serve-mcp <https://host:port> |
AGENT_SERVE_MCP |
off | serve agent's own MCP over HTTP(S) with mTLS/bearer auth (loopback http:// for dev) |
--mcp name=<endpoint> |
— | — | declare a remote MCP server over Streamable HTTP (repeatable; operator-only, never model-derived) |
--max-steps <N> |
AGENT_MAX_STEPS |
50 | per-run step cap (a bound on a runaway/injected loop) |
--max-tokens <N> |
AGENT_MAX_TOKENS |
200000 | token budget |
--deadline <dur> |
AGENT_DEADLINE |
600s | wall-clock deadline |
--max-depth <N> |
— | 4 | subagent tree depth cap |
The intelligence-URI validator rejects any scheme outside https:// (or a loopback
http://) and exits 2 on a bad value — before any side effect, including any LLM
round-trip. The same https-only rule holds for --mcp, --serve-mcp, and --a2a-peer.
The self-MCP — subagent.*, the agent:// state resources, and the operator control family —
is served over Streamable HTTP(S). Trust is never derived from the transport; it is
established per request by an authenticated identity:
- mTLS is the primary identity. With
--serve-cert/--serve-key/--serve-client-ca, the TLS acceptor verifies the client certificate against the pinned CA; a presented, verified cert mints aManagementpeer. - A bearer token is the alternative.
--serve-bearer <token>accepts a request whoseAuthorization: Bearer …matches in constant time, also mintingManagement. (The token is redacted everywhere.) - No open control plane. A non-loopback bind must configure mTLS and/or a bearer — an
unauthenticated non-loopback listener is a startup error. A loopback
http://bind with no auth is allowed only for local development. - Operator control is the A2A admin method family (
a2a.Drain/LameDuck/Pause/Resume/Cancel), reachable only by aManagementpeer; an unauthenticated/in-process (Stdio) caller gets-32601, as if the method did not exist.
This is the authenticated, hardened HTTP control surface earlier drafts deferred — it now ships
(the HTTP/1.1 + SSE server framing lives in the reusable mcp crate). agentd links no
unix/vsock listener.
Stated plainly so you size the surrounding environment correctly:
- No policy engine / DSL, no request signing, no JWT/OAuth/x509 auth, no built-in RBAC — in core, in any feature gate. The conscious reversal of "governance is the moat."
- No in-binary sandboxing (seccomp / namespaces / chroot) — delegated to the outer boundary (§1).
- No content-based injection detection / classifier. Prompt injection is unsolved; agentd defends structurally (isolation + scope budget + firewall) and is honest that this is containment, not a guarantee. There is no "is this prompt injection?" model call.
- No schema-enforced subagent returns in v1 — the firewall holds on process isolation; constrained-shape returns are a documented defense-in-depth recommendation (§3).
- No dynamic / network-supplied config. Config is never read from the network; the model can
never register an MCP server or an
execbinary.
- Run agentd inside a real sandbox (container/VM) with an egress NetworkPolicy and cgroup limits — that is the security boundary, not agentd.
- Treat every
--mcpserver as code you execute at agentd's privilege. Vet it. - Keep
execoff unless you need it; when you do, never co-locate it with an untrusted-content reader. - Tag your tools and split trifecta tasks into reader/actor subagents
(
--allow-trifectato override with an audit log). - Pass secrets via env/flag only; they never touch logs, transcripts, the config file, or disk.