Documentation — Harden a deployment
Guides · Security

Harden a deployment

This guide turns agentd's security model into configuration: a checklist, one complete hardened configuration that validates, and the reasoning, variations and traps for each item. It is for operators preparing an agent that reads outside input, holds real credentials, or is reachable from anywhere but one developer's machine. When you finish, your configuration refuses the dangerous shapes at load, before any side effect.

The checklist

Do thisBecause
Run agentd in a sandbox with an egress policy and resource limitsagentd has no in-binary sandbox
Tag every MCP server, one server per tag profilethe Rule of Two judges servers by their tags
Catalog every service and close egresstags become a floor and the endpoint list an allow-list
Keep outside input away from the actorsa run that reads a stream is judged with everything it can reach
Put policies on risky callsa verdict on the call and its arguments
Keep approvals with peoplean approval is the operator's decision, not the agent's
Lock the A2A listenerwithout a credential every local process is the operator
Authenticate every webhook routea route answers whoever reaches it
Leave exec offa local command runner is sensitive + egress on its own
Write every secret as a referencevalidation catches only the shapes it knows
Pin where the instruction comes fromthe instruction is the agent's standing policy
Freeze workflow definitionsthe agent cannot rewrite its own machinery
Record an audit trailwho did what, on whose authority
Validate in CIthe same check startup runs, before you deploy

A hardened configuration

This is an issue-triage agent that reads a public tracker and its docs, labels issues and comments on them. It holds two legs of the trifecta (untrusted_input and egress), dials only catalogued endpoints, asks a person before it posts a comment, names the model any approval judge would run on, serves people over TLS with the device grant, and keeps an audit trail.

# needs: triage.md
agent:
  name: triage
  instruction:
    file: triage.md
  approval: ask
  ask_human_unowned: gate

intelligence:
  endpoints: https://api.openai.com/v1/chat/completions
  token: "{{secret:OPENAI_API_KEY}}"
  models:
    smart: { model: gpt-5.1 }
  roles:
    turn: smart
    approve: smart

services:
  openai:
    kind: intelligence
    endpoint: https://api.openai.com/v1/chat/completions
  tracker:
    endpoint: https://mcp.tracker.example.com/mcp
    tags: { "*": [untrusted_input, egress] }
    auth: { kind: static, token: "{{secret:TRACKER_TOKEN}}" }
    allow: ["get_*", "list_*", "search_*", "add_comment", "set_labels"]
  docs:
    endpoint: https://mcp.docs.example.com/mcp
    tags: { "*": [untrusted_input] }

mcp:
  servers:
    - { name: tracker, service: tracker, ns: tracker }
    - { name: docs, service: docs, ns: docs }

security:
  egress: closed
  policies:
    - match: { tool: "tracker.add_comment" }
      action: ask
      question: "Post this comment? {{args}}"
      timeout: 30m
      on_timeout: deny
    - match: { tags: [egress], caller: [subagent] }
      action: deny
  workflows:
    immutable: true

a2a:
  listen: https://0.0.0.0:8443
  url: https://triage.example.com:8443
  bearer: "{{secret:AGENTD_OPERATOR_TOKEN}}"
  tls:
    cert: /etc/agentd/tls/server.crt
    key: /etc/agentd/tls/server.key
  device_grant:
    enabled: true
    scopes: [user]
    token_ttl: 8h

store:
  kind: file
  file: { path: /var/lib/agentd/triage }

observability:
  audit:
    sink: [log, store]

Save the agent's instruction as triage.md in the directory you start agentd from (a relative file: path resolves against the working directory), set the three referenced secrets in the environment, and validate:

agentd --validate-config --config agentd.yaml

The last line of the output is the verdict:

{"event":"config.valid","files":["agentd.yaml"]}

Before it, one config.effective_server line per MCP server shows the endpoint, tags and tool ceiling each server ended up with after the catalog was applied. The TLS files are read when the listener starts, not at validation.

Run agentd in a sandbox

