Documentation — Security model
Concepts

Security model

agentd wires a language model to real credentials and real side effects, so everything the model reads may be adversarial and everything it can call is authority it holds. This page explains what agentd enforces, where each rule runs, and what agentd does not defend against, for security reviewers and operators who need the reasoning behind the controls. To apply it, follow Harden a deployment.

The threat model

agentd assumes three things about the parties it works with.

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

Everything an MCP server returns is untrusted. That covers tool results and also what the protocol presents as metadata: a tool's name, description, input schema and annotations. A malicious server can put an injection in a description. agentd hands that metadata to the model as the tool catalogue and never lets it make a security decision: capability tags come from operator configuration only, and readOnlyHint and 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 defences here are structural: they bound what a compromised loop can reach, not what it intends. agentd ships no injection classifier and asks no model whether a text is safe.

agentd trusts its own binary, the operating system and the operator's configuration. What arrives over the network cannot change that configuration, with one bounded exception: the operator-only admin.set command reaches exactly two paths, agent.approval (how closely a person is asked) and a2a.introspection.enabled. Every other path is refused with the list of what is settable, nothing is written to disk, and the next reload puts the file's value back. The model can never register an MCP server, edit an endpoint or name a binary to run.

Trust boundaries

agentd splits the work across processes so that the process holding the operator's control is not the one running the agent's reasoning. The supervisor owns state, configuration, credentials, policies and every listener, and runs no agent loop. Each of the agent's turns runs in a turn worker process, and each subagent in its own process with its own context.

Apart from the operator's configuration, everything that crosses into the tree is untrusted content: model output, tool results, A2A messages and webhook bodies. The controls on the rest of this page decide what that content can reach once it is inside.

Capability scoping

An agent's authority is the set of tools its operator declared. agentd has 58 internal tools (memory, artifacts, plans, subagents, workflows, asks and similar), listed by agentd --capabilities. Every task capability, such as reading a repository, sending mail or querying a database, comes from an MCP server the operator declared. 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. It applies at two places:

  • A workflow agent step's tools: list filters the tool plan by pattern (*, an exact name, or prefix*) before the worker sees the definitions. Its servers: list is a cap: the step is offered the MCP tools of those servers alone.
  • subagent.run's tools: argument confines the child. The supervisor mints it into the spawn payload as a grant, and the child filters both its catalogue and its dispatch routing against it, so an excluded tool is unreachable, not merely unadvertised. A caller-supplied context entry cannot forge or widen the grant. Its servers: argument builds the payload from those server specs only, so the child cannot dial the rest.

Limit. An unknown name in subagent.run's servers: is dropped silently. A typo yields a less capable child and no diagnostic.

agent.tools.internal, agent.tools.mcp and agent.tools.code filter what the model sees. They are a catalogue filter, not the authorization check. Internal tools carry default grants by caller kind (root, workflow, subagent); the tiers are in the reference section.

Callers from outside arrive over A2A as principals. A turn that a user or agent principal drives is held to that principal: tools that act on the whole instance are refused, and tools that name a run or a subagent act only on the principal's own. A principal's grants bound the command operations it may call; they do not bound what the model does in a conversation the principal starts. A natural-language message from a user drives a turn with the root tool plan, so to restrict which tools that turn may call for a given caller, write a policy that matches the principal.

Tool tags and the Rule of Two

The trust budget is three tags the operator declares on each MCP server:

TagThe tool…
untrusted_inputreturns content from an uncontrolled source: web pages, inbound mail, issue text
sensitivereaches private data or privileged systems: a secret store, an internal database, a production control plane
egresscan move data out or change external state: an HTTP POST, sending mail, opening a pull request

The lethal trifecta is all three in one agent: something that reads text an attacker wrote, can reach secrets, and can send data out. The Rule of Two is agentd's response: any two legs may sit together, all three may not. The budget is an OR across tools, never a count, so twenty egress tools are still one leg, and sensitive + egress together is allowed as long as nothing in the same grant reads untrusted input.

Tags come from configuration only, and an unknown tag is a configuration error. Nothing the model or a server says feeds the check. Three properties of tags matter when you assign them:

  • Tags are per server, not per tool. The tags: map is keyed by glob, but the keys are discarded and the union of every list applies to every tool of the server. {"send_*": [egress], "read_*": [untrusted_input]} makes both tools untrusted_input and egress. The only real split is one MCP server per tag profile.
  • An untagged server counts as untrusted_input, the conservative default, in the check and at runtime: what it answers taints the work that reads it (Taint is tracked), as an untrusted_input server's does. A server with no tag at all is untagged, and so is one whose lists are empty. To vouch that a server's results are not outside text, tag it with the legs it does carry and leave untrusted_input out, or, when it carries none of the three, tag it trusted: tags: {"*": [trusted]}. trusted is no leg; beside untrusted_input it changes nothing. It may not be written on a delegated server, whose results auth.trusted_output vouches for, nor on a Company entry, whose content is always outside text.
  • tools.narrow.<tool>.tags adds tags to one tool, and can never remove one. Those per-tool tags reach the policy matcher, not the load-time Rule-of-Two check.

Where the check runs

The check runs at two points over servers, both governed by the one security.allow_trifecta switch, and at a third over what reaches a run through a stream (below).

Check 1 runs inside configuration validation, the one authority that both startup and agentd --validate-config use, so the two never disagree. A refusal is 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)

Check 2 runs when subagent.run is called, over the tags of the servers the child would get. It returns an error result the parent's model must adapt to.

Because check 1 folds over every declared server, it is a whole-instance budget. An instance that declares an untrusted-input reader, a secrets server and an egress server does not start, even if you intend to hand each leg to a different subagent. Run that shape as separate agentd instances per risk profile, or set security.allow_trifecta: true, which relaxes every trifecta check at once. The security section is restart-only, so a hot reload can never widen the override.

Three sources other than the servers' tags matter. A server named under skills.sources contributes untrusted_input whatever its tags: a skill it serves is its text, which the model reads (below). A binary built with the exec feature and running with security.exec.enabled contributes sensitive and egress to the same fold. Tools an embedder registers in code sit outside the accounting entirely: they carry no tags, so an embedded tool that does egress or reads secrets is invisible to the check.

Limit. Using security.allow_trifecta emits no event. The refusal text says "audited", but an allowed trifecta proceeds silently and the only trace is the configuration value.

The service catalog and closed egress

Tags being configuration leaves one hole: the author of a config, or of a subagent template, could point a server at the billing system and not write sensitive. The check would then reason 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 check runs, whether it references the entry or restates the endpoint inline. Under-tagging a catalogued endpoint is impossible rather than undetected. An entry also carries a tool ceiling (allow, exclude) that a consumer can narrow and never widen, and the one shared credential its consumers use.

security.egress: closed turns the catalog from an authority into an allow-list. Every outbound dial agentd makes from configuration must then match an entry of the right kind, matched on scheme, host and a path prefix that ends on a segment boundary:

SurfaceEntry kindChecked
MCP servers, a subagent template's serversmcpat load, and again before each dial
model endpoints, every intelligence.providers entryintelligenceat load
A2A peerspeerat load
HTTP store, workflow url: referenceshttpat load
the http stephttpliteral URLs at load; every URL again at the dial, where a matching entry's methods list is a ceiling
stream forward webhook targetshttpat the forward; a refusal logs stream.forward.refused
A2A push targets a caller registershttpat registration
agent.instruction from url: or oci:httpbefore the fetch

An instance child inherits the closed setting. The built-in mock: model endpoint opens no socket and is exempt. There is deliberately no in-config exception list: the way to allow an endpoint is to catalog it, which is the reviewable event it should be.

What closed egress does not bind:

  • observability.otel.endpoint. Telemetry export is operator plumbing, and validation warns that closed egress does not cover it.
  • Credential plumbing: OAuth token and discovery endpoints, the AWS metadata and STS endpoints, the AAuth Agent Provider and Person Server.
  • Anything a compromised MCP server, or a command the exec runner starts, reaches on its own. Network policy at the host or cluster stays complementary; the catalog is what makes those rules derivable.

Outside input that arrives through streams

The two checks above judge servers. Neither sees text that reaches a run through a stream: a webhook route with into: appends whatever its caller posted, an A2A a2a start with into: appends a peer's message, and the workflow consuming the stream hands that text to an agent. A config whose servers are all tagged, one of them sensitive + egress, passes check 1 and could then feed a webhook body to the agent holding both. So agentd taints streams and judges the runs that read them.

Producers taint a stream. A webhook into: and an A2A into: add untrusted_input: a valid signature says who sent the text, not that it is safe to obey. A subagent template's mirror_streams adds untrusted_input, because the child's producers run in another process. An MCP Events binding or grant adds its server's tags, and untrusted_input when the server is untagged, since whoever can write into the watched upstream writes into the stream. An emit from a run that carries taint puts it on its target.

Runs carry what they read. A run started from a tainted stream, or waiting on one, carries its tags. So does every run it starts: a workflow step, a workflow tool one of its agents calls, workflow.run, and workflow.signal (a signal reaches every workflow with a signal start or a wait {on: signal}).

The check. Each such workflow is judged with its tags folded together with everything its model-driven steps can reach: an agent step's servers: (or every server without the list), a subagent step's template servers, any tool that hands text on (subagent.run, subagent.send, message.send, workflow.run, workflow.signal) and what that tool can reach, and exec. Every model-driven step of a run the outside text reaches is judged together, so a reader and an actor in one run, or an actor in a run the reader starts, are refused alike. The refusal names the workflow, the stream, what feeds it and the servers that brought the other two legs:

workflow "triage": lethal-trifecta refused — it consumes stream "inbox" (fed by webhook `into:` at workflow "intake" step "hook"); it is run by workflow "triage" step "reply" (through workflow.run), and its model-driven steps reach mcp server "mail" [sensitive, egress]: untrusted input + sensitive + egress in one run. …

A run that has to act on what it read does so with no model-driven step holding both other legs: read with a step that has no tools (extract is built for this), and act with a deterministic step (mcp.tool, http), whose target is fixed in the definition. Harden a deployment has a worked example.

The check runs at --validate-config for inline definitions and those in local files and folders, and at startup and on every reload for the whole set once url: and uri: definitions resolve. A reload that moves anything the check reads re-judges every definition, the agent's stored ones included; a refused reload keeps the running configuration. A definition the agent writes is held to the same line: workflow.create and workflow.update refuse one that would complete the trifecta. A wait whose on is templated is refused at load, since rendered at dispatch it could listen on something the check never saw.

A route's own run is the operator's call. A webhook or a2a start that fires a run directly, and a wait {on: webhook}, hand a caller's text to a run the operator opened on purpose; that run is not judged on its own reach. What such a run emits onto a stream is judged like into:.

What the check does not follow:

  • Deterministic steps. mcp.tool, http, a2a.send and a2a.delegate are not counted, because no model picks their target. Their arguments are templated and can carry what a model that read the outside text produced. Give that model an output_schema so what reaches the arguments is constrained.
  • Shared state. Text a model writes to memory or an artifact is not traced to whoever reads it.

The telemetry taps (observability.runtime_events and an audit stream sink) are fed by every run, and their consumers are judged as any stream's consumers are. A workflow tool carries the taint of the run it starts, and the tools that read a result back carry the taint of every run, so a policy that matches untrusted_input sees them too.

The check is coarse and static on purpose: per stream, not per value, and per server. It is a grant-level check, not data-flow tracking.

