This example runs one AI agent under the full opt-in posture: a per-run least-privilege
database identity (C1/C2/C5/C6), default-deny egress (C8), mediated tool calls (C9), and
optional image pinning (C12). Each block in perch.yaml is independent —
remove any and the agent still runs.
The agent code lives in assistant/ — a tiny stdlib-only demo that
prints its injected identity, confirms a brokered DB credential was handed to it, and
proves mediation by making one non-allow-listed tool call (the gateway refuses it).
Replace it with your real agent; the perch.yaml posture stays the same.
perch -f examples/secure-agent/perch.yaml validate # structural check, no Docker
export ANTHROPIC_API_KEY=... # whatever your agent needs at runtime
perch -f examples/secure-agent/perch.yaml upPerch starts Postgres, mints the agent a per-run, read-only database credential
(not a static password), puts the agent on an internal network with a default-deny
egress proxy that only allows api.anthropic.com, and launches the agent's MCP
mediation gateway sidecar.
The agent must talk to its gateway instead of directly to MCP servers — that's what makes every tool call mediated. Generate the client config:
perch -f examples/secure-agent/perch.yaml mcp-config assistant{
"mcpServers": {
"perch-gateway": {
"type": "http",
"url": "http://perch-secure-agent-demo-mcp-assistant:8900/",
"headers": { "Authorization": "Bearer <per-agent token>" }
}
}
}Drop that into your agent's MCP client config (the mcpServers schema used by Claude
Desktop / Claude Code and compatible clients). The agent now sees one MCP server — the
gateway — which multiplexes to the real upstreams in mcp.servers, but only forwards
the allow-listed tools. Because egress is locked down, the gateway is the agent's only
outbound path, so it cannot reach an upstream directly.
The Authorization bearer (C1) is a per-agent token sealed in .perch/state.json; the
gateway requires it on every request, so a co-resident container can't use the gateway.
For an MCP client that can't send a header, set mcp: { auth: false } (and rely on the
network boundary instead).
| You declared | What happens at runtime |
|---|---|
identity: { scopes: { db: read } } |
A fresh Postgres role per run, SELECT-only, expiring on ttl. No static DATABASE_URL password. |
egress: { allow: [api.anthropic.com] } |
Internal network + filtering proxy; any other host is refused. |
mcp: { allow: { tools: [...] } } |
Gateway authorizes every call; non-allow-listed tools get a JSON-RPC error and never reach the upstream. */list is filtered so the model never even sees them. |
| repeated denied tool calls | Folded into the tamper-evident audit log; the Detector quarantines the subject and the gateway then denies it outright. |
A repeatable, Docker-free end-to-end check of the gateway (real sockets + a real
subprocess, exercising the same perch.gateway code the container runs):
python tests/e2e_gateway.pySee THREAT_MODEL.md for the controls (C1–C12) and their documented residuals.