agentd is not the security boundary; the environment around it is. agentd has no seccomp profile, no namespaces and no chroot of its own, so give it those from the platform:

  • run it as a non-root user, with a read-only root filesystem and only the store directory writable, all capabilities dropped and privilege escalation off (the Kubernetes example has the security context);
  • set memory and process limits on the container or unit, and consider security.cgroup, which puts each child process agentd spawns in its own cgroup leaf, with optional memory and process limits, that agentd can kill as one (Supervision and containment);
  • put an egress policy (a NetworkPolicy or firewall) in front of the process. security.egress: closed governs the dials agentd makes from configuration; it does not stop an MCP server, or a command, from reaching anywhere else.

Treat every MCP server you declare as code you run at agentd's privilege, and vet it as you would a dependency.

Tag every MCP server

Tag each server with every leg it carries: untrusted_input if it returns text strangers wrote, sensitive if it reaches private data or privileged systems, egress if it can send data out or change something outside. Any two legs may sit in one agent; all three refuse to load.

Three traps:

  • Glob keys do not split a server. Tags apply to every tool of the server. {"read_*": [untrusted_input], "send_*": [egress]} makes both tools carry both tags. To keep a reader and a sender apart, declare them as two servers.
  • An untagged server counts as untrusted_input. Leaving tags off is not neutral.
  • The budget is the whole instance. The load folds every declared server together. An instance with a reader, a secrets server and a sender does not start, even if you meant to hand each to a different subagent. Split it into separate agentd instances per risk profile.

When all three legs are present the load says so and exits 2:

{"event":"config.invalid","msg":"lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress into one agent; narrow the tags or set security.allow_trifecta (audited)"}

security.allow_trifecta: true lifts every trifecta check at once and emits nothing when it is used. Do not set it to get past this message; split the instance instead.

tools.narrow.<tool>.tags adds a tag to a single tool (it can never remove one). Those per-tool tags reach policies, not the load-time check.

Catalog every service and close egress

Put every external service in services:, with its endpoint, its tags, its one credential and its tool ceiling, and have servers reference entries by service:. Then set security.egress: closed.

The catalog makes tags a floor. Any MCP declaration whose endpoint matches an entry gets the entry's tags added before the Rule-of-Two check, including one an instruction document or a subagent template declares inline, so nobody can under-tag a catalogued system. The entry's allow and exclude are a ceiling a consumer can narrow and never widen.

Closed egress makes the catalog an allow-list. Every endpoint agentd dials from configuration must match an entry of its kind: mcp for MCP servers, intelligence for model endpoints and every provider, peer for A2A peers, and http for http steps, stream forward targets, workflow url: references, the HTTP store, A2A push targets and an instruction fetched by url: or oci:. A literal endpoint is judged at load. Every http step URL is checked again at the dial, where a matching entry's methods list is also a ceiling.

The trap is the model endpoint: it needs an intelligence entry like any other destination, or the load refuses it.

{"event":"config.invalid","msg":"security.egress is closed and intelligence endpoint (https://api.openai.com/v1/chat/completions) matches no `kind: intelligence` services: catalog entry — catalog the endpoint to allow it"}

Closed egress does not cover observability.otel.endpoint (the load warns), or credential plumbing: OAuth token and discovery endpoints, AWS metadata and STS, and the AAuth provider. The built-in mock: test endpoint opens no socket and needs no entry.

Keep outside input away from the actors

A webhook or A2A route with into: appends a caller's text to a stream, and every run that reads the stream carries that text. agentd judges such a run with everything its model-driven steps can reach, and the runs it starts, and refuses the configuration when they reach sensitive and egress together. The pattern that passes: read the outside text with a step that has no tools, and act with a deterministic step whose target is fixed.

agent:
  instruction: "Acknowledge customer mail."

intelligence:
  endpoints: ["mock:final"]

mcp:
  servers:
    - name: mail
      endpoint: https://mcp-mail.internal.example.com/mcp
      tags: { "*": [sensitive, egress] }

webhooks:
  listen: http://127.0.0.1:9450
  default_auth:
    hmac: { secret: "{{secret:HOOK_SECRET}}" }

streams:
  inbox: {}

workflows:
  - name: intake
    steps:
      hook:
        kind: webhook
        path: /hooks/mail
        into: { stream: inbox, subject: mail.received }

  - name: triage
    steps:
      take: { kind: stream, stream: inbox, subject: "mail.*" }
      read:
        kind: extract
        depends_on: take
        input: "{{ steps.take.output.data }}"
        output_schema:
          type: object
          required: [sender, topic]
          properties:
            sender: { type: string }
            topic: { enum: [billing, outage, other] }
      ack:
        kind: mcp.tool
        depends_on: read
        server: mail
        tool: send_template
        args:
          to: "{{ steps.read.output.sender }}"
          template: "ack-{{ steps.read.output.topic }}"
      done: { kind: finish, depends_on: [ack], status: completed }