Results read back are withheld

Taint flows forward into what a run starts. It also flows back, when a context reads a run's or a child's result. A context that holds both sensitive and egress and reads back a result carrying outside input would complete the trifecta, so the runtime answers it with the run's status and replaces the text with a marker that says why. It logs readback.withheld.

output withheld: this run carries outside input (it consumes stream "inbox" (fed by webhook `into:` at workflow "intake" step "hook")); this conversation holds sensitive and egress tools

The output, result and error text are replaced, along with the opening of a child's instruction and a run's step outputs and variables. Status, ids and timings stay. Every path a result can be read back through is covered; the reference section lists them.

What a run carries is recorded, and only ever added to. Each run and each child keeps a durable record of the outside input that reached it: from its definition's taint, from the run, child or peer that started or signalled it, from an A2A peer's own request, and from a stream event's record. A read is judged by that record together with the current definitions, which can only add. Deleting or replacing a definition, a restart, and a peer that has gone quiet all leave a result as withheld as it was; a run with no record left is taken to carry text from an origin no reader holds. A result is withheld only for text the reader was not already handed, so a route's own run reading back a child it started reads nothing new.

Holding a leg less, a context reads the text whole, and then carries it. Its own result is withheld in turn from a reader holding both legs, and what it starts or feeds is judged as a run that read a tainted stream would be. So no read-back is refused at load. An operator reading a run over A2A is no model's context and reads it whole. security.allow_trifecta lifts the withholding with every other trifecta check.

The injection firewall

The defence that does the most 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 result, never its transcript. A string result over 8000 bytes is cut back to a UTF-8 boundary.

Poisoned text lives only in the reader's context and is 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 process isolation. The tree is flat by construction: a subagent is handed no orchestration tools, so it cannot spawn children of its own.

Policies: a verdict on each call

Grants answer may this caller reach this tool at all, and they are name patterns. They cannot say "delete nothing outside /tmp" or "a person signs off before any egress-tagged call". security.policies is that layer: an ordered list of rules matched against the call itself. The first match wins, and no match is allow.

A rule matches on any combination of the tool name (a glob), the tool's tags (tags, every one listed; any_tags, at least one), the caller kind (root, workflow, subagent), the principal id (a glob, for calls made on a principal's behalf), a CEL expression over the arguments, and the facts of the call: delegated (it is made with an enrolled person's grant), tainted (the work making it has read outside text), holds_delegated_data (a delegated server's result reached that work), unattended (a trigger started the work, nobody is there to answer), document_sourced (a workflow an instruction document defined makes it) and mode (delegated when the work acts with a person's grant, agent when with agentd's own) — see Acting for an enrolled person. Conditions are ANDed, so adding one always narrows a rule. The verdict is allow, deny, ask or shadow; an ask with to: principal is the person's consent gate, and once_per: run_tool makes it ask at the first use per run and tool alone.

Policies run at one chokepoint every call passes, after the arguments are validated against the tool's schema, so a guard judges arguments that already conform. tools.overrides says where a call goes; a policy says whether.

An ask holds the call. It parks the call and puts a question on a task, which turns input-required. approve runs the held call, its grant and arguments checked again, and the asker gets the call's real result. deny returns an error naming the rule. A timeout runs the call only when the rule's on_timeout is allow, and refuses it otherwise; deny is the default. A workflow step's gate is recorded durably with its question, its addressee and the held call, so a restart rebuilds it unweakened.

An ask is addressed. It goes to the rule's to:, or to the operators when the rule names nobody. It never goes to the caller whose call is being judged, which would let the party the policy checks approve itself.

An ask carries agentd's badge. The task of a gate agentd raised itself — a policy's ask, a Company's gate — is marked securityGate in its task annotations, and the TUI and the web UI show it as agentd security gate. A model's ask_human and a workflow's human step are never marked, whatever their question says.

Every caller is covered. A policy that held for root turns and not for subagents would be worse than none. A turn worker dials its own MCP tools, so any tool a rule might match is left out of its routing and served by the supervisor instead. A subagent connects to servers itself, so the supervisor names its gated tools in the spawn payload and the child routes those back up. The parent evaluates the policy again when the request arrives, so a child that ignored the list is still refused.

Three refusals are deliberate:

  • shadow never fabricates a result. It says the call was held and no result exists. A schema-conforming fake would be reasoned over as real.
  • An ask nobody can answer takes its on_timeout, which defaults to deny. Nobody can be asked when no A2A listener serves, or when the call belongs to work no caller owns (a schedule, a webhook, a subagent) and agent.ask_human_unowned is not gate. The question is logged as tool.policy.unanswerable.
  • An argument guard that cannot be evaluated never becomes "no match". A guard that does not compile is exit 2 at load, a build without the cel feature included. One that fails at call time refuses the call.

A reload swaps the whole list or none of it. security.policies is read at every call, so a reload's list holds from the next one. A list that does not load refuses the reload and the old one stands. A list whose rules could hold a tool a running turn worker or subagent dials itself — a call no rule would see — is refused too, naming them, until that work ends. A gate already open keeps the rule that raised it, and a first use approved under a rule stands while that rule, the grant and the template are as they were.

Approvals and the judge

agent.approval decides what happens to the asks the agent makes with ask_human. With ask, the default, a person decides. With auto, a model judge answers on the operator's behalf, conservatively, and the answer is marked as the judge's. With accept, the ask's recommended answer is taken, and an ask with no recommendation goes to the judge. ask_human_fallback: auto also hands an unanswerable ask to the judge.

The judge runs on the model intelligence.roles.approve names. That role is operator-only: a served document cannot pick the judge of its own gates. On a decision model, a closed ask (an enum or boolean answer) is answered only above the role's confidence.

Three rules bound the judge:

  • A gate that names its decider is never judged. An ask_human with to:, and every policy ask, go to a person whatever agent.approval says.
  • No judge answers over outside input. When the run, subagent or peer conversation asking carries outside input, the ask fails with that reason and agentd logs human.judge.withheld. A judge's answer is no more trusted than what it reads.
  • admin.set can change agent.approval at runtime, and only the operator can call it. The next reload puts the file's value back.

An addressee must be able to see the task. A task is visible only to its owner and to operators, so a to: may name the task's owner (by leaving to: out) or an operator, narrowed by labels. A to: naming any other principal is refused at load, wherever it appears. An instruction document's @human/<name> gate goes to that human's principal, or to the operators; the human's channel is announced to clients and never widens who may answer. Work no caller owns gates on the listener only with agent.ask_human_unowned: gate. Human in the loop covers the asking side.

The A2A boundary

The A2A listener is the one door into a running agentd. The A2A channel explains what it serves; this section is the model it enforces and the threat each piece answers.

Who is calling

Every request resolves to a principal (an id, a role and grants) from its headers and its connection, before any of its body is read. The evidence is consulted in a fixed order, and each kind decides only for itself: a unix-socket peer of the daemon's own uid is the operator; a presented bearer decides next, and one that matches nothing is 401, never a fallback to what else the request carried; then a verified client certificate; then an any rule; then the implicit operator. Bearer secrets are compared in constant time. Session tokens, device codes and launch codes are 256 random bits, held only as their SHA-256 digest, in memory.

An enterprise access token is checked rule by rule. With a2a.inbound_tokens, a JWT bearer that is no static secret is held to every rule of RFC 0044 §5.9 before it names anyone, each refusing rather than warning:

  • the issuer is a configured one, byte for byte, and its keys are those its own metadata names, on its origin — jku, x5u, jwk and x5c in a token's header are never read, and kid only picks among them;
  • the algorithm is on the issuer's asymmetric allow-list (RS256, ES256, EdDSA): none and every HMAC algorithm are refused, so a public key can never verify as a shared secret, and a crit header is refused;
  • the typ is one the issuer's profile allows, and the token is an access token for this agent, never an ID token: its aud is the listener's resource and names no client, it carries its client and its scopes, and it has no nonce or at_hash;
  • the client is one the issuer lists, and an azp naming another refuses it; exp, nbf and iat hold with a minute's skew, and iat is within max_token_age;
  • it names a person — a uid, a sub that is not the client, or sub_profile: user — or, issued to a client alone, it is refused (or, with require_user_token: false, names an agent and never a user);
  • an act chain is recorded as attribution, and one deeper than max_chain_depth is refused, never cut.

A refusal is a 401 naming invalid_token, logged with the issuer and why and never with the token; a token whose scopes reach no skill the request asks for is a 403 naming insufficient_scope. The token is kept only as the caller's inbound session, for as long as it is good, and is never forwarded: no peer, MCP server, model or log is sent it. Its one way out is a same-domain exchange (subject: inbound) at the very issuer that issued it.

Principal ids are what work is owned by. A user or agent is <role>:<id> when its rule declares id, and otherwise <role>:cn=<CN> or <role>:san=<first SAN> from its certificate. The = is in neither the declared-id nor the device-name character set, so a certificate whose CN equals a declared id can never inherit that principal's work.

A rule that matches a certificate's subject or SAN needs a2a.tls.client_ca: without it no certificate is verified, and the load refuses the rule. An any rule needs no CA. A rule matches only a caller every field of its match admits: a bearer_ref beside a san admits the bearer only with a certificate the san matches.

Who a caller is, as the transport said. Beside its display id, every principal the listener names carries the tuple its transport established — how it was authenticated (x509, bearer, device, operator), by whom, and the stable id that transport proves: a certificate's SubjectPublicKeyInfo hash, a rule's id, a device session's name. Two callers that share a display string — every device approved under one name is user:<name>, and a certificate's CN can read like anybody — are two tuples, and every key a principal's credential is ever held under is derived from the tuple, never from the string.

Whose a run is comes from the transport. A run started by an A2A message is its authenticated sender's; one started by a principal's signal is that principal's; every other run — a schedule, a webhook, a stream, a loop — is the autonomous actor's (identity.autonomous_as) — except a webhook whose route takes a person's token (auth.jwt), whose run is that person's. A principal key in a trigger's payload is what its sender wrote, and is never the run's authority.

The implicit operator

On a TCP listener bound to loopback, with no a2a.bearer, no principals, no client_ca and no device grant, a local request that presents nothing and carries no Origin is the operator. Being on the machine is the authorization there, and the load says so with a warning. Where it stops:

  • Every local account is the operator, not only yours: plain curl from any uid on the host. On a multi-user host use a unix listener, where the kernel checks the uid, or set a credential.
  • It needs the bind, not merely the peer. On a wildcard or other bind, a connection from 127.0.0.1 grants nothing: a same-host reverse proxy relays every remote caller from there.
  • A browser is never the implicit operator. A request carrying Origin, any value, Origin: null included, is refused 401 with a body saying browser requests must authenticate. Any page on any site can make the operator's browser POST to 127.0.0.1.
  • An any rule can never carry the operator role. The load refuses it.

Warning. A same-host reverse proxy or sidecar that relays to a no-credential loopback listener and strips Origin makes every request it relays the operator. Do not front such a listener with a proxy. Bind a routable address with credentials, or configure a credential on the loopback listener.

Through the implicit operator, anything that reaches the port without a browser, including a co-tenant process or an SSRF from another service in the same network namespace, is a full operator. That includes flipping agent.approval to accept with admin.set, after which the agent's own unaddressed asks answer themselves. Addressed gates and policy asks survive that flip. Configure a credential on any host you do not fully own.

Rules and posture move together

