Documentation — Security
Build & operate

Security

agentd wires a language model to real credentials and real side effects. That is the whole product and also the whole problem: everything the model reads may be adversarial, and everything it can call is authority it holds. There is no reliable way to tell an injected instruction from a legitimate one, so agentd does not try. It ships no policy engine, no classifier, and no RBAC DSL. An agent's authority is the set of tools its operator declared, bounded by one structural rule about which kinds of tool may sit together in one process. This page says what is enforced, where in the code, and — in a final section that is not an afterthought — what agentd does not defend against at all.

The threat model

The model is untrusted input holding credentials. Under a successful prompt injection, the agent loop emits attacker-chosen tool calls with the operator's authority. Treat the agentd process as potentially compromised and size the surrounding sandbox accordingly.

Everything an MCP server returns is untrusted — not only tool results but the parts the protocol presents as metadata: a tool's name, description, input schema, annotations. A malicious server can ship an injection in a description, or mutate one after the first connection. agentd passes that metadata to the model as the tool catalogue but never lets it make a security decision: capability tags come from operator config only, and readOnlyHint / destructiveHint are hints, never gates.

Prompt injection is not patchable. A guardrail that works 95% of the time is a failure in security terms. The defenses here are structural — they bound what a compromised loop can reach, not what it intends.

agentd trusts its own binary, the OS, and the operator's configuration. What arrives from the network is bounded to a whitelist: an A2A caller's config.set reaches three display/debug paths plus agent.approval — the human-approval mode, operator-only and deliberately settable mid-session, because how closely you want to be asked changes with what the agent is doing (a2a_server.rs::interface_config_set) — and nothing else, every other path refused by name in that same function's catch-all arm, which answers with the list of what is settable. The model can never register an MCP server, edit an endpoint, or name a binary to run.

Capability scoping

agentd has 53 internal tool contracts (memory, artifacts, plans, subagents, workflows). Every task capability — reading a repository, sending mail, querying a database — arrives from an operator-declared MCP server. Declaring a server is a trust decision equivalent to adding a dependency you call at your own privilege.

Narrowing is set membership, not a policy language, and it lands at exactly two surfaces — which enforce it at different points, and the difference matters:

  • A workflow agent step honours its tools: list — the plan is filtered by pattern (*, an exact name, or prefix*) before the child sees the definitions (runtime/steps.rs::step_turn, registry/mod.rs::defs_for, ::pattern_matches).
  • subagent.run's tools: argument does confine the child. The supervisor mints it into the spawn payload as a grant (runtime/subagents.rs::subagent_run, through subagent/protocol.rs::narrow_tools), and the child filters both its tool catalogue and its dispatch routing map against it (agentloop/runner.rs::narrow_catalogue), so an excluded tool is unreachable rather than merely unadvertised. A caller-supplied context entry cannot forge or widen that grant — any allow-list already on the seed is dropped before the mint, because subagent_run discards every seed message whose role is subagent/protocol.rs::ALLOWED_TOOLS_ROLE. servers: narrows independently: the payload is built from just those server specs, so the child cannot dial the rest (the same function's read of args["servers"]).

An unknown name in servers: is filtered out silently, not rejected — that read filters on self.mcp_specs.contains_key, so a typo yields a less capable child and no diagnostic.

sec/scope.rs also defines a Scope / ToolScope intersection type — parent ∩ requested over a server whitelist and a tool-name whitelist, both dimensions checked independently. It has no call site outside its own tests; the live exports of that module are TrifectaTag and check_trifecta. The two bullets above are the whole of the narrowing.

Separately, agent.tools.internal | mcp | code controls what the model sees. That is a catalogue filter, not the authorization check; the check is Registry::allowed.

Tool tags and the Rule of Two

The trust budget is three operator-declared tags:

TagMeaning
untrusted_inputthe tool returns content from an uncontrolled source — web pages, inbound mail, issue text
sensitivethe tool reaches private data or privileged systems — secret store, internal database, prod control plane
egressthe tool can move data out or change external state — HTTP POST, send mail, open a pull request

Tags are parsed as snake-case strings from config only; an unrecognized tag is a hard config error (config/v2/mod.rs::McpServer::tag_set). Nothing the model or a server says feeds the gate.