This sample uses mock:final, the built-in test endpoint, so it validates without a model account. The extract step reads the mail with no tools, and its output_schema constrains what reaches the mcp.tool arguments. Replace read and ack with one agent step, reply, that holds servers: [mail], and the load refuses it, naming the stream, what feeds it and the server that brought the other two legs:

{"event":"config.invalid","msg":"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. …"}

The message also shows a second trap: an agent step can call workflow.run, workflow.signal, subagent.run, subagent.send and message.send, and the check counts what those reach. A tools: list on the step that leaves them out narrows its reach, though it cannot help a step that itself holds both other legs, as reply does.

A context that holds sensitive and egress and reads such a run's result back (workflow.status, a synchronous workflow tool, subagent.await) gets the status with a marker in place of the text, and agentd logs readback.withheld. That is expected; it is the same rule applied on the way back.

Put policies on risky calls

security.policies is an ordered list of verdicts on individual calls. The first matching rule wins, and no match is allow. Use it for what tags and grants cannot say: an argument condition, a person's sign-off, or a rule for one caller.

# fragment: under security:
policies:
  # Never delete outside /tmp (a CEL guard over the call's arguments).
  - match: { tool: "fs.delete*", args: "!args.path.startsWith('/tmp/')" }
    action: deny
  # A person signs off on anything a subagent sends out.
  - match: { tags: [egress], caller: [subagent] }
    action: ask
    question: "{{caller}} wants {{tool}} with {{args}}. Allow?"
    timeout: 15m
    on_timeout: deny
  # Signed-in users never reach billing tools through the model.
  - match: { tool: "billing.*", principal: "user:*" }
    action: deny
  # Refunds go to the finance operators, not to whoever is on duty.
  - match: { tool: "billing.refund" }
    action: ask
    to: { role: operator, labels: { team: finance } }
    on_timeout: deny

How rules match:

  • tool is a glob over the tool's registered name. MCP tools keep their bare name unless the server sets ns:, so fs.delete* needs ns: fs on that server. Set ns on every server you write policies for.
  • tags must all be present on the tool; caller is root, workflow or subagent; principal is a glob over the principal id (user:*, agent:ci-bot) of calls made on a principal's behalf.
  • args is a CEL expression over args, tool and caller. A guard that does not compile refuses the load, and one that fails at call time refuses the call, never "no match". The release binaries include the cel feature.
  • to: must name someone who can see the task: leave it out for the operators, or narrow to operators by the labels their principal rule carries. A to: naming a user or agent role is refused at load.

The verdicts: ask holds the call until a person decides, and an ask nobody can be asked (no listener, or work nobody owns) takes on_timeout, logged as tool.policy.unanswerable. shadow refuses and says the call was held, without inventing a result. Policies apply to every caller, subagents included. They live in the security section, which is restart-only.

Keep approvals with people

agent.approval decides who answers the asks the agent itself makes with ask_human. Leave it at ask, the default, so a person decides.

  • auto hands each ask to a model judge on the intelligence.roles.approve model, and accept takes the ask's recommended answer. Neither touches an addressed ask (to:) or a policy ask, and no judge answers an ask whose run, subagent or peer conversation carries outside input: that ask fails and agentd logs human.judge.withheld.
  • If you do use auto, bind roles.approve to a model you chose. The role is operator-only, so a served document cannot pick its own judge.
  • ask_human_unowned: gate puts asks from work nobody owns (schedules, webhooks, subagents) on the listener for an operator, instead of taking the ask_human_fallback. It needs an A2A listener.
  • The operator can change agent.approval at runtime with admin.set; the next reload restores the file's value. Anyone who reaches a no-credential loopback listener is the operator, which is one more reason to lock the listener.

Human in the loop covers asks, gates and answering them.

Lock the A2A listener

Choose the listener by who calls it.

