Inbound
A message can carry untrusted content and injection attempts straight into the agent's reasoning.
Agent Mailbox Access Protocol · on OpenShell
AMAP is a wire contract that lets sandboxed AI agents take part in message-based workflows through a trusted runtime that does not trust them. This walkthrough runs it on OpenShell. Press Next to walk through the ideas, watch each flow move through the real pieces, see how OpenShell supplies what AMAP needs, and then run it yourself.
The invariantThe runtime's authority does not depend on the agent or its connector behaving correctly. A request file cannot, on its own, grant permission to send, change the sending identity, or bypass policy.
Status: verified live on one host, under the scope this walkthrough describes. What the live runs proved.
The problem
It can be prompt-injected, buggy or compromised. Connecting it to a workflow does not make its decisions trustworthy, and message access brings three risks.
A message can carry untrusted content and injection attempts straight into the agent's reasoning.
A message can disclose information, or misuse an identity the agent should not control.
Anything the agent can read, it can leak. Credentials in its reach are credentials at risk.
What matters is not whether the agent can write a message. It is whether it can authorise one, choose the identity it goes out under, or step around policy.
The problem
what was delivered to it, from a place it cannot write.
a file saying what it would like to happen. Nothing more.
whether anything happens, to whom, and under which identity.
AMAP was designed for email workflows, where the runtime holds the mail credentials. This walkthrough uses amap-router-local, a reference runtime that carries no mail at all: no mail provider, no mail credentials, no network. It moves the same artifacts between agents on one machine, so what is left is exactly what the protocol asks of a runtime.
Isolation first
The protocol defines who may do what across a boundary. It does not create the boundary. Running each agent in isolation is the operational baseline that gives the protocol's guarantees meaning, and it is good practice even when nothing is malicious.
A compromised or confused agent damages its own workspace, not its neighbours' or the host's.
The runtime holds the sending identity, and the connector holds no credential. On OpenShell the sandbox gets placeholder credentials, and the agent never holds the real model key.
It runs as the operator's own uid, so chmod would succeed. A read-only mount makes the kernel refuse the write: EROFS.
The runtime binds each sender to its own outbox, named by the host.
Isolation first
The delivery process restarts if it dies. A damaged member can be deprovisioned and created again, and a recreated name is a new member with a fresh first sight.
Which agents take part is a rule the operator writes and the host evaluates. Nothing inside a sandbox can add itself.
Every check ends in pass, fail or unknown, and unknown is reported as a problem.
If your environment cannot provide one of these, the matching guarantee does not hold. It is not merely weaker. This walkthrough uses OpenShell for isolation. Its sibling, amap-deploy-sandy, does the same with sandy, and the Why OpenShell chapter shows how OpenShell supplies each property instead.
The pieces
The directory layout, message and verdict shapes as JSON Schema, and golden fixtures. Each side proves conformance against the fixtures, without the other.
Holds the graph of who may reach whom, binds replies, quarantines on first sight, writes the roster. Runs in its own container on the host with no network. Used unmodified.
MCP tools to read (inbox, delegation) and to request (inbox-submit), plus inbox-delivery, which injects deliveries into the session. No credentials, no allowlist. Used unmodified.
A sandbox per agent, with kernel-level filesystem and process confinement, a default-deny network and placeholder credentials. It supervises one main process, and its name is the agent's identity.
Turns one fleet policy into each sandbox's lanes, OpenShell policy and create command, and the router's config. It ships the mounts interceptor, then verifies that what runs is what was declared.
OpenShell contains the agent; AMAP governs what it says and to whom. Nothing OpenShell-specific enters amap-spec, the router or the connector.
The seam
Runtime and connector never call each other. They share directories under $AMAP_OPENSHELL_HOME on the host, and the design rests on which side can write which one. Everything the agent reads, it cannot forge; everything it writes is only a request.
| Path on the host | Written by | In the sandbox | Holds |
|---|---|---|---|
| instances/<name>/ | deployment | One agent's lanes, created by provision. The name is the OpenShell sandbox name and the agent's identity. | |
| inbox/ | runtime | read-only | Mail delivered to this agent. |
| peer/ | runtime | read-only | Tasks from agents allowed to send them. |
| outbox/ | agent | writable | Inert requests; the runtime's verdicts come back in results/. |
| payload/ | deployment | read-only | The connector's binaries, the main-process wrapper, the session lister, the MCP config and the agent's policy text, at /opt/amap/payload. |
| roster/ | runtime | read-only | Who exists in the fleet, at /opt/amap/roster. Never who may task whom. |
| router-state/ | runtime | host only | Ledgers, held requests and first-sight markers. Not a mount in any sandbox. |
"Read-only" here is two layers: a :ro bind mount, and a Landlock read_only entry in the sandbox's OpenShell policy. Only the outbox is read_write.
Two lanes
They are declared separately, and a pair of agents may sit on only one of them, so a sender can never pick which trust it is judged under.
inbox tool.On both lanes a reply goes back to the sender and nowhere else, even against the direction of a delegation edge. The example fleet uses only delegation: alpha may task beta, and beta may not task alpha.
Knowing it holds
The deployment's verify has six sections: the install, the router config, the gateway, the members, the router's container and the router's health. For each member it checks the sandbox against its recorded ID, the effective policy, the container's mount table, a write attempted from inside, and that inbox-delivery is running. Every check ends one of three ways.
Established from the thing itself: the container's mount table, not the host directory; a write attempted inside the sandbox; the router's own status, not a guess.
Known to be broken. A stopped router is a FAIL, not an UNKNOWN: its evidence is stale, not missing.
Could not be answered, as with an unreadable file. Reported as a problem: verify exits 1, and it is never rounded to a pass.
The fleet roster keeps the same honesty. It lists who exists so agents can find addresses, and never who may task whom. The verdict in outbox/results is the only report of what happened.
Why OpenShell
| AMAP needs | OpenShell, with this deployment, provides it as | Shown live |
|---|---|---|
| A space per agent | One sandbox per agent. provision creates each agent's lanes on the host and passes them to that sandbox alone as bind mounts. OpenShell does not stop two sandboxes sharing a host path, so exclusivity is the tooling's job: verify checks it, and the interceptor refuses a wrong mount at creation. The router's writes appear inside the running sandbox with no delay. | L1 · Unknown 1 |
| An identity it cannot forge | The sandbox name, read on the host and mapped to <name>@<fleet_domain>. The agent cannot rename its sandbox. A deleted and recreated name gets a new ID, so membership is the pair (name, OpenShell ID), and verify fails when the ID changes. | from OpenShell's docs and code |
| Read-only mounts | Two independent layers. A :ro Docker bind, in a container with every capability dropped and no-new-privileges. Under it, Landlock: the policy lists the inbox, peer, roster and payload as read_only, only the outbox as read_write, every other path inaccessible, locked at creation. A write fails with EROFS. | L1 · Pass criterion 3, Unknown 2 |
| A supervised process | OpenShell supervises one main process. Here it is the deployment's wrapper on the read-only payload: it runs inbox-delivery in its own restart loop, then claude. The daemon injects over Claude Code's own Unix socket. | L1 · Pass criterion 2, Unknown 4 |
| Operator-owned membership | The tooling decides, from the fleet policy, which sandboxes get lanes. A sandbox without lanes is simply not in the fleet. membership.json records (workspace, name, ID), and provision refuses a name whose sandbox exists under a different ID. | the tooling's own rule |
The default policy denies all egress. The provider profile adds one endpoint, the model's, for the image's real node and claude binaries. Every denial is logged. L1 · Pass criterion 4
The operator's own uid:gid owns every lane and runs the router and every sandbox (D7). Agents are separated by container and per-sandbox mounts, not by uid, and the router writes 0600. L1 · Unknown 7
Host bind mounts need the gateway's admission checks off, an "unsafe operator override". Any caller that may create a sandbox can then mount any host path. That sets the scope, next.
Why OpenShell · the scope
The router, the payload, every lane and every sandbox are on one OpenShell host, and every account and process on it acts for one operator (D17). The gateway is dedicated to this work, listens on loopback only, has no OIDC, and runs no other sandboxes. This is the designed scope, not a temporary risk.
Several operators on one host, or a fleet of hosts, need work first: gateway authentication and per-operator workspaces, an interceptor that becomes a real boundary, a uid per operator, and checks that stop assuming the host is ours. Across hosts there is no delegation: the spool is local directories, so each host is an independent fleet. None of it is planned. DESIGN.md "Widening the scope" lists it, and issue #1 tracks it.
Why OpenShell · the mounts interceptor
With admission off, a hand-typed sandbox create, or a script, that mounts the wrong path would silently break a member's exclusive lanes, and verify would catch it only afterwards. The interceptor is this deployment's code, beside the gateway: one validate binding on CreateSandbox, registered fail_closed. It restores, at creation, that a member's lanes are its own and that only its own outbox is writable.
instances/<name>/ lanes, the payload or the roster. Not a parent, not a subtree.provision renders. Only the member's own outbox may be read-write.bind.Under the scope it is defence in depth: protection against the operator's own mistakes and misbehaving local tooling (D17), not a boundary between people. A create without mounts cannot touch the spool, so it is outside the interceptor's job. docs/INTERCEPTOR.md has the full rule.
Run it yourself · 1 of 4
The example in examples/vms/proxmox makes a fresh Ubuntu 24.04 VM and provisions it: it checks the kernel for Landlock, installs Docker and OpenShell (pinned to 0.1.2), writes the gateway config with the scope's posture, and checks it. Both scripts are dry runs by default. In the VM, as the account that will own OpenShell:
./provision-guest.sh # checks, and the plan
./provision-guest.sh --applyOn another host, meet the requirements and merge the gateway fragment that gateway-config prints. The gateway must listen on loopback only, with no other sandbox on it.
Clone this repository. siblings puts amap-router-local, amap-connector-claude and amap-deploy-sandy beside it at the commits in siblings.json, and clones or fast-forwards amap-spec. amap-deploy-sandy is not optional: the fleet-policy core is imported from it.
git clone https://github.com/proofpoint/amap-deploy-openshell
cd amap-deploy-openshell
python3 amap-openshell.py siblings # the plan
python3 amap-openshell.py siblings --applyexport AMAP_OPENSHELL_HOME="<absolute path of a new directory>" export CLAUDE_CODE_VERSION="<the Claude Code version you measured>"
Every command runs from the repository's root. $AMAP_OPENSHELL_HOME must not be inside, or contain, this repository or a sibling checkout.
Run it yourself · 2 of 4
examples/vms/proxmox/provision-guest.sh --home "$AMAP_OPENSHELL_HOME"
examples/vms/proxmox/provision-guest.sh --home "$AMAP_OPENSHELL_HOME" --apply
# read-only: the fragment, and whether gateway.toml has it
python3 amap-openshell.py gateway-config[[openshell.gateway.interceptors]] name = "amap-openshell-mounts" grpc_endpoint = "unix://$AMAP_OPENSHELL_HOME/run/interceptor.sock" allow_insecure_transport = true failure_policy = "fail_closed" binding_policy = "exact" timeout = "2s" [[openshell.gateway.interceptors.bindings]] rpc = "openshell.v1.OpenShell/CreateSandbox" phases = ["validate"]
gateway.toml and restarts the gateway. It also records the home, so later commands find it without the export (D25).0600, in a 0700 directory outside every mount source.On a host that is not the example VM, run the dry run and do what it prints.
Run it yourself · 3 of 4
# the key: exported in this terminal, never printed test -n "$ANTHROPIC_API_KEY" || echo "set ANTHROPIC_API_KEY first" python3 amap-openshell.py bring-up --claude-code-version "$CLAUDE_CODE_VERSION" python3 amap-openshell.py bring-up --claude-code-version "$CLAUDE_CODE_VERSION" --apply # the last line, only when verify exits 0: # AMAP is up
In order, stopping at the first that fails, and skipping whatever is already done:
install: the payload, the fleet policy, the rosterprovision alpha and betaverifyAMAP is up
It never deletes a sandbox, a container, an image or a profile, never edits gateway.toml and never restarts the gateway. Its preflight refuses when the interceptor is not accepting on its socket. The key reaches sandbox create and nothing else. Any other ending names the stage that stopped it, with a non-zero exit. Starting the router is the approval: its first poll of each member is first sight, and nothing staged before it is ever delivered. To see each piece, follow the runbook's steps 3 to 9 one at a time instead.
python3 amap-openshell.py list # each line: member python3 amap-openshell.py verify # exit 0 before going on
Run it yourself · 4 of 4
openshell --workspace default sandbox connect alpha # terminal 1 openshell --workspace default sandbox connect beta # terminal 2: watch it arrive
Ask alpha, in plain words:
Read /opt/amap/roster/roster.json and find beta's address. Then ask beta, through inbox-submit, to list the files in its workspace and report back.
The address has the form beta@openshell.<host>.internal. The reply must arrive in each session as a turn, not be found by polling.
You have just watched the first flow for real. The runbook then breaks each guarantee on purpose: a write to a lane fails with Read-only file system, both layers show in the policy and the mounts, beta's new task for alpha is queued_for_human, a call to a mail host is DENIED, an edited router.json is a FAIL, and so is a stopped router.
Proven live
Each run was on a fresh host, with the example fleet: alpha may task beta. Every outcome is PASS, FAIL or UNKNOWN, as recorded in docs/POC-REPORT.md.
| Run | What it established | Outcome |
|---|---|---|
| L1 proof of concept | alpha delegated to beta, beta's daemon reported delivered, and beta's reply, real work, reached alpha. A write to the inbox lane failed at both layers with EROFS. A call to a mail host was denied and logged. beta tasking alpha was held at the router. Host writes appear inside a running sandbox; a lane mounted but missing from the policy is unreadable; the shared uid holds end to end. | PASS Pass criteria 2-5, Unknowns 1-7 |
| L1 Pass criterion 1 | Every live document of five classes passed amap-spec's validator. Three classes had no live document, so the runner said UNKNOWN. The report accounts for each by hand: two are not emitted by this deployment, and deliver-notice belongs to the mail lane, which this fleet does not carry. | UNKNOWN from the runner, PASS as accounted |
| L2 the tooling | bring-up --apply ended AMAP is up on a rebuilt host, after two stops that each named their stage; a rerun skipped every stage before verify. Given the runbook's prompt, alpha chose to delegate, and the task and the reply each arrived as a turn. Every break-it experiment passed, and one prediction was corrected: a stopped router is FAIL, not UNKNOWN. | PASS |
| L2 re-check | l1-run --only 8 against the fleet the verbs provisioned, once --only 7 had planted a delegation. | PASS for Pass criteria 2-5 and Unknowns 1-7; Pass criterion 1 UNKNOWN, as in L1 |
Proven live · the interceptor on
A VM built fresh, on the full stack: the interceptor, the per-host fleet domain and the home record. Four problems were found, each stop named its cause, and each fix landed with a test the old code fails. The final recreate ended AMAP is up, and verify, run from a fresh shell with no export, exited 0.
| Check | Outcome |
|---|---|
The gateway accepts the interceptor block, and both members provision through it (evaluate allow, once each) | PASS |
| A create mounting another member's outbox is refused by the interceptor, with the right reasons | PASS |
| Latency well inside the 2 s timeout; the gateway keeps working after the interceptor restarts | PASS |
Addresses use the derived domain, alpha@openshell.<host>.internal | PASS |
| Step 10, both directions: alpha found beta in the roster, the task and the reply arrived as turns | PASS |
l1-run: Pass criteria 2-5, Unknowns 1, 2, 4, 5, 6, 7 | PASS |
l1-run: Pass criterion 1, for L1's three classes | UNKNOWN |
l1-run: Unknown 3. Its probe is a non-member sandbox that mounts alpha's lanes, and the interceptor refused it before it existed, as it should. The property is Landlock's, settled PASS in L1 and L2 with the interceptor off. For this run it stays UNKNOWN, never a pass. | UNKNOWN |
Not established yet. The mail lane: this fleet carries none. Whether an agent's own settings.json changes its receiver setting. The exact wire spelling of field names the interceptor reads (it reads both). provision-guest.sh replacing its own interceptor block when the fragment changes. Podman, Kubernetes, and anything beyond one host and one operator.
amap-deploy-openshell · Apache License 2.0 · AMAP is a draft contract; the spec repository is normative.