The principal rules and the posture they imply (whether the implicit operator applies, which schemes the card declares) are one value. A reload that changes a2a.principals rebuilds and swaps it whole, so a request is never judged by one set of rules under another set's posture. A reload that adds the first rule to a no-credential loopback daemon ends the implicit operator at once. A reload whose rebuild fails keeps the rules and the posture in force. Narrowing or deleting a rule narrows work already in flight. a2a.bearer, a2a.listen, a2a.tls and a2a.device_grant are restart-only.

The operator floor

Every named role may call every core A2A method, on its own tasks; anonymous may call nothing. Beyond that a role reaches command operations, and the op table, published as the command extension specification, is the one the listener enforces. Its floor: an operator operation (admin.*, auth.*, config, debug.events and the reserved ops) answers to the operator role alone, before any grant is read. A user with grants: ["*"] reaches none of them, and a rule that grants an operator-only op to another role is refused at load.

Ownership

What a non-operator starts is its own, compared by principal id, never by the credential or session that presented it:

  • Tasks are visible to their owner and to operators. Anyone else's task is -32001 "task not found", the same answer as a task that does not exist, so ids cannot be probed.
  • Runs, subagents and conversations reached over A2A act only for their owner. An object nobody owns is the operator's.
  • The model path matches the listener. When a turn runs for a non-operator, the model's own workflow.cancel, workflow.signal and subagent.* tools meet the same ownership checks. A caller cannot reach through the model what it cannot reach through the listener.
  • contextId is the caller's own name. It is bound, per principal, to a fresh runtime key, so two callers who pick the same id hold two conversations, and neither can read or charge the other's.
  • Signals are scoped to their sender. A non-operator's signal wakes only runs it owns, starts only workflows it may run, and never ends an instance's lifecycle.until_signal wait.

Limit. store.retention.tasks.keep_last counts every principal's finished tasks together, so on a shared listener one caller's tasks can push out another's. A task that settled less than 30 seconds ago and has not been read is kept past the bound, so a blocking SendMessage always gets its answer. On a multi-tenant listener bound retention with ttl.

An instance-tier subagent reports to its parent over a parent peer. When the parent's listener is TCP and requires a credential (it is not in the implicit-operator posture), and the peer entry would carry no authorization header, the spawn is refused before anything is created and logged instance.spawn.refused, with the remedy: make a2a.bearer a secret reference, or use a unix listener. The alternative is a child that runs its work and then has every report refused.

Browsers