CallersListener
processes on this host, one userunix:///run/agentd/<name>.sock: the kernel admits only the daemon's own uid
people, from anywherehttps:// with a2a.tls, a2a.bearer for the approving operator, and a2a.device_grant
services and agents with certificateshttps:// with a2a.tls.client_ca and certificate principal rules
development, one personhttp://127.0.0.1:<port>, which loads with a warning that every local process is the operator

The hardened configuration above serves people. For services, require a client certificate and name callers by it:

# fragment: under a2a:
listen: https://0.0.0.0:8443
url: https://triage.example.com:8443
tls:
  cert: /etc/agentd/tls/server.crt
  key: /etc/agentd/tls/server.key
  client_ca: /etc/agentd/tls/clients-ca.pem
principals:
  - { match: { san: "spiffe://acme/ops/deployer" }, role: operator }
  - { match: { san: "spiffe://acme/*" }, role: user, grants: [workflow.run] }
  - id: ci-bot
    match: { bearer_ref: "{{secret:CI_TOKEN}}" }
    role: agent
    labels: { team: platform }
    quotas: { rate: "20/1m" }

The load enforces the edges: a non-loopback bind needs a2a.bearer or a2a.tls.client_ca, plaintext is loopback-only, a wildcard bind needs a2a.url, the device grant needs an operator credential to approve with, and a certificate rule needs client_ca. The traps:

  • Certificate rules are first-match, in the order written. Put the narrow rule first; swap the two san rules above and the deployer is a user.
  • client_ca makes a certificate mandatory for every caller, bearer-only and browser clients included, so the device grant refuses to load beside it. An instance has one listener: serve people and certificate-holding services from separate instances.
  • Never put a reverse proxy in front of a no-credential loopback listener. The proxy's relayed requests are local and present nothing, so each one is the operator.
  • List browser origins exactly in a2a.cors.origins; * is refused.
  • Push notifications are off until a2a.push.enabled, and a push target on a private address needs a2a.push.allow_private as well. Leave both off unless a caller needs them.
  • Retention is shared. On a listener several callers use, bound store.retention.tasks with ttl, so one caller's finished tasks cannot push out another's.

Connect agents over A2A walks through sign-in and delegation.

Authenticate every webhook route

A non-loopback webhooks.listen refuses to start while any route is unauthenticated. Set webhooks.default_auth, or auth on each webhook step, and prefer HMAC over the raw body:

# fragment: under webhooks:
listen: https://0.0.0.0:9443
url: https://hooks.example.com
tls: { cert: /etc/agentd/tls/hooks.crt, key: /etc/agentd/tls/hooks.key }
default_auth:
  hmac:
    secret: "{{secret:GITHUB_WEBHOOK_SECRET}}"
    header: X-Hub-Signature-256
    prefix: "sha256="

agentd computes HMAC-SHA256 only. A sender that signs another way (Stripe's timestamped scheme, Twilio's HMAC-SHA1 over the URL) needs a small relay that verifies its scheme and signs again. Give each route a rate, and route outside text into a stream with into: when it should wait for a consumer; the stream then carries the taint described above.

A served instruction document opens a webhook route only when the operator grants interface in agent.document_capabilities. Leave that grant out unless the document is meant to define the agent's public surface.

Leave exec off

The exec tool runs local commands only in a binary built with the exec feature and with security.exec.enabled: true. The release binaries and the image do not include the feature, so the safest posture is the default. To run commands, prefer mapping exec onto an MCP server that runs them in its own sandbox (tools.overrides), and tag that server.

If you build with the feature and enable the local runner, fence it:

# fragment: under security:
exec:
  enabled: true
  allow: [git, ls, cat]     # exact argv[0] matches; empty runs nothing
  workdir: /workspace       # a requested cwd must resolve inside it
  timeout: 30s              # a request may shorten it, never extend it
  max_output: 1048576       # bytes of stdout and stderr kept
  env: [PATH, HOME]         # the only variables the command receives

The local runner counts as sensitive + egress, so beside any untrusted-input server the load refuses it. Never allow-list a shell or an interpreter: the guard is argv-not-shell, and bash puts the whole injection surface back. A misconfiguration surfaces as an error at the first call, not at startup.

Write every secret as a reference

Write every credential as {{secret:NAME}} (an environment variable) or {{secret-file:PATH}} (a mounted file, re-read at each use, so rotation needs no restart). A reference that does not resolve is exit 2 at load, naming the reference.