config_version: "1"
mcp:
  servers:
    - { name: web,   endpoint: https://mcp-fetch.internal/mcp, tags: { "*": [untrusted_input] } }
    - { name: vault, endpoint: https://mcp-vault.internal/mcp, tags: { "*": [sensitive] } }
security:
  allow_trifecta: false   # default; adding an `egress` server here refuses startup

The budget is an OR-fold across legs, never a count (scope.rs::Trifecta::merge) — repeating one leg across twenty tools stays one leg. The Rule of Two is literally legs() < 3 (scope.rs::evaluate), so every pair is allowed, including sensitive + egress. A tool that reads secrets and can POST is fine as long as nothing in the same grant reads untrusted input.

Tags are per server, not per tool. The config shape is a map keyed by glob, but McpServer::tag_set() iterates self.tags.values() and discards the keys (config/v2/mod.rs::McpServer::tag_set); Registry::build stamps that union onto every tool of the server (registry/mod.rs::Registry::build). So tags: {"send_*": ["egress"], "read_*": ["untrusted_input"]} does not split the server into two risk classes — both tools end up untrusted_input | egress. The only real split is one MCP server per tag profile. An untagged server counts as untrusted_input (config/v2/mod.rs::validate, in the fold headed // trifecta over the root grant), the conservative default.

Where the gate runs

Two enforcement points, both consulting the same security.allow_trifecta setting.

Gate 1 lives inside config validate() (config/v2/mod.rs::validate, where the root-grant fold ends in a check_trifecta call), the single validation authority that both startup and --validate-config run, so the two can never disagree. A refusal is a config error — exit 2, before any side effect:

lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress
into one agent; narrow the tags or set security.allow_trifecta (audited)

Gate 2 is at the subagent.run chokepoint (runtime/subagents.rs::subagent_run, which calls check_trifecta itself), over the tags of the requested server subset. It returns an isError tool result the parent's model must adapt to, not a crash.

Because gate 1 folds over every declared server, it is a whole-instance budget. An instance declaring an untrusted-input reader, a secrets server, and an egress server will not start, even if you intend to hand each leg to a different subagent. To run that shape you either set security.allow_trifecta: true — which relaxes gate 2 as well — or run separate agentd instances per risk profile. security is a restart-only config path (config/v2/mod.rs::RESTART_ONLY_PATHS), so a hot reload can never widen the override; a reload touching it is refused as restart_required and the running config is kept.

Not every leg comes from mcp.servers: a binary built with the exec feature and running with security.exec.enabled contributes sensitive + egress to this same fold, pushed by its #[cfg(feature = "exec")] arm, so enabling the local runner beside an untrusted-input server refuses startup like any other trifecta.

Two gaps to know. No warn event is emitted when the override is exercised: a scope.trifecta_grant event name is reserved but never written, so an allowed trifecta proceeds silently and the only trace is the config value. And code-registered (embedder) tools sit outside the accounting: they are inserted with Grant::all() and an empty tag vector — the ToolSpec literal that registry/mod.rs::Registry::build writes for a code tool — so an embedder whose native tool does egress or reads secrets defeats the budget silently.

The tag floor and closed egress

Tags being config-only leaves one hole: the author of a config (or of a subagent template) could point a server at the billing system and simply not write sensitive — the gate then reasons soundly from a false premise. The services: catalog closes it: an entry binds an endpoint to authoritative tags, and any MCP declaration whose endpoint matches the entry gets those tags unioned in before the gate runs — referencing or inline, unconditionally. Under-tagging a catalogued endpoint is therefore impossible rather than undetected. security.egress: closed extends the catalog from authority to allow-list across the outbound surfaces it knows about: MCP dials, intelligence endpoints, A2A peers, the http step (with per-entry methods: ceilings), stream forward: targets, the HTTP store, workflow-reference URLs, and caller-registered A2A push targets — refused at boot for configured surfaces, at execution for templated ones. Entries carry a kind: and matching is kind-filtered. There is deliberately no in-config exception list: the way to allow an endpoint is to catalog it, which is exactly the reviewable event it should be. What the catalog does not bind: where a compromised MCP server can reach (network egress policy stays complementary — the entry list is what makes those rules derivable), observability.otel.endpoint (telemetry export is operator plumbing; validation says so rather than implying coverage), and the instruction fetch itself — an agent.instruction or subagent-template url: / oci: source is dialled while the configuration is still being assembled, before there is an egress policy to consult, so pin it by digest and treat the document host as operator plumbing too.

Policies: a verdict on the call

Grants answer may this caller reach this tool at all, and they are name patterns. They cannot express "delete anything outside /tmp", or "a person signs off before any egress-tagged call" — and agent.approval only decides whether to honour a gate the model asked for. security.policies is the layer in between: an ordered list matched against the call itself, first match wins, no match is allow.

security:
  policies:
    - match: {tool: "fs.delete*", args: "CEL: !args.path.startsWith('/tmp/')"}
      action: deny
    - match: {tags: [egress], caller: [subagent]}
      action: ask
      question: "{{caller}} wants {{tool}} with {{args}} — allow?"
      on_timeout: deny
    - match: {tool: "billing.*", principal: {role: user}}
      action: deny

It hooks execute_tool — one chokepoint every call passes — deliberately after argument validation, so a guard judges arguments that already conform to the tool's schema rather than whatever the model emitted. It composes with the machinery either side rather than duplicating it: tools.overrides says where a call goes, this says whether, and action: ask suspends on the same deferred-human path ask_human and the human node already use. This is also where the trifecta tags finally do work at runtime rather than only folding at startup.

A policy gate has no addressee: it asks whoever is watching. Naming a decider for an operator-declared tool gate is the same feature the human node's to provides (see Addressed gates), but it belongs on the policy rule rather than being invented per call — so it is not there yet.

An empty list is exactly the previous behaviour, for the cost of one is_empty check.

Every caller, or it is worse than nothing

A policy table that held for root turns but not subagent turns would be worse than none, because the operator would believe they were covered. Two different bypasses exist and both are closed:

  • a turn worker dials its MCP tools from its own route map, so any tool a rule might touch is left out of that map and served by the runtime instead;
  • a subagent is a separate process that connects to MCP servers itself, so the supervisor names its gated tools in the spawn payload and the child routes exactly those back up the existing tool-request channel.

Gated tools pay one round-trip; everything else keeps the fast path. It is a routing decision, not a trust boundary — the parent re-evaluates the policy when the request arrives, so a child that ignored the list is still refused.

Three deliberate refusals

shadow never fabricates a result. It says plainly that the call was held and no result exists. A schema-conformant fake would be reasoned over as real, and every later decision built on an observation that never happened — a strange thing for a fail-closed runtime to ship, and worse than refusing.

An ask with no interface attached denies. A gate nobody could answer has not been approved, and running the call because no client happens to be connected would make the policy a suggestion. A policy ask also does not route through agent.approval, whose auto mode answers with a model judge — letting the agent approve the operator's own security gate.

An argument guard that will not compile is exit 2. Silently treating it as no-match turns a deny into an allow at exactly the moment it was meant to bite, so a build without the cel feature refuses the config rather than evaluating the guard to nothing.

The injection firewall

The defense that does the real work is process isolation plus a distilled return. A subagent runs in its own process with its own context, and the parent appends only the child's distillate — never its transcript. A string result over DISTILL_CAP (8000 bytes) is truncated back to a UTF-8 boundary (subagents.rs::DISTILL_CAP, ::distill).

Poisoned bytes live only in the reader's context and are gone when that process exits. The reader holds no sensitive tool, so it has no secret to encode into its summary. This is a trusted-planner / untrusted-data split realized as OS process isolation rather than taint tracking. The tree is flat by construction: a subagent is handed no in-child orchestration tools (subagent/control.rs::NoSelfTools), so it cannot spawn children in-process.

Caller scopes

Internally there are four caller kinds: Root, Workflow, Subagent, Principal. Grants only ever gate internal contracts — for MCP and code tools the check short-circuits to allowed for root and workflow callers, and for a subagent spawned without a tools: grant; a subagent that carries one is held to it for every tool class, MCP and code included (registry/mod.rs::allowed). MCP restriction otherwise happens through agent.tools.mcp selection and the per-spawn server subset, never through grants.

TierCallersExamples
ALLroot, workflows, subagentsmemory.*, artifact.*, plan.*, skills.*, knowledge.*, search.*, ask_human, think, exec
ROOT_WFroot, workflowssubagent.*, code.run, workflow.run / cancel / wait
ROOT_ONLYrootinstruction.subscribe, workflow.create / update / delete / pause / resume

finish is granted to root and subagents but not to workflows — a workflow terminates with the finish step kind instead.

External callers arrive over A2A. The transport supplies a CallerIdentity — verified mTLS SANs and subject, a bearer reference, an AAuth agent id, a loopback flag — and Resolver::resolve walks the a2a.principals rules first-match, then falls back to operator on verified management, then operator on loopback with no principals configured, then anonymous (a2a/principals.rs::Resolver::resolve).

a2a:
  listen: https://0.0.0.0:8443
  tls: { cert: /tls/cert.pem, key: /tls/key.pem, client_ca: /tls/clients.pem }
  principals:
    - { match: { san: "spiffe://ops/*" },  role: operator }
    - { match: { san: "spiffe://team/*" }, role: user, grants: [workflow.*] }

Authorization for the served surface is a hand-written matrix, Principal::may for the RPC method and Principal::may_command for a command DataPart:

RoleMay call
operatoreverything, unconditionally
userworkflow.run / status / cancel, subagent.send / status, plan.get, ask_human, conversation.get, run.get
agentworkflow.run, workflow.status
anonymousnothing — denied at every layer, and an explicit grants: ["*"] does not rescue it

status and interface.info are always granted to any non-anonymous role (principals.rs::Principal::may_command). Of the 53 internal contracts, exactly one — status — carries a default grant for user/agent. The admin family (drain, lameduck, pause, resume, cancel) is refused by name for every non-operator role, independent of grants. Bearer tokens and pairing codes are compared in constant time: principals.rs::ct_eq for a principal's bearer (::Compiled::matches), and the shared sha.rs::ct_eq for the static server bearer (a2a/serve.rs::is_server_bearer) and the rotating pairing code (a2a_server.rs::PairingState::pair). Those are two copies of the same length-check-then-XOR-fold compare rather than one shared helper — a duplication to know about, not a hole.

Two honest limits. A2A role limits bound command DataParts, not conversation: a natural-language message from a user principal drives a turn handed the root tool plan (runtime/turns.rs::start_root_turn), so a caller who cannot invoke workflow.delete as a command may still be able to ask for it in prose. And the registry's Grant.roles table is not the live onePrincipal::as_caller() has no production call site, so editing a contract's user/agent grant changes nothing for A2A.

The exec runner

agentd runs no local code by default. The exec tool exists as a contract, but a local runner materializes only when the exec cargo feature and security.exec.enabled are both true (registry/mod.rs::Registry::build); otherwise exec is Impl::MappingOnly, which fails is_available() and routes nowhere — unavailable for every caller including operator. The dispatch arm itself is #[cfg(feature = "exec")], so a default binary answers "no built-in implementation". Map the contract onto an MCP server with tools.overrides and the command runs in that server's sandbox instead.

Watch the tag weight when you do. Registry::build stamps exec sensitive + egress, and those per-tool tags are what the security.policies engine matches on — Registry::tags_of is read by runtime/tools.rs::apply_policy, by the turn-worker routing split (runtime/turns.rs::tool_plan) and by the subagent gated_tools mint (runtime/subagents.rs::subagent_run) — while an override replaces them wholesale with the serving server's tags (registry/mod.rs::apply_override). What actually reaches the trifecta budget is the config-side contribution above: two legs when the local runner is built and enabled, and otherwise whatever the MCP server you mapped it onto declares. Mapping exec onto an untagged server moves the blast radius off-box and files it as untrusted_input.

security:
  exec:
    enabled: true
    allow: [git, ls, cat]     # argv[0] allow-list; EMPTY = deny everything
    workdir: /workspace       # mandatory; a requested cwd must resolve inside it
    timeout: 30s              # a longer requested timeout is clamped down
    max_output: 1048576       # 1 MiB cap on captured stdout+stderr
    env: [PATH, HOME]         # the ONLY variables the child receives
GuardBehaviourWhy
argv, never a shellCommand::new(cmd).args(argv) — execve directly (exec.rs::run_command)no metacharacters, globs, $(…) or pipes, so no command injection
allow-listexact equality on argv[0]; empty list denies all (runtime/tools.rs::exec_tool)enabled: true alone runs nothing
workdir confinementmandatory; a requested cwd is canonicalized then checked with starts_with(base) (exec.rs::resolve_cwd)defeats .. traversal and symlink escape together
timeoutmin(requested, max), default 30s; the child is killed and reaped (exec.rs::run_command)a request can shorten but never extend the ceiling
output capdefault 1 MiB; the reader drains past the cap and discards the excess (exec.rs::read_capped)bounded capture, and no deadlock on a full pipe
minimal envenv_clear() then rebuild from the named list (exec.rs::run_command)the agent's environment, and its secrets, are never inherited
off the reactora named tool:exec thread; stdin fed from a further threada child that writes before reading cannot stall the daemon
auditexec.run{cmd, argc, cwd, timeout_ms, caller} (runtime/tools.rs::exec_tool)the confinement is logged, never the output

Every guard is re-checked at call time even though Registry::build already gated the route — runtime/tools.rs::exec_tool re-derives the allow-list, the workdir and both clamps from live settings on every call. Output is {stdout, stderr, exit_code, timed_out}.

Two things before you enable it. A misconfiguration surfaces as an isError result at first call, not as a startup failure — the feature, enabled, a non-empty allow, and a workdir must all line up. And the guard is argv-not-shell, not no-shell: allow-listing bash reinstates the entire injection surface by construction.

Secrets

Secrets have exactly two reference forms, {{secret:NAME}} (process environment) and {{secret-file:PATH}} (a mounted file). Both resolve at the instant of use (sec/secret.rs::resolve), so a rotated file is picked up without a restart. Exactly one trailing newline — or CRLF — is stripped from a file read, because kubelet projects a Secret verbatim while editors append one; interior whitespace stays part of the credential.

An unknown {{…}} token is an error, not a pass-through, so a typo cannot smuggle braces onto the wire. Errors name the reference, never the value: a missing variable yields {{secret:NAME}} is not set in the environment. The Secret newtype's Debug prints *** (config/v2/mod.rs::Secret), so a credential cannot reach a log line, a payload dump, or a panic message through formatting. The durable subagent record is written with the intelligence token nulled out, re-supplied from live settings on restore (subagents.rs::secret_free_payload).

Two checks catch an inline credential in the config file. Four paths must be references outright — /intelligence/token, /a2a/bearer, /security/aauth/enroll_token, and each MCP server's oauth.client_secret (config/v2/mod.rs::secret_violations, whose first three come from ::FILE_SECRET_PATHS). Separately, any header whose key looks credential-shaped — authorization, api-key, x-api-key, token, password, secret, or anything ending -token / _token / -key / _key (config/mod.rs::is_secret_shaped_key) — is refused with an inline value, across intelligence.headers, mcp.servers[].headers and a2a.peers[].headers.

The limit is the key name, not the value: a bearer pasted into headers.X-Session passes validation. Use references everywhere; outside those two shapes nothing will catch you. Outbound credential providers — OAuth2, AWS SigV4, SPIFFE, agentd login — are covered in authentication.md.

Transport and identity

MCP endpoints are HTTPS-only, with plaintext http:// permitted for loopback hosts alone; anything else exits 2 before any side effect (config/mod.rs::mcp_endpoint_scheme_ok). The same rule holds for the intelligence endpoint. A non-loopback a2a.listen must configure client auth — a2a.tls.client_ca, a2a.bearer, or interface.pairing — or startup fails validation, and plaintext http:// on a non-loopback bind is likewise a startup error (config/v2/mod.rs::validate, the two refusals in its a2a.listen block).

One default deserves emphasis: a loopback caller with no a2a.principals configured resolves to operator with grants: ["*"] (principals.rs::Resolver::build sets the flag, ::Resolver::resolve acts on it). Anything that can reach the loopback port — a sidecar, a co-tenant process, an SSRF from another service in the same network namespace — is a full operator, which includes flipping agent.approval to accept over config.set: the agent's own ask_human gates then answer themselves from whatever they recommend, and the ones recommending nothing fall to a model judge (runtime/human.rs::ask_human_tool, ::spawn_human_judge) — until the next reload puts the file's value back, since agent.approval is reloadable (config/v2/mod.rs::RELOADABLE_PATHS). Two kinds of gate survive that flip: one that names its decider with to:, which is never auto-answered whatever the policy says — the _ if addressee.is_some() arm of ask_human_tool short-circuits ahead of every approval mode — and an operator-declared security.policies ask, which is deliberately not routed through agent.approval at all (runtime/tools.rs::policy_gate). Configure principals on any host you do not fully own.

The only process agentd launches is a re-exec of its own binary via current_exe() (runtime/mod.rs::run), marked with the AGENT_SUBAGENT environment variable. The child's work arrives as a serialized control frame on its stdin — data to a model loop, never argv to a shell. Each child gets its own process group so the kill ladder can target the subtree, an optional cgroup leaf whose Drop writes cgroup.kill, and PR_SET_PDEATHSIG so a supervisor death collapses it.

SSRF defenses

The SSRF classifier guards the outbound surfaces where the URL is not purely operator config: the workflow http node (runtime/http_node.rs::do_http), the one place a URL can be model- or graph-derived; caller-registered A2A push targets, vetted at registration where the caller is present to be told why and again at delivery because DNS can change its mind in between (a2a/push.rs::check_url, ::deliver); and the AAuth Person-Server dial, where it is the URL the PS returns that is vetted — the operator's configured ps_url still dials by name, so a loopback PS keeps working in development (aauth/ps.rs::connect, whose vetted argument picks between ssrf::connect_vetted and a plain connect_tcp). Intelligence, MCP, configured A2A peers, and OAuth traffic goes out unguarded by design, because those endpoints come from operator config; a model that can influence any of those URLs is outside the guard. A configured peer dial in particular is a plain connect_tcp with no classification at all (mcp/a2a_client.rs::HttpConn::connect) — it is only the push targets a caller registers at runtime that are guarded.

Blocked as non-global: 0.0.0.0/8, 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16 (the cloud-metadata range), 100.64/10 CGNAT, 240/4 reserved, 224/4 multicast, and the broadcast address; for IPv6, ::, ::1, fe80::/10, fc00::/7, ff00::/8, 2001:db8::/32 (net/ssrf.rs::is_global_v4 and ::is_global_v6, which ::is_global dispatches between). IPv6 is classified by first peeling ::ffff:a.b.c.d and ::a.b.c.d forms back to v4 and re-running the v4 rules — the classic bypass, closed. guard_host rejects if any resolved address is non-global, so a hostname answering with both a public and a private address is refused outright. Header names and values containing \r or \n are rejected at request construction in both send paths, before any bytes are written (net/http.rs::send, ::send_streaming).

The guard and the dial are one step. A check that resolves a name, likes the answer and then dials the name is decorative: the connect resolves a second time, and an attacker holding the authoritative DNS answers the check public and the connect 169.254.169.254. So ssrf::connect_vetted resolves once, classifies every address it got back, and connects to an address it vetted, re-asserting is_global immediately before the syscall (net/ssrf.rs::connect_vetted). TLS/SNI and the Host header stay on the hostname — connect by IP, verify by name — so certificate validation is unaffected. All three guarded dial paths use it (http_node.rs::do_http, push.rs::deliver, ps.rs::connect), which closes the rebinding pivot on each. guard_host survives for the one question asked where there is no socket yet: may this push target be registered at all (push.rs::check_url), answered while the caller is still there to be told no. Its own doc says it is not sufficient at delivery time, which is exactly why delivery guards again.

One limit remains. allow_private: true is an off switch, not a "permit RFC-1918" switch. It does not permit a narrower range; it removes the classifier. guard_host returns Ok without resolving at all (net/ssrf.rs::guard_host), and on the dial path resolve_guarded still resolves — it has to, to have an address to connect to — but skips every address check (net/ssrf.rs::resolve_guarded_with), as does the pre-syscall re-assertion (::connect_addrs). The workflow http node exposes it as a plain per-node boolean in the graph spec. Review it in graph diffs the way you review a credential.

The low-level client follows no redirects at all — there is no 3xx/Location handling, so a redirect comes back as a plain response and the redirect-chain pivot does not exist for the http node. Two document-fetch paths layer redirect following on top of it, both at config load and both from an operator-named reference: the url: instruction source, up to three hops (config/mod.rs::http_get), and the OCI blob fetch, which has to follow a registry's CDN (oci.rs::get_blob).

Where the instruction came from

The instruction is the agent's standing policy: whoever controls it controls what the agent will do. agentd can check two independent things about it, and they answer different questions.

Who WROTE it — agent.instruction.trust. The Instruction Specification §7 author signature travels inside the document, as a front-matter signature: line. With a publisher pinned, agentd verifies that signature after decryption and before anything interprets the bytes — the one point a file:, dir:, url:, oci: and registry-served document all converge on. An unsigned document is refused, as is one signed by another publisher or naming a doc id no pin covers. A folder is verified per file, before the documents combine. The attested capabilities CAP the grant: effective = grant ∩ ceiling ∩ attested, so a signature can never widen what the operator gave.

Who PUSHED it — oci: {ref, cosign_key}. For an OCI artifact, the cosign signature beside it says which publisher pushed these bytes to this registry. agentd fetches it, verifies it against the configured public key, and checks that the signed payload names this manifest digest.

Neither substitutes for the other. A registry compromise can serve a genuinely authored document from the wrong place; a stolen push credential can publish an artifact nobody authored. Pinning the reference by digest (@sha256:…) removes the mutable-tag question entirely and is the cheapest of the three.

A build without --features sign cannot check an author signature at all, so a configured pin is a startup refusal there rather than a silent pass.

What agentd does not protect against

Stated plainly so you size the surrounding environment correctly.

  • No in-binary sandboxing. No seccomp, no namespaces, no chroot. The only OS-level hardening is process-group isolation, an optional cgroup leaf, and PR_SET_PDEATHSIG. Confinement, filesystem scope, and aggregate resource limits are the deployment's job.
  • No egress network policy. Which hosts the process may reach is a NetworkPolicy or firewall concern; the SSRF guard covers only the surfaces named above.
  • No content-based injection detection. No classifier, no "is this injection?" model call. The defense is containment, and containment is not a guarantee.
  • No per-tool tagging in mcp.servers[].tags. Server tags apply per server; the glob keys are parsed and discarded. Per-tool tags exist only through tools.narrow.<tool>.tags, which is append-only — a tag may be added, never removed — and reaches the security.policies matcher rather than the startup trifecta fold.
  • No rug-pull detection. A server that mutates a tool description after first connect is not detected; the only connect-time log is mcp.connect{server, tools} with a count.
  • No audit event for a trifecta override — it proceeds silently.
  • No artifact redaction. Artifacts carry a sensitive flag, but artifact.get returns the content regardless (runtime/artifacts.rs::get_value).
  • No policy engine, request signing, or RBAC beyond the principal roles above.

Operator checklist

  1. Run agentd inside a real sandbox with an egress policy and cgroup limits. That is the security boundary; agentd is not.
  2. Treat every declared MCP server as code you execute at agentd's privilege. Vet it.
  3. Tag every server, one server per tag profile — glob keys do not split a server.
  4. Configure a2a.principals on any host where loopback is not exclusively yours.
  5. Reference every secret; validation covers four config paths plus credential-shaped header keys — a credential under any other key name sails through.
  6. Leave exec off. If you enable it, keep allow minimal, never allow-list a shell, and never co-locate it with an untrusted-content reader.
  7. Run agentd --validate-config -c agentd.yaml in CI — the same authority startup runs, exiting 2 on any diagnostic.
  8. Pin where the instruction comes from: a digest rather than a tag, trust for who wrote it, cosign_key for who pushed it. A document you did not verify is a policy you did not write.