CORS admits exact origins. A request carrying Origin, to POST / or to any /oauth2/* endpoint, is served only when that origin is listed in a2a.cors.origins, compared as scheme, host and port. Origin: null never matches, * is refused at load, and a refused origin is 403 before the body is read. The one origin admitted unlisted is the UI that agentd ui launched, for that process only. Admission is not trust: an admitted page still authenticates.

The Agent Card is public. It answers every origin and holds nothing a stranger may not read.

Web credentials live in the tab. The web UI keeps its session token in sessionStorage, bound to the endpoint it was issued for, and the terminal UI keeps its token in memory. The agentd-ui server holds no credential.

Failed credentials: the source limiter

A presented credential that fails (a bearer or session token that names nobody, a certificate no rule gives a role) counts against its source: an IPv4 address, or an IPv6 /64. A source may fail 20 times, forgiven one every 3 seconds. The limiter throttles guesses, not callers:

  • Only failures count. An origin refusal is never counted (any web page could otherwise lock the local console out), nor is a request that presents nothing.
  • Past the limit, a source's bearers are refused 429 before they are checked. A limiter that still checked each guess would only turn a wrong guess's 401 into a 429 while a right one went through.
  • A live session token is still checked and served. A session token is 256 bits agentd minted, so answering "valid, or 429" gives a guesser nothing, and a trickle of junk bearers from 127.0.0.1 cannot keep the operator's console out.

The residual: a client presenting a configured bearer that shares a source with a failing one (behind one NAT, on one host, or behind one reverse proxy) is refused until the source drains. Refusals a stranger can provoke for free are logged once per source and reason per minute, with a count of what the window left out.

The device grant

With a2a.device_grant.enabled, the listener origin is an OAuth 2.0 authorization server for the RFC 8628 device flow, and a person signs a terminal or browser in without any credential of the daemon's being copied into it. Approving needs an operator credential, and the load refuses the grant without one, beside client_ca, and on a unix listener.

Brute force. The device code a client polls with and the session token it gets are each 256 random bits. The user code names a request to the approver and is never presented by the client, so guessing one buys nothing. Token polls are bounded per source, and a guessed session token is a bearer the source limiter throttles.

Remote phishing (an attacker starts a sign-in and talks an operator into approving its code) is the device flow's known weakness, and approval is built to make it hard:

  • approving is an operator command (auth.device.approve), never a page a link can take someone to;
  • auth.device.pending shows each waiting request's peer address, client_id, scope and age, so an operator approves only a code they watched a client they recognise display;
  • a session is a user unless the operator approves scope: operator and a2a.device_grant.scopes lists it; an approver may narrow a request, never widen it, and agent is never grantable;
  • a mistaken approval is ended by its session id, and revoking never deletes what the principal owns.

Per-source limits. A source may ask for 5 codes, then one every 12 seconds, and hold 4 waiting; one network holds at most 16 and the whole table 64. Behind a reverse proxy every caller is one source and shares those budgets.

Revocation reaches open work. A revoked or expired session's streams end and its blocking waits answer 401 within a tenth of a second.

Session tokens have their own prefix. They start with agentd_at_, and a bearer with that prefix is checked against the sessions and nothing else, so a configured a2a.bearer or bearer_ref secret that starts with it, or that equals another configured secret, is refused when the listener builds its rules. a2a.device_grant.rate bounds requests per signed-in name; operators are exempt.

Named identities. An approval names the person (user:<name>), and the operator vouches for the name. Sessions sharing a name are one principal by design, so approving a new person under a name already approved hands them its history; the approval answer says existing: true in that case. Names that spell other parties (operator, anonymous, unknown, launcher, runtime, system) are refused. The durable store keeps a registry of every declared rule id and approved name, so a device name and a user rule id never share a principal across restarts and rule removals, for as long as the store persists. There are no refresh tokens, and a restart revokes every session.

The launch grant

agentd tui and agentd ui run the daemon and one display client as one command, and sign the client in without handing it any credential the daemon was configured with.

  • What the launcher never passes: not a2a.bearer and not a resolved config secret, by argv, environment, file or HTTP. The client's environment is the launcher's minus every variable the config loader reads, every referenced {{secret:NAME}}, and AGENTD_BEARER.
  • The code is minted in the daemon's own process; no operation, route or configuration key mints one. It is 256 random bits, single-use, valid 60 seconds, bound to its client and to the launched UI's origin (or to the absence of Origin for the terminal UI), and redeemable only from a loopback peer. A presentation from an origin the listener does not admit is 403 and spends nothing; every other wrong presentation burns the code with the same 400, so a stolen code can at most be spent.
  • Delivery: to the terminal UI over an inherited pipe; to the web UI in a 0600 file in a fresh 0700 directory, opened with the desktop's opener, which receives the file path and never the URL. The code travels only in the URL fragment, which a browser never sends over HTTP, and the page strips it before any request. A later browser tab is approved by the person typing a short code at the launcher's terminal.
  • The session is the operator's, because the person who ran the launcher started this daemon from their own configuration. A web session lives 8 hours at most, a terminal session as long as the launcher.

Any local process can ask for a browser sign-in from the launched UI's origin, and at 16 waiting requests the oldest is retired, so a local flood can make the real tab ask again. It cannot get a request approved: approval is the person at the launcher's terminal typing the code the tab shows.

Residual risks, stated plainly: same-uid processes are equivalent to the user and could read the launch file or the pipe inside the 60-second window; the environment scrub covers resolved config secrets, not credentials other code reads (the implicit AWS_* chain); and a same-host proxy in front of a loopback listener makes remote requests look local, which the launcher cannot see.

The remote posture

Anywhere a listener is reachable off the machine, the posture is TLS plus the device grant, approved by an operator credential: an https:// bind with a certificate, a2a.bearer held by whoever approves, and every person signed in under a name they are accountable for. The load enforces the edges: a non-loopback bind needs a2a.bearer or a2a.tls.client_ca, plaintext is loopback-only, and a wildcard bind needs a2a.url. client_ca instead makes a client certificate mandatory for every caller, bearer-only and browser clients included, which is why the device grant refuses to load beside it.

The exec runner

agentd runs no local command by default. The exec tool exists as a contract, but a local runner exists only when the binary is built with the exec feature and security.exec.enabled is true. The release binaries and the published image do not include the feature. Otherwise exec is a mapping-only contract: map it onto an MCP server with tools.overrides and the command runs in that server's sandbox instead.

Watch the tags when you do. The built-in runner contributes sensitive and egress to the Rule-of-Two fold. Mapped onto a server, exec takes that server's tags, and an untagged server files it as untrusted_input.

The runner's guards, each re-checked from live settings on every call, are listed below: argv without a shell, an exact allow-list on argv[0] where an empty list runs nothing, a mandatory working directory that a requested cwd must resolve inside, a timeout a request can shorten but never extend, an output cap, and an environment rebuilt from a named list. Every run logs exec.run with the command, argument count, directory, timeout and caller, never the output.

Warning. The guard is argv, not shell. Allow-listing bash, sh or any interpreter reinstates the whole injection surface. A misconfiguration (no allow, no workdir) surfaces as an error result at the first call, not at startup.

The local runner and skills from an MCP server do not load together: security.exec.enabled with any skills.sources refuses the configuration. A server's skill may tell the model to run a command, and agentd has no per-skill approval to put between that text and the host.

Beside a delegation the runner loads only under the whole isolation set — a worker uid of its own, Landlock, every supervisor-only path closed to that uid — and a command from work holding a person's delegated data asks them first (Acting for an enrolled person).

Secrets

A secret appears in configuration only as a reference:

  • {{secret:NAME}} reads the environment variable NAME, or the value entered at startup under --prompt-missing;
  • {{secret-file:PATH}} reads a mounted file, and strips exactly one trailing newline (or CRLF), because kubelet projects a Secret verbatim and editors append one.

Both resolve at the instant of use, so a rotated file is picked up without a restart. A reference that does not resolve is exit 2 at load, naming the reference and never a value. An unknown {{…}} token is an error, not a pass-through, so a typo cannot put braces on the wire. The type that holds a secret prints *** when formatted, so a credential cannot reach a log line or a panic message that way, and --effective-config prints <redacted> for every credential. A subagent's durable record keeps only a model reference, never a token, and the connection is resolved again from live settings when the subagent is restored.

Validation refuses an inline credential in three shapes:

  • Paths that must be references in a config file: intelligence.token, each intelligence.providers.<name>.token, a2a.bearer, security.aauth.enroll_token and each MCP server's oauth.client_secret. Set from the environment or a flag, these may be inline.
  • Every auth: block's token, value and client_secret, on a model connection, a provider, an MCP server, a service entry or an A2A peer.
  • Header keys that look like credentials (authorization, api-key, x-api-key, token, password, secret, or a name ending in -token, _token, -key or _key) with an inline value, across model connection and provider headers, MCP server and service headers, A2A peer headers, the HTTP store's headers and workflow reference headers.

Limit. The header check reads the key name, not the value: a bearer pasted into headers.X-Session passes. Webhook route secrets (auth.bearer, auth.header.equals, auth.hmac.secret) and a principal's match.bearer_ref are not checked at all. Write every credential as a reference; outside the shapes above nothing catches an inline value.

Outbound credential providers (OAuth, AWS SigV4, SPIFFE) are in Outbound credentials.

What the supervisor holds stays in it. The supervisor is non-dumpable (PR_SET_DUMPABLE 0): no process of its uid can attach to it or read its memory or environment through /proc/<pid>/. Its core-dump limit is zero, and every child inherits that, so no core file holds a secret. A proc.harden warning names either that the kernel refused.

A child's environment is an allow-list. Every child — turn worker, subagent, think child, instance — is started with these variables, each only when the supervisor has it:

  • the baseline: PATH, HOME, TMPDIR, TZ, LANG, AGENTD_CRED_DIR, RUST_LOG, NO_PROXY, and every XDG_*, LC_*, SSL_CERT_*, OTEL_* and *_PROXY;
  • the variables its own spawn names: each {{secret:NAME}} its connections reference (the model's, the MCP servers', the A2A peers'; an instance's composed configuration), AWS_* with the aws sources' endpoint overrides when an aws credential is routed to it, and AGENTD_INTELLIGENCE_TOKEN, _FILE and _<N>/_<N>_FILE, which a worker reads itself when it dials the top-level model connection's endpoints; an instance also gets the intelligence family's configuration variables;
  • the names and PREFIX* patterns in spawn.env_passthrough.

Text in a child's conversation (an instruction, a message, a tool result, a step's output) never adds a variable, even when it spells {{secret:NAME}}: which variables a child holds is the configuration's to say.

No other variable crosses, and a variable a supervisor-only secret is read from never does: the {{secret:NAME}} of security.companies.seal_key, and every key and secret under identity.* or on a delegated server. A passthrough that names one is refused at load.

A child cannot open the vault or a key file. Where the configuration holds a supervisor-only key — identity.providers, a delegated server, or security.companies.seal_key — every child, and every exec command, is started in a Landlock domain that denies it the file store's root (where the grant vault and its register are), every {{secret-file:…}} such a key is read from, and the audit chain with its closed segments. With only observability.audit.chain set, the chain is denied. The child enters the domain before it runs any code of its own, and cannot leave it; one that cannot enter it is not started. An instance-tier child keeps its own directory, which a durable parent keeps under its store's root, and nothing else of the root. A key file or the chain kept inside the store's root, as /var/lib/agentd/audit/audit.log with the store at /var/lib/agentd, leaves the whole root denied. Under key_custody: external the signer's socket is denied too: a child can still connect to it, and can neither remove it nor bind another in its place. proc.landlock at start names the kernel's Landlock ABI and the paths denied.

Landlock ABIWhat a child is denied in a protected path
1 (Linux 5.13)reading, writing, running, creating and removing — and, anywhere, renaming or linking a file into another directory
2 (5.19)linking and renaming into or out of one
3 (6.2)truncating a file by path
5 (6.10)device ioctls

A protected path's names can be listed; its contents cannot be opened. A child cannot create or remove an entry directly in a directory on the way to a protected path, so keep the store, the key files, the signer's socket and the audit chain in directories of their own, not in /tmp or a home directory a tool writes to. On a kernel without Landlock (older than 5.13, built without it, or a seccomp profile that refuses the calls), proc.landlock.unavailable says so at start and children run unconfined: a same-uid child can then read those paths.

A per-person credential is presented for its person alone. AAuth's user-scoped (Case C) tokens are held for the authority and the principal the request was made for. The principal travels with each MCP request as its scope, set from the work's authority and never from anything a caller wrote; the transport hands it to the signer and never sends it. One principal's token is never presented on another's request, a request made for nobody presents none and earns none, and a child is handed only the tokens of whom it works for.

Transport and process isolation

MCP and model endpoints are HTTPS-only; plaintext http:// is allowed for loopback hosts alone, and anything else is exit 2 before any side effect. security.tls_ca adds a CA to the bundled trust roots for every outbound TLS dial, in the daemon and its children. A non-loopback A2A or webhook listener must authenticate and serve TLS.

Apart from the exec runner, and the display client and desktop opener that agentd tui and agentd ui start, every process agentd launches is a re-exec of its own binary. A child's work arrives as a serialized control frame on its stdin: data for a model loop, never argv for a shell. Each child gets its own process group so the kill ladder can reach the subtree, an optional cgroup leaf, and a parent-death signal so a supervisor death takes it down. Every descriptor beyond stdio is close-on-exec in a child, so no process the daemon spawns inherits the operator's terminal or a listener socket. Supervision and containment covers the process tree.

Inbound webhooks

A webhook route authenticates per route, by HMAC-SHA256 over the raw body, a header match or a bearer, compared in constant time — or by a person's access token (auth.jwt). A non-loopback webhooks.listen refuses to start while any route is unauthenticated. Once a request is in, more rules hold:

  • A person's token starts a run as them, once. auth.jwt {issuer, audience, max_lifetime} takes a token from one of a2a.inbound_tokens' issuers, checked as the listener checks one, for the webhook's own audience — never one an A2A token is issued for, so a token for the listener starts nothing here — living no longer than max_lifetime (5 minutes by default, 15 at most), and carrying a jti never presented before — a durable marker of it answers a replay after a restart as a duplicate, whatever idempotency key it carries. The person is the one the listener's own a2a.principals rules name: a rule naming the issuer that admits nobody the token names admits no delivery either. The run is theirs, acting through a webhook session of its own, apart from the session their A2A calls keep. Only the operator's own configuration declares such a route: a workflow a served document defines, or one workflow.create/update stored, takes no auth.jwt, nor webhooks.default_auth.jwt for a node that names no auth of its own — a document's is refused at load, a stored one is not loaded.

  • Replay markers are agentd's, not the agent's. A delivery's idempotency key is recorded under a _-prefixed key in the instance's memory, and the memory.* tools refuse every _-prefixed key, so a model steered by an injected payload can neither forge a marker nor delete one. A marker is written only after the delivery is kept, and answers replays for seven days. Under store.on_error: degrade a failed store write is reported as made, so "kept" is only as true as the store.

  • A webhook wait outlives a restart without its secret at rest. Its durable record keeps the route's auth as written, as references, and boot resolves them again.

  • /hooks/_ is reserved for callbacks agentd arms itself. A configured route under the prefix could take a suspended run's callback, so one is refused when the workflow is parsed.

  • MCP Events deliveries verify the server. Each request to a binding's route is checked against the Standard Webhooks signature before its body is parsed: all headers, a timestamp within 5 minutes, HMAC-SHA256 under a secret minted per subscription and held in memory only. The server's verification challenge is echoed only under a signature that verifies, so the listener is no oracle for someone subscribing a URL that is not theirs. A body over 256 KiB is refused on its declared length.

  • A served document opens a route only under the interface grant. A webhook start or a wait {on: webhook} in a document's workflows opens a route on the operator's listener, so it needs interface in agent.document_capabilities, in every spelling, including definitions the document references. workflow.create and workflow.update refuse a definition that opens a route under the same rule. The grant is restart-only. The same workflow in the operator's own configuration needs no grant. Instruction documents explains the grant model.

SSRF defences

The SSRF guard covers the outbound surfaces where a URL can come from something other than the operator's own endpoint list:

  • the workflow http step, the one place a URL can be model- or graph-derived;

  • stream forward webhook targets;

  • workflow definitions fetched from a url:;

  • A2A push targets a caller registers, vetted at registration and again at delivery, because DNS can change in between;

  • the AAuth Person Server exchange, where the URL the Person Server returns is vetted.

  • an authorization server an MCP server's RFC 9728 metadata names off the server's own origin, and the endpoints that server's metadata names: HTTPS only, and under security.egress: closed only inside the catalog.

Model, MCP, configured A2A peer and operator-configured OAuth traffic is not classified, because those endpoints come from operator configuration. A model that can influence any of those URLs is outside the guard.

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 again, and an attacker holding the DNS answers public to the check and 169.254.169.254 to the connect. agentd resolves once, classifies every address it got back, refuses if any is non-global, and connects to an address it vetted, checking it again immediately before the connect. TLS and the Host header stay on the hostname, so certificate validation is unaffected. IPv4-mapped and IPv4-compatible IPv6 forms are classified as the IPv4 address they carry. Header names and values containing CR or LF are refused before any byte is written. The blocked ranges are in the reference section.

allow_private is an off switch, not a "permit RFC 1918" switch. It removes the classifier for that surface: per http step, per forward target, per workflow reference, and a2a.push.allow_private for push targets. Review it in a diff the way you review a credential.

No redirects. The HTTP client follows none, so a redirect comes back as a plain response and the redirect pivot does not exist for the http step. Two document fetches, both from an operator-named reference at load, follow redirects: the url: instruction source, up to three hops, each held to HTTPS (plain http:// only to a loopback host, never after https://) and to security.egress; and the OCI blob fetch, which follows a registry's CDN.

Where the instruction came from

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

  • Who wrote it: agent.instruction.trust. An author signature travels inside the document, as a front-matter signature: line. With a publisher pinned, agentd verifies it after decryption and before anything interprets the bytes, the one point every source converges on. An unsigned document is refused, as is one signed by another publisher or naming a document id no pin covers. A folder is verified per file. The signature covers the document's end matter (owners, approvals, review dates), so editing it breaks the signature.
  • Who delivered it: a pinned reader. A registry can resolve a document for one reader and sign that delivery. With reader set, agentd verifies the delivery attestation on every registry read: the audience, the expiry, the delivered digest, and the chain from the resolution manifest to the author signature.
  • Who pushed it: oci: {ref, cosign_key}. For an OCI artifact, agentd verifies the cosign signature beside it against the configured key and checks that the signed payload names this manifest digest.

None substitutes for another. A registry compromise can serve a genuinely authored document from the wrong place; a stolen push credential can publish an artifact nobody authored. For an OCI source, pinning the reference by digest (@sha256:…) removes the mutable-tag question and costs less than any of the three checks.

Signatures cap, never widen. What a document may activate is the operator's grant (agent.document_capabilities), intersected with the pin's max_capabilities and with what the author attested, and with what the delivery attested when a reader is pinned. A folder is capped by the narrowest of its files.

A build without the sign feature cannot check a signature, so a configured pin is a startup refusal there rather than a silent pass. The release binaries include sign. Under security.egress: closed, the url: and oci: fetch must match a catalog entry before agentd dials it. Write and ship instruction documents shows how to sign, pin and encrypt documents.

Skills

A skill is instructions the model reads. Who wrote it decides how agentd treats it.

  • Your own skills — a folder's <name>/SKILL.md, the instruction's :::!skill — are configuration. A folder skill that breaks an Agent Skills rule refuses the configuration, and .agents/skills/ is read only beside a configuration file agentd loaded, never from the working directory alone.
  • A server's skills are that server's text. agentd shows a body to the model inside a block that names the server and the URI and says the text is the server's, not yours, and nothing the server wrote leaves it: the catalogue gives each skill one line, the server and URI are escaped, a <skill_content or </skill_content in the body is made inert, and a URI holding a space, a line break, a quote or an angle bracket is declined. The server counts as untrusted_input in the Rule-of-Two fold, and a skill of it loaded or read — by skills.load or skills.read, a reference the instruction, a message or preflight makes, or a step's skills: — taints the conversation, run or subagent it reaches, whatever the server is tagged.
  • Removing a source takes its skills back. A reload that removes a skills source, or narrows what it admits, unloads that server's skills from every context (skills.unloaded), and skills.read reads only from a server that is a source.

For every skill, from any origin:

  • It grants nothing. allowed-tools and every frontmatter field beyond name, description, license, compatibility and metadata are ignored. A skill never adds a tool, a server or a permission.
  • Nothing in it runs. scripts/ are files: skills.read returns their text.
  • A name is not an identity. A server's skill is known by its server and its URI. A name two origins hold resolves to neither: a reference names the origin, and the shared name is logged as skills.collision.

What a server's skill is checked for, before any of it is used:

CheckRefuses
The entry, at listinga missing or malformed resources, a file outside the skill, a uri in characters a URI cannot carry unencoded, a name the Agent Skills rules refuse, more than 512 files or 16 MiB, dynamic content without dynamic: allow
Each file, when it is readbytes whose size or SHA-256 differ from the entry's, after one skills/get refresh; a file the entry it was loaded under does not list
SKILL.md's frontmatterany field that differs from the entry's
Where a file is read fromanything but the skill's own server: skills.read follows the skill's origin, never the model's choice of server

A matching digest proves the entry and the bytes agree, not that either is trustworthy: the same server supplies both. The checks stop a stale or altered file from passing for the one listed, and keep one server's skill from reading another's; they do not make a server's text safe to obey. Server skill content is held in memory only, never written to disk.

Companies

With the companies feature agentd acts at Companies for one person under the Personal Agent Protocol draft (RFC 0047). What holds:

  • Custody. The client key, the DPoP keys and every Session and Bearer token live in the supervisor. No turn worker, subagent or instance child is handed a Company server to dial; their calls to a Company's tools come back to the supervisor.
  • Account Tokens are kept sealed. The Account Token a sign-in gives agentd is stored only sealed with AES-256-GCM under security.companies.seal_key, which load requires of any sign_in, bound to the client_id, the issuer, the User and the label of the account it is for, under an id the store cannot map to a Company or an account. The supervisor reads it for each request that uses it and holds it for that request alone; it is sent only to the Company's token and revocation endpoints, in the form body, never in a URL, a log line, a model's context or a child's payload. A rotated one is kept before the response that carried it is used, under the Session's lock, which a sign-in or a sign-out takes too: a renewal in flight never writes an old account's token over a new sign-in's, nor leaves a rotated one good after a sign-out. Only invalid_grant forgets it — an invalid_token or sign_in_required renews with it — and signing out revokes it. One that grants none of the entry's scopes, or held where the entry no longer lists sign_in, is not used: requests there are signed out.
  • Only the User signs in. A sign-in is the User's own: the gate is addressed to them, and agentd never opens the Company's page or drives a browser to it. Device Sign-In's verification_uri must be an HTTPS URL with a plain host on the entry's hosts, in printable ASCII, or no gate opens — a Company cannot send the User to another Company's page with a code it started there. A plain host has no userinfo and no \, which a browser reads as /: https://other.example\@a.example/ is at other.example there, so no URL whose host two readers could disagree on is dialled or shown. The refusal names the field and the host, never the link, whose query may carry the code. Direct Sign-In's state is single-use and bound to the sign-in, and a callback whose iss is not the Company's issuer byte for byte is refused, its code never exchanged. The device code, the PKCE verifier and the state are in supervisor memory only.
  • Company content is untrusted input. A company entry's tags default to untrusted_input and egress, and fold into check 1 like any server's — its MCP servers', and the company.* tools' that reach its agent and its APIs, which carry the same tags: an instance that declares a Company and a sensitive server does not start without security.allow_trifecta. Split them across two instances linked over A2A instead. The stream a Company's conversations feed, company.<name>, carries its tags in the stream-taint check, and every event on it carries the Company's origin.
  • One Company never acts for another. A Company call's answer adds the Company's origin, company:<issuer>, to the record of the conversation, run or subagent it reaches — the outside-input record, handed on to what that work starts, spawns, signals, steers or messages, and to whatever reads its result back. A Company's error counts as its answer does. A call to another Company from work holding one waits for the User (Company gates), before any policy is consulted.
  • No correlators under privacy: strict, the default: a Company is sent its own keyed idempotency key and its own trace, no instance, run, context, principal or label, no clientInfo.title, no AAuth signature, and no trace context on the handshake, the tool list or a conversation reader's requests.
  • No call sent twice blind. A tools/call that failed after it was sent — no answer, a 5xx, a result agentd could not read — answers outcome unknown and is not repeated, by a step's retry: or by a restart that finds its step still running; the handshake, tool lists and token requests are repeated at most three times. A conversation message is sent again only with the id it was first sent with, and an API write only when its operation declares Idempotency-Key, with the key it was first sent with; any other API write that may have run is outcome unknown. A workflow step's call keeps its id and key across the step's attempts and a restart's replay.
  • What a Company's API takes is what its description says. company.api and the http step with auth: {kind: company} send an operation only when the Company's OpenAPI description has it and api_allow lists it, its parameters and body held to the operation's schemas; anything else is refused unsent, and so is a path segment a server may resolve to another operation (., .., or one holding / or \ once decoded). What is sent is the operation's own template, its parameters percent-encoded into it. An operation's $refs are resolved when it is called, to at most 20 000 values, so a description whose references multiply costs one operation at a time. Every API and conversation request carries the User's DPoP Session Token with a proof of its own, and no correlator of agentd's.
  • An operation tool is company.api under the operator's name. Each operation api_allow lists that the description declares is a tool of its own, served by the supervisor as a Company's MCP tools are; its call is company.api's call of that operation, sent by the same code under every rule above, and it reaches that operation or none: a path parameter's value that would make the path another operation's is refused unsent. Every control on company.api holds it too — a security.policies rule judges its call as company.api's as well as its own, and tools.disabled, tools.narrow and agent.tools for company.api apply to it — so the tools add no way around a rule written before they existed. Its name is made from the api_allow entry, so the Company cannot choose what a tool is called or rename one a policy or a tag is keyed to; a tag key that names it narrows its tags, and one that does not leaves it every tag the entry lists. Its description and input schema reach the model and are the Company's words: the description starts with the note that says so, quotes the Company's operationId, summary and description with their control characters removed and capped, and the schema is the operation's own, resolved within the bounds above, at most 16 KiB, and closed to any argument it does not declare. No header or cookie is ever an argument.
  • Conversations are bounded and the supervisor's. At most limits.conversations are open, each read by a thread of the supervisor's, which never blocks its loop and stops when the conversation closes or the instance drains. A Company's text-delta pieces are never read, and an authorization event never opens a gate by itself. A user_requested event opens one question for the User per conversation at a time, its text agentd's, and only in unowned work that may gate: a Company cannot prompt the User at will. How far a call got is the transport's word, not the error's text, which the Company writes. A Retry-After on a 429 or 503 holds every request to the Company until it is over.
  • Declared browsers stay off Companies' sites. A call on a server security.companies.browsers names is refused when any string argument names a Company's host as a browser would read it, or a host that is not ASCII.
  • One Session per account. Each of the User's accounts at a Company has its own Account Token and its own Session, with a DPoP key of its own; a request is made in the Session of the account it names, a conversation or an operation in the one it belongs to, and none falls back to another account's. The account's label is agentd's own word: no request sent to a Company carries it.
  • A reset is a new User. company.user_id.reset revokes and forgets the Account Token of every account the User holds there, and drops the Sessions and the Company's MCP connections with the User ID, so nothing the Company tied to the old User — a token, an MCP session — reaches it again.
  • Only the User approves an operation. With the Operations extension listed, an action a Company proposes is confirmed only with the User's approve of the revision confirmed, at their own gate — never a judge's, a model's, a message's or an operator's who is not the User — or under a standing permission the operator declared in the configuration, which is restart-only, never written by a document, a tool or a workflow, and never applies where the Company requires the User's own approval. The model passes no revision and no approved_by: agentd reads the operation again and sends what it holds. The User's approval covers the revision the gate showed only while the Company says the same of it — other terms under the same revision ask again — and an answer is one message: one sent again answers no later question. A standing permission covers one Company, one channel and every term key of the revision, is asked again at each confirm, and its limited uses are reserved with the confirm's lock and spent only by a confirmation the Company recorded under it, so two calls racing for the last one confirm once and a confirm the Company did not take spends none. One confirm of an operation is in flight at a time (its lock, kept in the ledger), and a confirm that got no answer is read until the operation is final; until then the request that proposed it is refused unsent, and an operation of the same terms is not confirmed.
  • Only the User's words are human. A Direct Conversation starts only with the User's answer at their gate, and every message agentd sends there is a message the User wrote on its task, as they wrote it — sent once, however often their client sends it. The model's messages are agent, it cannot write to a Direct Conversation, and a Company asking there to talk with the User is answered with nothing.

What does not:

  • The origin record does not follow content into memory or artifacts: Company content written there and read later reaches another Company unasked.
  • Under the default tags nothing stops a Company's answer leaving through a non-Company egress tool (mail, a webhook): a Company channel is not sensitive, so the Rule of Two does not see that flow. Add sensitive to the entry for a Company whose data is.
  • A browser that follows a link onto a Company's site, or one not declared in browsers, is not caught. A browser call to a host that is not ASCII (an IDNA name not in its xn-- form) is refused whatever it names.
  • A step's call cut off by a crash is ended, not replayed, only when the step made it; a subagent's call to a Company is not on its step's record, and a step that spawned the subagent replays as any other.
  • The client_id of a self-hosted single-user agent identifies its person to every Company, whatever the User IDs say, and every Company sees the same network address.
  • A thief who holds the store, the seal key and the client key is this agent to every Company the User signed in at. Keep the three apart.
  • A sign-out whose revocation fails forgets the Account Token here, and it stays good at the Company until it expires (company.signed_out says so).
  • approved_by works on trust: the Company cannot see the User's gate, and agentd's audit records are agentd's account of who approved what, not proof the Company can check.
  • The same action proposed again with other arguments and other terms is not recognised as the same: the draft gives an operation no kind of action, and a standing permission binds to the channel that proposed it for the same reason. Nor does the draft name the account a Session is signed in to, so a standing permission covers the accounts its labels named when it was declared — without accounts, the one labelled default alone. A label is held to its account by its Session: a sign-in to it again goes there, where the Company takes only the same account. A sign-in that goes in a new Session may be to another account — one with new_account, or one under a label whose account was signed out of, reset or lost with its Session — and suspends the permissions covering the label until a restart, which takes the account the label then holds as the one meant.
  • The User ID is one per User per Company, so a Company can tell that the accounts the User holds there are one person's.

Acting for an enrolled person

With the xaa feature agentd acts for each enrolled person at MCP servers through their enterprise IdP: ID-JAG and MCP's Enterprise-Managed Authorization (RFC 0044, Enterprise-managed authorization). What holds:

  • Supervisor-only credentials. The person's refresh token, ID tokens, ID-JAGs and access tokens, and the IdP and authorization-server client keys, live only in the supervisor. No turn worker, subagent or instance is handed a delegated server to dial — mcp::from_spec refuses one — and their calls come back to the supervisor. What a delegated call returns, and every refusal of one, reaches them, the log and the audit record with every live token value replaced by [redacted]: the value itself, its base64 and percent-encoded forms, and a long prefix of it. A key or seal key under identity.* or on a delegated server is read from a mounted file or the startup prompt — --validate-config refuses one that would resolve from the environment (delegation_secret_in_environment) — and no child is started with the variables such a secret names. No child opens a mounted key file or the vault where the kernel has Landlock (above). A delegation several people can hold grants under is refused on a kernel without Landlock unless workers run as spawn.uid with every supervisor-only path closed to that uid. security.exec.enabled beside a delegated server, identity.providers or a seal key — a command is model-chosen — loads only under the whole isolation set: spawn.uid and spawn.gid set, Landlock active, and the store's root, every key file, the audit chain and its segments and each signer's socket closed to the worker uid by the mode and ownership of each path and every directory above it — none owned by that uid, none carrying a POSIX ACL, none above it writable by it without the sticky bit; the store's root is judged as agentd restricts it at every start (0700) only where agentd's own uid owns it, and as it is otherwise. The load names each piece missing. A command from work holding a person's delegated data asks them first, tainted or not (exec_holding_delegated_data, below).

  • Keys agentd never holds. Under identity.delegation.key_custody: external an external signer holds the IdP and resource client keys and the key-encryption key, on a unix socket whose peer must be the uid configured. agentd builds each client assertion and has the signer sign it; each grant's refresh token is sealed under a data key of its own, which the signer wraps. The signer signs only what a client assertion is, for the audiences and key ids it is configured with, and wraps only under its key-encryption keys, so a process that reaches its socket gets no signature of anything else. With the signer down, a delegated call fails signer_unavailable before anything is sent (The external signer protocol).

  • Authority from the transport. A person is who the transport says: the unix listener's peer the kernel names by uid, on a rule with an exact idp_subject pin; a client certificate whose key a rule admits alone (match.spki), with an idp_subject pin — its grant held to that key; a session a display client was issued by signing in at the IdP (a2a.login), the person their IdP names; an enterprise access token a front end presents (a2a.inbound_tokens), the person it names, whose issuer is the provider's or one of its equivalent_issuers; or a device session whose name an operator bound to one person (a pin, or — opted in — its first enrolment). A template admits each by that source. A run's authority is set by the transport that started it, and a session's work acts through that session as it is at each call: a model, a tool argument, a document or a payload never names either. Work for anyone else — autonomous work, another operator, a bearer-named operator on a TCP listener — is no_delegation at a delegated server, which also never offers it its tools; identity.autonomous_as may not name a person. A subagent's work — its own calls, a run its call started, directly or through other runs — is delegation_not_permitted, unless the person's template says subagents: inherit: then it acts with the person's grant at the servers the subagent itself declared, and still holds no token. A per-person operator's rule is the one the process read at start: a reload that changes it is refused. Nothing falls back to agentd's own credential.

  • Each person's grant is theirs. A grant is keyed by the person's typed principal at the IdP's issuer — never a display string — so two people, or one person at two IdPs, never share one: each mints with their own refresh token, and no access token, ID-JAG cache entry or MCP connection is pooled across them. A device name's grant is held, at every call, to the identity the calling session itself proved at the IdP: a second device approved under the same name, one that proved someone else, or one that never signed in is not_enrolled, and a sign-in as someone else is binding_mismatch and leaves the grant alone. A changed or removed pin, or first binding turned off, revokes what it bound. A session's end — revoked, expired, or lost to a restart — ends what it delegated at once: its work's next delegated call fails revoked, and a gate it waits on closes revoked, the call never made. A revoked grant's calls fail revoked too, for as long as the tombstone register keeps the revocation; a person revokes their own grant from their own session (delegation.revoke) — the grants that session holds, so a device session under the name that never signed in revokes nothing — and an operator anyone's. Every authorization-code sign-in — a display client's login, a device session's enrolment, a step-up — goes through a one-time start link on the listener origin that sets a cookie bound to it, and the callback is taken only with that cookie: an IdP URL forwarded to someone else fails there.

  • The grant is sealed and bound. The refresh token is kept sealed with AES-256-GCM under identity.delegation.seal_key, the associated data binding the grant id, the IdP, the subject, the epoch and the refresh-token version. A tombstone register, written only upward, refuses a revoked epoch and a record older than the latest refresh token — a store restored from a backup — so a rotated-out token is never presented. With observability.audit.chain set, each raise of the register is anchored in the hash-chained audit log first, and a load is held to the higher of the two: a store restored with its register, or a register a writer of the store truncated, does not bring a revoked grant back. Without the chain, a writer of the store can truncate the register too. A rotation is kept before the ID-JAG it came with is checked. Signing in again replaces a grant that is no longer the same — expired, another identity, another template or ceilings — one epoch up, and the refresh token a sign-in replaces is revoked at the IdP.

  • Pins bound every mint. The audience and resource of every exchange come from the configuration; discovery may confirm, never supply them, and the server's RFC 9728 metadata must confirm both before anything is minted for it. The scopes are the configured ones within the template's ceiling as it is now, then whatever the IdP narrows them to. An ID-JAG for another authorization server, client, issuer, subject or resource, one that grants a scope not asked for, or one that names no resource, is refused and never redeemed; under a provider's subjects: pairwise its sub is recorded, not compared. A server is told the ID-JAG's own sub (agent/acting_for) and a random handle per grant and server (agent/grant), never the person's principal or the grant's id, so two servers share no handle to correlate. Mints are budgeted per person and resource per day, month and minute (budget.per_minute, every mint asked for counted) and per run.

  • The same-domain exchange stays in its domain. A server behind the person's IdP (grant: token_exchange) gets a token exchanged on their behalf at that IdP, agentd authenticating as the provider's own client: the grant's access token (subject: grant), or the caller's own token (subject: inbound), which goes to the issuer that issued it and nowhere else and lives no longer there. The result is held to the pinned audience and the person, no scope beyond those asked, and a refresh token in it is dropped unread. A caller's token the IdP will not exchange, or one that has expired, is subject_expired.

  • A sign-in is asked for out of band, and authorizes one thing. A task that waits on a person's sign-in — at admission, where a caller's token names someone a template admits but who holds no grant, or at a step-up — waits in auth-required with a single-use start link on the listener origin. The sign-in is bound to the person the task is for, no message answers it, and what it obtains authorizes the work it resumes alone — an admission's, the run it held; a step-up's, the one call it was asked for, never another to the same server: nothing reaches the caller. Cancelling the task ends its admission sign-in, so a sign-in finished after starts nothing. Offers are paced per person; a delegated call that finds no grant never asks.

  • Each use is authorised. A delegated call is admitted at every use — a pooled token skips none of it: a turn uses a delegated tool only where its plan offered it (a step's tools: and servers: narrow it), the tool's scopes must be within the ceiling, and the operator's security.policies and the person's consent rules hold the call.

  • Freshness. A delegated success is a live exchange, or a token agentd minted within at_cache_cap for a grant whose refresh token was proved alive within liveness_interval. An IdP that stops answering stops delegated calls within that interval; a refresh token the IdP revoked is noticed at the next probe or mint, and the grant is revoked, tombstoned and purged.

  • Taint is tracked. Work — a run, a step, a turn's conversation, a subagent — is tainted once outside text reached it: a delegated server's result (unless the operator sets auth.trusted_output: true on it), an untrusted_input tool's result — through a model's call, a tool step or a built-in search.* step — a webhook's or a stream's events, a non-operator principal's A2A message or a per-person operator's (each but to work acting with their own grant, below), an A2A peer's answer, an http step's response, an mcp.tool or mcp.resource result, or the resource a wait {on: resource} reads, from a server whose tags name untrusted_input (whether or not it serves a tool), knowledge context or a knowledge.* step's result, a skill an MCP server serves (whatever the server is tagged), a served instruction, or a memory entry or an artifact tainted work wrote. A server that declares no tags counts as untrusted_input here as in the Rule of Two: what it answers taints the same ways, until the operator tags it — with its legs, or trusted (Tool tags). Tools an embedder registers in code (the reserved server code) are its own program and taint nothing; a delegated server must carry tags and is judged as above; a Company's servers carry its entry's tags, or untrusted_input and egress when it declares none. It holds delegated data once a delegated server's result reached it, trusted or not. Both are recorded before the text is read, durably, and carried wherever the work's text goes: into what it starts, spawns, signals or steers, the messages it sends (message.send, a message step), the events it emits, the memory and the artifacts it writes, a plan item's note, and the results read back from it. A step's cached output (cache) hands the run it is given to what the run that produced it carried; a run that holds delegated data caches nothing — the cache is every run's, keyed by no principal, and a hit would hand the person's data past the admission and the revocation every read is held to (step.cache.not_kept). security.policies match them as tainted and holds_delegated_data, beside delegated and any_tags; a single run's workflow.status shows them under taint.

  • A person's own message is theirs. A message, a signal or a start a person sends through a session that proves them the holder of their grant — the session's principal is the grant's, and a device session has itself signed in at the IdP as the identity its name is bound to — or a per-person operator sends over the unix listener, is recorded as theirs: it does not taint work that acts with that person's grant. It is outside text to everything else — another person's work it reaches through an event or a message, work for no grant, a subagent that does not inherit the grant — and a tool's result, a fetched page, a served document and anyone else's text taint the person's work as before. A message through a device session that has not proved the binding taints as anyone's.

  • Consent from the person. After every rule of the operator's — whose allow decides the operator's list alone — each call is held to the person's consent rules, which agentd ships, in this order: any egress call, delegated or under agentd's own credential, from work that is tainted and holds delegated data (egress_from_tainted_work_holding_delegated_data); a delegated sensitive or egress call from tainted work (delegated_call_from_tainted_work); a local command (exec) from work holding delegated data (exec_holding_delegated_data); a delegated egress call from a workflow an instruction document defined that the person's template does not list for the server by name and definition digest (document_sourced_delegated_egress); and a delegated egress call at its first use per run — or conversation, or subagent — and tool (delegated_egress), the person's approval passing later calls of the tool there until a restart. A workflow step that sends the run's data out by its own effect (http, a2a.send, a2a.delegate, a forwarded emit, an mcp.tool or mcp.resource step at an egress server) meets them as an egress call; an approved step runs with the spec the person saw. An operator adds rules of their own with to: principal — on any matcher, the facts unattended, document_sourced and mode among them, and once_per: run_tool — whose gate is the person's in the same way. Where a delegation is declared every egress tool comes back to the supervisor, so no worker dials one past a rule. A subagent a document's workflow spawns is that workflow's work, and asks as it does. While the instruction is frozen — its source not confirmed current before its trust freshness deadline — no model turn or subagent, no run a model asked for (workflow.run, a workflow tool, a signal that starts or wakes it, and the runs those start) and no run of a workflow the document defined makes a delegated call, and no model writes a workflow. Only the person answers, bound to the gate's nonce and argument hash — over the unix listener, over the connection their certificate authenticates, or from a display session whose own IdP sign-in is younger than identity.delegation.consent_session_max_age; an operator answers too only where the person's template lists approvers: [principal, operator]. Gates of one shape — rule, server, tool and argument names alike — may be answered together, each still bound to its own nonce and hash, and each recorded; at most identity.delegation.consent_per_minute gates open for one person a minute, and a call past that is refused rather than asked (delegation.consent.rate_limited). Anyone else is refused, a message on the task the gate holds is refused, and each is recorded (delegation.consent.refused); no judge answers whatever agent.ask_human_fallback says, and an unanswered gate denies. What they read is agentd's own text: fixed lines, and every value the work chose — a server, a tool, a scope, the arguments — quoted and escaped to printable ASCII, so a bidirectional override, a zero-width character or a letter that only looks Latin shows as \uXXXX; the arguments in full up to 4,000 characters, with their length, and all of them in --delegation-list. In the display clients a gate agentd raised itself — a policy's, a Company's — carries the badge agentd security gate, which no question a model asks can earn; under it, a policy ask shows the call's {{args}} as printable-ASCII JSON, capped, and {{tool}} quoted, and the cross-Company gate its arguments the same way.

  • No reach for agentd's own authority. Work acting with a person's grant — the person's run, turn, conversation, a subagent that inherits — is not offered, and is refused (mode_mixing), an agent-mode MCP server tagged sensitive or egress unless the person's template lists it in agent_mode_servers; every agent-mode call it makes comes back to the supervisor and is recorded (delegation.agent_mode_call). The load refuses the same resource or authorization server under a delegated and an agent-mode server.

  • A server's questions reach its person alone. An elicitation a delegated server sends on a connection made with a person's grant is a gate of its own, owned by and addressed to that person, in agentd's words with the server's quoted as the server's. It is answered as their consent gates are, and only through a session that holds that grant: a device session under the same name that never signed in at the IdP as the identity the name is bound to is refused, and recorded (delegation.elicitation.refused). No operator answers it for them, no judge and no approval policy does, and unanswered within the server's wait it is cancelled. A URL elicitation is declined; agentd never opens a link a server sends.

  • Nobody there, and less allowed. A firing of an act_for workflow acts for its person only from a schedule, loop or once start — act_for beside any start an outsider can fire, or a webhook wait, is refused at load, so no one can make the person's phone buzz at will or drive an approved run with their own payload (T18) — only from the operator's own configuration, and only while the person's grant holds their consent to that very definition: a changed digest suspends its firings until they sign in again. A firing's work is tainted and unattended, so the person's consent rules ask them and deny unanswered; a delegated egress tool is refused unasked unless their template lists it in unattended_tools; no step-up is asked for. Its authority ends with its run, and work outliving it — a call on its way included — acts for nobody. The authority is that run's alone: a run its work starts — a workflow step, workflow.run, a signal or a message that starts one — is a firing of its own, held to the person's consent to that run's own workflow, so one that declares no act_for for them, or that a reload changed, acts for nobody; a conversation turn its work starts by message acts with no one's grant. via: standing uses the grant with nobody asked, and under a profile: okta IdP only with allow_against_idp_guidance. via: ciba asks the person on their own device, once a firing and grant — written to the run's record before the request is sent — every call of the firing waiting on that one request, at most one request waiting per person and workflow and ciba.per_day a day; the message their device shows is agentd's identifiers, never a payload; the approval's ID token must name the grant's own iss and sub — a hint can resolve to another account — or the firing fails closed; it is the subject of that firing's mints and exchanges alone — a grant: token_exchange server's exchange presents it, bound to the approval, never the grant's access token — held in memory and never written. A restart loses the request and the approval, and the firing asks the person once more — one new request, saying again, nothing of the old one used — and the run's record says so before it is sent, so a second restart fails the firing closed and a denied request is never asked again. A refresh token the IdP issued with an approval is kept sealed in the store while the run lasts and revoked when it ends; one a crash left there is revoked at the next start, before any request is sent, so it never outlives its run at the IdP for longer than agentd is down — or, under an external signer down at that start, than the signer is: the start's pass is tried again, with a backoff, until it finishes, and a request made meanwhile fails unsent. The login hint a CIBA request falls back to is kept sealed under the person's personal key.

  • A gone grant waits only where it is safe to. Under auth.on_revoked: ask a workflow step whose grant was revoked or expired, or whose run's session ended, waits for the person to sign in again; a model's call, and work that has read outside text or that nobody is there for, never does, so no steered model can make anyone sign in. A run whose session ended moves only to a live session of the same principal holding an IdP proof of the very identity its grant is bound to, and the move is audited. A call held before any rule was consulted meets the operator's rules and the person's consent when it is made again.

  • Sender-constrained where both sides offer it. A delegated server whose metadata and whose authorization server's both advertise ES256 DPoP proofs gets an access token bound to a key agentd makes in memory and never writes (T23): a stolen token is useless without the key, a redemption answered with a Bearer token to a DPoP request is refused as a downgrade, and a server that takes bound tokens alone is refused where agentd cannot bind one. The key lives as long as the process; the ID-JAG exchange at the IdP is not bound (RFC 0044 P4).

  • A worker uses what it was offered. A turn worker makes a delegated call only of a tool its plan offered — its step's tools: and servers: — and a subagent only of one on a delegated server it declares that its tools: grant admits; a worker asking for any other is refused before any check or mint (tool.refused, why: not_offered).

  • Documents cannot configure authority. A served document, a :::!config or :::!mcp fragment and a subagent template's machinery may not declare a delegated server, set its pins or its client authentication, or reference a secret under identity.* or on a delegated server, by any spelling of it — a reference is compared by what it resolves to, a *_file field's path as a file (RFC 0042, as RFC 0044 §5.14.1 amends it).

  • Documents cannot vouch for a server. A server any of them declares counts as untrusted_input beside the legs it names, in the check and at runtime, unless the operator's kind: mcp catalog entry it names with service:, or whose endpoint it gives, declares tags; trusted on it is refused. A document can make a server look more dangerous than the operator did, never less.

  • Workers under a uid of their own. With spawn.uid and spawn.gid every turn worker, subagent and think child, an instance child's own workers, and every exec command run as that uid and gid with no supplementary group, before they enter the Landlock domain: no worker can read agentd's memory, a file only agentd's uid may open, or pass an external signer's peer check. Every capability is dropped with the uid — the ambient set cleared, the permitted, effective and inheritable sets emptied and read back empty — and no_new_privs set, so no worker can set its uid back to agentd's, whatever agentd held or the binary's file capabilities say. agentd needs root, or CAP_SETUID, CAP_SETGID and CAP_KILL (the last to signal a worker of another uid at its deadline and its drain), to do it, and the load refuses the configuration where it cannot, naming why; a uid that is agentd's own, root's or a signer's is refused too.

  • An instance child answers as itself. A child daemon reaches its parent only through a channel the parent handed it at spawn — a socket pair held at fd 3 — and every connection over it is instance:<handle>, named by the channel, never by what it presents. That principal may send its own two reports (_instance.result, _instance.emit) and nothing else: no operator op, no read, no stream, no conversation, and no report about another child's handle (instance.op.refused). The composed configuration holds no bearer, peer or other credential of the parent's. With spawn.instance_uid and spawn.instance_gid, agentd running as root starts each child under that uid — not 0, not agentd's own, not a signer's, not spawn.uid — after making its directory the child's, still inside the Landlock domain; the child's own workers run under the same uid. Without it, a child keeps agentd's uid, and could still dial a unix A2A listener of its parent's it finds, which the kernel names as the operator: that is what the uid of its own closes.

What this preview does not do: a grant held by a person stays usable after the IdP removes a Resource connection until the next mint (at most at_cache_cap); taint does not follow text inside an instance child, which keeps a record of its own; for one person on a kernel without Landlock, with no separate worker uid, the vault and a mounted key file are readable by any same-uid process; a via: ciba approval answers for every delegated call of its firing at the person's IdP, not for each; a restart asks the person a second time for a firing they had approved; a refresh token an IdP issues with a CIBA approval lives at the IdP while agentd is down, until the next start revokes it, and one that IdP will not revoke then is forgotten all the same; and DPoP binds the authorization-server and MCP legs, not the exchange at the IdP.

A container deployment with a worker uid

Run agentd as root in the container (it drops every worker, and the drop from root clears every capability the kernel's way too) — or as a uid of its own granted CAP_SETUID, CAP_SETGID and CAP_KILL, with no instance-tier template — and give the worker uid only what a worker reads itself:

  • the agentd binary, readable and executable by it (0755);
  • each {{secret-file:…}} its connections name — a model's token, an MCP server's header — readable by its gid, and none of the supervisor's keys: the IdP and resource keys, the seal keys and the companies' seal key stay 0600 to agentd's uid;
  • AGENTD_CRED_DIR, where workers keep the OAuth tokens of the MCP servers they dial, owned by the worker uid (sign in there with agentd --login run as it);
  • security.exec.workdir, owned by it, when exec is enabled;
  • a TLS trust file (security.tls_ca), readable.

The store's root and the audit chain stay agentd's alone; with Landlock they are denied to every child as well. Keep the external signer's socket in a directory the worker uid cannot write, and the signer under a uid of its own.

For instance children under a uid of their own (spawn.instance_uid, above) run agentd as root: it makes each child's directory the child's before it starts it, which a uid granted only capabilities cannot. The uid needs no entry in the container's /etc/passwd. A durable child's directory is under the store's root, so the store's root and each directory above it must be passable (o+x) by that uid, or the spawn is refused saying which is not; a non-durable child's is a private directory under the system temporary directory. Under user namespaces the uid must be mapped in the container.

The audit chain

observability.audit.chain appends every audit record — each A2A call, delegation's records, the person's consent answers — to a file only the supervisor writes, opened for appending. Each line carries seq and prev, the SHA-256 of the line before, so a record edited, removed, inserted or moved breaks the chain. Delegation's records and the consent answers are flushed to the disk before the thread that makes them goes on: before a minted token is handed out, before a delegated call's answer is returned, before an answer takes effect. A delegated call's delegation.used record follows the call it records, so a crash during the call leaves no record of it. At every start agentd verifies every kept segment and the head the store keeps, and refuses to start (exit 2), naming the file and the line, when the chain does not hold: a cut tail is caught by that head, and the store is handed only a head whose records are on the disk, so a power cut does not look like a cut tail. A last line cut short by a crash is the one thing repaired, and an audit.chain.repaired record says how many bytes were dropped. With store.kind: none or memory the head lives only while agentd runs, so a tail cut while it is down is not caught; the load warns.

The file rotates at rotate_at; a closed segment is kept until its last record is older than retention (400 days by default). Its deletion is an audit.chain.retired record naming the segment's last record, written before the segment is deleted; a chain that starts past its first record with no such record — a segment removed by hand — refuses the start. Each segment opens with a header that carries the tombstone register, so retention never forgets a revocation.

What a person saw is sealed for them. A consent record keeps who decided, how (via), the run, step, tool, server, scope and rule, the arguments' hash and length and whether the work was tainted, in the clear; what the gate showed — its rendered fields and text, the arguments among them — only sealed (AES-256-GCM) under the person's own key, personal: {key, sealed}. That key is kept in the vault as a grant's refresh token is: sealed under the seal key, or wrapped by the external signer. A key is made only for a person who holds a grant. Under the external signer it is opened off the loop — at start, and when a gate of theirs opens — and a record is sealed only with a key already open, so the loop never waits on the signer; one not open yet leaves an approval undecided, the gate open, and is opened again for the next answer. agentd --delegation-audit reads a person's records back with it, off the loop. agentd --delegation-forget <principal> erases the person: every gate open for them is denied first (via: forgotten), recorded under their key; then their grants are revoked, and their key is destroyed — its id raised in the tombstone register, which the chain anchors, so a store restored from before still opens nothing it sealed. With their grants gone no key is made for them again, so nothing of theirs is sealed readable after. Their records stay in the chain, unreadable, and the chain still verifies. A backup of the store taken before the erasure holds the key, sealed under the seal key: destroying such backups, or the seal key with them, is the operator's part of an erasure.

Starting a new chain after a refusal you have looked into:

  1. Stop agentd.
  2. Move the chain aside as evidence: <path> and every <path>.<seq> beside it.
  3. Remove the store's head of the chain: the audit record chain-head (under store.kind: file, <root>/<store.prefix>/<instance>/audit/chain-head.json, the prefix agentd by default).
  4. Start agentd. The new chain begins at record 1, and the store's tombstone register is anchored in it again. Revocations only the old chain held are not carried: check that the store you start from is not a backup taken before one.

Moving the chain aside alone is a chain cut to nothing, which the store's head refuses.

Payments

With the x402 feature agentd pays with x402 — for HTTP resources, MCP tool calls and A2A peers' tasks, on EVM chains and Solana — and only within the spend policy of security.payments (RFC 0049, under ADR 0003). An agent that can pay can be talked into paying — by a prompt, a tool result or a server's own text — so each of ADR 0003's rules is a check agentd makes before anything is signed:

  • Caps and allow-lists. Assets by CAIP-19 id, payees by address, and caps per payment, run, day and payee, counted from the ledger over every payment that left agentd or may have. A payment outside them is refused unsent, naming every reason. The check and the reservation are one step on the supervisor's loop, so two steps cannot both pass a cap the second one breaks.
  • No model-facing paying tool. A model's tool arguments come from text that may be anyone's. A payment is made only by an http, mcp.tool, a2a.send or a2a.delegate step of a workflow in the operator's own configuration, whose payment field is literal (a model's own call of a tool that charges reads the payment request as the tool's error); a model reaches one only by starting a workflow the operator exposed as a tool, and such a run asks the approver unless ask_when_tainted is off. An instruction document's paying step is refused at load, and a definition workflow.create or workflow.update stored fails the step unpaid.
  • The approver's gate. Above approve_above, for a step that says approve: true, and — with ask_when_tainted, the default — for work that has read outside text or that a model asked for, the payment waits for security.payments.approver on a task of their own, marked as agentd's security gate, its text agentd's and what the step or the server chose in it — the request, the server's words — quoted. No judge answers it and no operator but the approver; unanswered, it declines. The policy is checked again when the approval lands. An A2A peer whose payment is refused or declined is told payment-rejected and no reason, so it learns nothing of the policy or the approver (Payment approvals).
  • The key stays with the supervisor. A wallet's key file holds the key — secp256k1, or a Solana wallet's Ed25519 seed — sealed under AES-256-GCM with a seal key read from a mounted file, bound to the wallet's name and address. The supervisor opens it for one signature and wipes it after (ring's Ed25519 key pair, made from the seed, is dropped after one signature; ring does not promise to wipe it); the key file, the seal key, the store root (where the ledger is) and the signer's socket are supervisor-only paths under Landlock. Under key_custody: external agentd holds no wallet key, and the signer is sent the typed data, or a Solana transaction's message, to read before it signs. No key, signature, signed authorization or signed transaction is in a log line, a model's context or a child's payload, and every signature is recovered to, or verified under, the wallet's address before it is sent.
  • On Solana, only the transfer asked for. agentd builds the one transaction x402's exact scheme describes — the token's TransferChecked to the payee's token account, compute-budget and Memo instructions, the server's sponsor as fee payer and in no instruction — reads it back before it signs, and signs nothing else; it pays no network fee. When the server's entry gives no blockhash it asks the RPC endpoint the operator names for the network (security.payments.rpc), which is sent nothing of the payment.
  • Every payment an audit record. Each step of a payment is a payment record in the audit chain, flushed to the disk before the payment goes on; the authorized record is written before the signed request leaves, and a chain that cannot take it refuses the payment.
  • Never twice. The ledger record of a payment is on the disk, sending, before the request leaves. A payment that settled, or whose outcome is unknown — no answer, a timeout, a 5xx, settlement_pending, a restart with the request out — is never made again: a retry, a replay or a restart of the step fails before sending anything.
  • The resource is the request's. A 402 that asks to be paid for another URL than the one the step asked is refused, and no redirect is followed; an MCP tool's payment request must name mcp://tool/<the tool called>; an A2A peer is paid on its own task, over the connection that asked. A delegated server's call is never paid.

What it does not cover: a server that takes the payment and serves nothing (the authorization flow settles after the resource is served, and the audit chain keeps what a dispute needs), a token contract or chain that misbehaves, and a wallet funded beyond what you mean it to spend — the caps bound agentd, not the wallet.

What agentd does not protect against

Stated plainly, so you size the surrounding environment correctly.

  • No in-binary sandboxing beyond one Landlock rule. No seccomp, no namespaces, no chroot. The OS hardening is process groups, an optional cgroup leaf, the parent-death signal and, where a supervisor-only key is configured, the Landlock domain that keeps children out of the vault, the key files and the audit chain. Any other confinement, filesystem scope and aggregate resource limits are the deployment's job.
  • No network enforcement. security.egress: closed governs the dials agentd makes from configuration. Which hosts the process, an MCP server or an exec command may reach is a firewall or NetworkPolicy concern.
  • No content-based injection detection. The defence is containment, and containment is not a guarantee.
  • No data-flow tracking. Taint is per stream and per server. Memory and artifacts are not traced, and deterministic step arguments are not counted.
  • No per-tool tags in mcp.servers[].tags. Server tags apply per server. tools.narrow adds per-tool tags for policies only.
  • No detection of a server that changes its tools. The catalogue is read at connect and at each reload. A tools/list_changed notification is logged as mcp.tools_changed and changes nothing until an operator reloads; a description changed under the same name is not flagged.
  • No event when the trifecta override is used.
  • No artifact redaction. Artifacts carry a sensitive flag, but artifact.get returns the content regardless.
  • No defence against the host's own users on a no-credential loopback listener. Every local uid is the implicit operator there, and a same-uid process is the user in every sense.
  • No per-caller bound on task retention. Use ttl on a shared listener.
  • No role model beyond principal roles, grants and policies.

Reference

Internal tool tiers

Default grants of the internal tools by caller kind. MCP and code-registered tools have no grant: root and workflow callers may call them, and a subagent may unless its spawn carried a tools: grant, which then holds for every tool class.

TierCallersTools
allroot, workflows, subagentsmemory.*, artifact.*, plan.*, skills.*, knowledge.*, search.*, ask_human, think, decide, sleep, await, context.compact, instruction.read, message.send, exec, workflow.list, workflow.status, workflow.signal, company.status, company.sign_in, company.sign_out, company.converse, company.read, company.handoff, company.close, company.api, company.api.describe, company.operation.get, company.operation.confirm, company.operation.cancel, company.direct
root and workflowsroot, workflowssubagent.*, code.run, events.*, mcp.task.*, workflow.run, workflow.cancel, workflow.wait
root onlyrootinstruction.subscribe, workflow.create, workflow.update, workflow.delete, workflow.pause, workflow.resume

finish is granted to root and subagents, not to workflows, which end with the finish step. status is granted to every caller. What an A2A user or agent principal may do is set by the command extension's op table, its grants and the ownership rules, not by this table.

Read-back paths

Every way a result can be read back, each answered with the marker when the reader holds sensitive and egress and the result carries outside input it was not already handed:

  • a synchronous workflow tool's reply, and workflow.run with wait: true;
  • workflow.wait, the workflow.wait step and wait {on: run};
  • workflow.status (runs, step outputs and variables) and status;
  • a workflow step that is not detached, and a join;
  • subagent.status, subagent.await, wait {on: subagent}, and subagent.list, which carries the opening of each child's instruction;
  • plan.update with bind: {run} or bind: {subagent}, whose note carries the bound output into every later turn;
  • the notes a finished run (agent.on_workflow_finished) and a child's result (subagent_result in agent.wake_on) leave in the root transcript;
  • the note an emit step without a stream: writes into the root transcript.

Exec guards

GuardBehaviourWhy
argv, never a shellthe command is executed directly with its argumentsno metacharacters, globs, $(…) or pipes
allow-listexact equality on argv[0]; an empty list denies everythingenabled: true alone runs nothing
working directorymandatory; a requested cwd is canonicalized and must resolve inside itdefeats .. traversal and symlink escape
timeoutthe lesser of the requested and configured values, default 30 s; the child is killed and reapeda request can shorten the ceiling, never extend it
output capdefault 1 MiB of stdout and stderr; the excess is drained and discardedbounded capture, no deadlock on a full pipe
minimal environmentcleared, then rebuilt from security.exec.envthe agent's environment and its secrets are never inherited
off the reactorruns on its own thread; stdin fed from anothera child that writes before reading cannot stall the daemon
auditexec.run {cmd, argc, cwd, timeout_ms, caller}the confinement is logged, never the output

The result is {stdout, stderr, exit_code, timed_out}. One coupling remains: spawning holds the process reaper's lock until the child's execve returns, so a binary on a hung network mount can stall the daemon as long.

SSRF blocked ranges

Refused as non-global, unless allow_private is set:

FamilyRanges
IPv40.0.0.0/8, 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (cloud metadata), 100.64.0.0/10 (CGNAT), 224.0.0.0/4 (multicast), 240.0.0.0/4 (reserved), the broadcast address
IPv6::, ::1, fe80::/10, fc00::/7, ff00::/8, 2001:db8::/32, and ::ffff:a.b.c.d / ::a.b.c.d classified as their IPv4 address

Instruction provenance refusal codes

A refusal of an author signature or a delivery attestation carries its Instruction Specification Appendix B code in the agentd-instruction crate's refusal type: signature-typ-mismatch, audience-mismatch, digest-mismatch, manifest-dropped-missing, unpinned-publisher, delivery-ceiling-exceeded, wire-floor or ungranted-family. A condition Appendix B has no row for, such as a JWS that does not parse or verify or an expired signature, carries attestation. The log line is the refusal's message.

A2A limits

LimitValue
failed-credential budget per source20, one forgiven every 3 s; 4096 sources tracked
sourcean IPv4 address or an IPv6 /64
denial logone a2a.denied line per source and reason per minute; 20 lines plus one a second overall
device codes per source5, then one every 12 s; 4 waiting
waiting device codes16 per network (IPv6 /48, or the IPv4 address), 64 in all
token polls per source30, then one a second
device user code8 letters from a 20-consonant alphabet
device approval name^[a-z0-9][a-z0-9._-]{0,63}$
launch code256 bits, single-use, 60 s
waiting launch requests16; the oldest is retired past it
launch failures per source20, one forgiven every 3 s
session, device and launch secrets256 random bits, held as SHA-256

Descriptor hygiene

Every descriptor the launcher creates (the pipe, the UI's socket, the saved terminal, the daemon log) is close-on-exec, and the one a client is meant to have reaches fd 3 only in that client. Every process of the agentd binary marks close-on-exec, at start and before it opens anything, each descriptor it inherited beyond stdio. Every child the daemon spawns (an exec command, a subagent, an instance) has every descriptor from 3 up marked close-on-exec between fork and exec, whether agentd runs as its own binary or embedded in another program.

The marking is one close_range call on Linux 5.11 and later. Where a kernel or a seccomp profile refuses it, and off Linux, a process marks what /proc/self/fd (/dev/fd) lists, and without /proc (agentd as PID 1 in a bare container) it tries each number below its descriptor limit, capped at 65536. With neither close_range nor /proc and a limit above the cap, higher numbers stay unmarked, and one process.inherited_fds_unmarked warning on stderr says so.

Next steps