Validation refuses an inline credential in the paths it knows:

{"event":"config.invalid","msg":"mcp server 'tracker' header 'X-Api-Key' looks like a credential but has an inline value; use a {{secret:…}} reference"}
{"event":"config.invalid","msg":"config file: intelligence.token carries an inline credential; use {{secret:NAME}} / {{secret-file:PATH}} (or set it from env/flag)"}

The paths it knows are the model and provider tokens, a2a.bearer, the AAuth enrollment token, every auth: block's token, value and client_secret, an MCP server's oauth.client_secret, and header keys that look like credentials. Two traps:

  • A header named something else is not checked. A bearer pasted into X-Session passes.
  • Webhook route secrets and a principal's bearer_ref are not checked. Write them as references yourself.

Tools that help:

  • --env <FILE> loads a dotenv file into the process environment (the real environment wins).
  • --prompt-missing asks for each missing {{secret:NAME}} on the terminal, echo off, and keeps the answers in memory only.
  • agentd --effective-config --config agentd.yaml prints the assembled configuration with where each value came from, credentials redacted.

Outbound credentials covers OAuth, AWS and SPIFFE credentials that replace static tokens.

Pin where the instruction comes from

The instruction decides what the agent does, so verify who wrote it, and, for an OCI artifact, who pushed it and which bytes:

# fragment: under agent:
instruction:
  oci:
    ref: ghcr.io/acme/triage-agent@sha256:4f1c0de6a3b5e9d27c8a1f3e6b0d9c2a7e5f8b1d4c6a9e2f7b3d0c5a8e1f4b29
    cosign_key: /etc/agentd/keys/acme-cosign.pub
  trust:
    - publisher: acme
      author_keys: [/etc/agentd/keys/acme-author.pub]
      max_capabilities: [material, knowledge]
document_capabilities: [material, knowledge]
  • A digest (@sha256:…) pins the exact artifact, and removes the mutable-tag question for less than any signature check costs.
  • cosign_key verifies who pushed the artifact.
  • trust verifies who wrote the document: with a publisher pinned, an unsigned document, or one signed by anyone else, is refused at load.
  • document_capabilities is what the document may activate beyond the default rung, and a signature can only narrow it (max_capabilities and what the author attested cap it further). Grant the fewest families the document needs.

An unsigned document under a pin is refused before anything reads it:

agentd: config file agentd.yaml: agent.instruction: the instruction carries no front-matter `signature:`, but 1 pinned publisher(s) are configured — an unsigned document is not from a pinned publisher (§7.6)

Under egress: closed, the registry must match an http catalog entry before agentd fetches the artifact. The pull happens at load, so the OCI sample above needs the registry reachable to validate. Write and ship instruction documents shows how to sign a document and push it.

Freeze workflow definitions

security.workflows.immutable: true refuses workflow.create, workflow.update and workflow.delete at runtime, for the model, subagents and operators alike. Definitions still load from configuration, files, folders and URLs. Set it when your workflows are reviewed code and the agent should not write new ones.

Without it, a definition the agent stores is still held to the Rule of Two and to the interface grant for routes, but it is machinery the agent wrote.

Record an audit trail

observability.audit.sink turns on the audit record: every A2A call, every principal-driven tool call and command, configuration reloads, restores, store conflicts and kills, each as {ts, principal, role, action, target, outcome, request_id, trace, instance} plus the session id for a signed-in caller.

SinkWhere it goes
logan audit line in the JSON log
storean append-only record in the durable store, never overwritten; needs a store
streamthe declared stream observability.audit.stream names, which a workflow can consume or forward

Use stream to ship the trail off the box. A failed store write is logged as audit.store.fail and never fails the audited action. Monitor in production covers the log and metrics around it.

Validate in CI

Run the same validation startup runs on every change to the configuration:

agentd --validate-config --config agentd.yaml

It reports every problem in one pass and exits 2 on any, before any side effect: the Rule of Two, closed egress, inline credentials, unresolved references (set the referenced variables in the CI job, with dummy values if need be), policy guards, addressees and listener rules. Workflows fetched from url: and uri: are judged when they resolve at startup, so keep reviewed workflows in files beside the configuration where CI can see them.

Next steps