Substrate actors
This guide puts an Agent Substrate
actor on the mesh under its own identity. Substrate runs agents as actors in
gVisor sandboxes and sends all of their outbound traffic through an egress
gateway that authenticates each actor by certificate and enforces a
per-actor EgressPolicy. That gateway can also replace a request header
with a credential it fetches from a credential provider. This guide adds an
Agent Mesh credential provider: for each actor, it asserts the actor’s
SPIFFE ID to the mesh and hands the gateway the credential the mesh mints
for it. The actor holds nothing and sends a placeholder; the mesh policy
decides what each actor identity may call, and revoking an actor is a policy
change.
Nothing here modifies Substrate. The provider implements Substrate’s
CredentialProvider gRPC contract and is registered with the installer flag
Substrate already has for that.
Everything runs on one kind cluster:
development/examples/substrate-actor/
in the repository has the provider, the manifests and a Makefile for every
step below.
How the pieces fit
flowchart LR
subgraph Worker["Substrate worker pod"]
Actor["actor (gVisor)"]
Tunnel["atunnel<br/>actor X.509 cert"]
end
subgraph System["ate-system"]
Egress["atenet-egress<br/>verifies cert, applies EgressPolicy"]
end
subgraph Mesh["agentmesh namespace"]
Provider["credential-provider<br/>FetchSecret(uri, actor SPIFFE ID)"]
Edge["edge agentmesh-node"]
One["agentmesh-one"]
Office["office agentmesh-node<br/>mcp://calc, mcp://payroll"]
end
Actor -- "POST /mcp<br/>X-Mesh-Authentication: placeholder" --> Tunnel
Tunnel -- "mTLS CONNECT" --> Egress
Egress -- "ate-secret://agentmesh/mesh/biscuit" --> Provider
Provider -- "assertion for spiffe://…/actor/<atespace>/<name>" --> Edge
Edge -- "/token/exchange" --> One
Egress -- "X-Mesh-Authentication: Bearer biscuit" --> Edge
Edge -- "mesh stream, caller's biscuit" --> Office- The actor sends a plain MCP request to
edge-node.agentmesh.svc.cluster.local:8080withX-Mesh-Authentication: Bearer placeholder. Substrate’satunnelcarries it to the egress gateway over mTLS with the actor’s certificate. - The gateway verifies the certificate, finds the matching
EgressPolicyrule, and because the request carries the header the rule names, asks the credential provider forate-secret://agentmesh/mesh/biscuiton behalf ofspiffe://substrate-actor.local/actor/<atespace>/<name>. The gateway authenticates to the provider with its pod certificate; the provider accepts no other caller. - The provider signs a short-lived assertion whose subject is that SPIFFE ID and exchanges it at the edge node’s token endpoint (RFC 8693). The control plane verifies the assertion against the provider’s published keys, since the provider is configured as a workload identity issuer, and mints a Biscuit with the roles bound to that subject. The provider caches it per actor.
- The gateway replaces the header value with
Bearer <biscuit>and forwards the request. The edge node passes the request into the mesh under that identity, and the office node enforces the policy on its side.
The provider is the attester here: the mesh trusts it to name actors
truthfully, and it names only what the egress gateway, which verified the
certificate, told it. Once Substrate serves OpenID discovery for the actor
JWTs it already mints
(agent-substrate/substrate#1756),
the provider becomes unnecessary: the gateway’s actorJwt injection puts a
Substrate-signed JWT on the header, and agentmesh-one trusts Substrate’s
issuer directly. The last section shows that change.
Before you start
- Docker, kind, ko, kubectl, Go and python3.
- A checkout of Agent Substrate; the example installs it from source with Substrate’s own scripts.
agentmesh-oneandagentmesh-nodeimages atv0.1.0or newer. The Makefile defaults tov0.1.1; setAGENTMESH_VERSIONfor another release.
cd development/examples/substrate-actor
1. Substrate, with the gateway pointed at the provider
make cluster SUBSTRATE_DIR=~/src/substrate
creates the kind cluster with Substrate’s hack/create-kind-cluster.sh and
installs Substrate with
./hack/install-ate-kind.sh --deploy-ate-system --deploy-demo-sandbox \
--credential-provider='{"name":"agentmesh","address":"credential-provider.agentmesh.svc:50051"}'
--credential-provider is the installer’s existing option for a provider you
deploy yourself. name becomes the provider class of the credential URIs
(ate-secret://agentmesh/...) and address is where the egress gateway dials
it. The gateway presents its pod-identity certificate and verifies the
provider’s serving certificate against the cluster’s servicedns trust bundle,
so the provider’s Service must exist before Substrate starts; the Makefile
creates the agentmesh namespace and the Service first.
--deploy-demo-sandbox installs Substrate’s sandbox demo: an Alpine actor
whose POST /process runs a command inside the sandbox. It stands in for an
agent; any image that speaks HTTP takes its place.
2. The provider and the mesh
make up
builds the provider with ko, loads the calculator image into kind, and
applies manifests/. The provider runs with the same two projected volumes
as Substrate’s bundled Kubernetes Secrets provider:
args:
- --server-cred-bundle=/run/servicedns.podcert.ate.dev/credential-bundle.pem
- --client-ca-file=/run/podidentity.podcert.ate.dev/trust-bundle.pem
- --injector-identity=spiffe://cluster.local/ns/ate-system/sa/atenet-egress
- --node-url=http://edge-node.agentmesh.svc.cluster.local:8080
- --issuer=http://credential-provider.agentmesh.svc.cluster.local:8080
- --audience=agentmesh
- --cache-ttl=60s
--issuer is the iss of the assertions the provider signs; it serves the
matching discovery document and JWKS on its HTTP port. agentmesh-one names
the same URL:
--workload-issuer=http://credential-provider.agentmesh.svc.cluster.local:8080
--allowed-audiences=agentmesh
The mesh policy in manifests/mesh.yaml.tmpl binds the actor’s identity to a
role:
{
"roles": [
{ "name": "actor",
"allowed_services": ["system://mesh.catalog", "mcp://calc"],
"allowed_targets": ["*"] }
],
"bindings": [
{ "role": "actor",
"members": ["user:spiffe://substrate-actor.local/actor/ate-demo-sandbox/demo"] }
]
}
The subject is the SPIFFE ID Substrate gives the actor: trust domain
substrate-actor.local, then actor/<atespace>/<name>. The office node
publishes the calculator twice, as mcp://calc and mcp://payroll, so the
policy has something to refuse.
3. The actor’s egress policy
make actor NAME=demo
creates the actor from the demo template and gives it this EgressPolicy:
rules:
- http:
hostnames: ["edge-node.agentmesh.svc.cluster.local"]
ports:
numbers: [8080]
effects:
replaceHeaders:
- header: X-Mesh-Authentication
prefix: "Bearer "
credentialUri: ate-secret://agentmesh/mesh/biscuit
One host, one port, and the rule the gateway applies to it. A request without the header is forwarded unchanged, so the actor opts in by sending the placeholder; a request to any other destination is refused by the gateway before it leaves the cluster.
4. Calling the mesh from the actor
make call NAME=demo
== tools the mesh offers actor demo
12D3KooWMaj9ere9… mcp://calc/add Return a + b.
12D3KooWMaj9ere9… mcp://calc/multiply Return a * b.
== mcp://calc, allowed for the actor role
mcp://calc/add(2, 3) = 5.0
== mcp://payroll, not in the actor role
(no tool of mcp://payroll is visible to this actor; calling the office node directly)
mcp://payroll/add(2, 3) -> refused: auth rejected by 12D3KooWMaj9ere9…: biscuit: denied by no matching policies
Each line is a wget run inside the actor through Substrate’s ingress
router (ate-target-actor: ate-demo-sandbox/demo). The egress gateway’s
access log shows the actor’s certificate and the request it allowed; the
provider logs one mint per actor and serves the rest from its cache; the
control plane logs the exchange that produced the credential:
Border Crossing {"endpoint": "/token/exchange", "decision": "allow",
"subject": "spiffe://substrate-actor.local/actor/ate-demo-sandbox/demo", "roles": ["actor"]}
5. Same template, same policy, different identity
make rogue
creates a second actor, rogue, from the same template with the same egress
policy. The gateway fetches a credential for
spiffe://substrate-actor.local/actor/ate-demo-sandbox/rogue, the mesh mints
one with no roles, and the office node shows it nothing:
== tools the mesh offers actor rogue
== mcp://calc, allowed for the actor role
mcp://calc/add(2, 3) -> refused: the mesh shows this actor no MCP provider at all (no role binds its identity)
Add user:spiffe://substrate-actor.local/actor/ate-demo-sandbox/rogue to the
actor role’s members in manifests/mesh.yaml.tmpl, apply the ConfigMap,
and push the policy:
make policy
make call NAME=rogue
The running actor is allowed on its next request; an actor that already held
a credential changes within --cache-ttl. Removing the member revokes it the
same way. Neither step touches the actor, its template, or Substrate.
When Substrate serves its own issuer
Substrate’s egress gateway can already mint and inject a JWT for the calling
actor (actorJwt in replaceHeaders), signed by the control plane with
iss set to Substrate’s --actor-jwt-issuer. What is missing today is a
server for that issuer’s discovery document and keys; it is tracked in
agent-substrate/substrate#1756.
When it lands, the provider goes away and two things change:
# EgressPolicy
replaceHeaders:
- header: X-Mesh-Authentication
prefix: "Bearer "
actorJwt: {audiences: ["agentmesh"], expirationSeconds: 600}
# agentmesh-one
--workload-issuer=https://idp.ate-system.svc
The mesh policy keeps the same subjects. The edge node exchanges the actor JWT on each request the way it exchanges any platform JWT.
Cleaning up
make down # the actors and the agentmesh namespace
make clean # the kind cluster