AMAP on OpenShell

Agent Mailbox Access Protocol · on OpenShell

An agent can ask. Only the runtime can act.

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

An agent is an untrusted process

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.

Inbound

A message can carry untrusted content and injection attempts straight into the agent's reasoning.

Outbound

A message can disclose information, or misuse an identity the agent should not control.

Credentials

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

AMAP's one move: a request is never permission

agent

Reads

what was delivered to it, from a place it cannot write.

agent

Writes an inert request

a file saying what it would like to happen. Nothing more.

runtime

Decides

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

AMAP assumes the agent is already contained

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.

One contained space per agent

limits the blast radius

A compromised or confused agent damages its own workspace, not its neighbours' or the host's.

No credentials in the sandbox

nothing to leak

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.

Read-only by mount, not by permission bits

the agent owns its files

It runs as the operator's own uid, so chmod would succeed. A read-only mount makes the kernel refuse the write: EROFS.

Identity given from outside

an agent must not name itself

The runtime binds each sender to its own outbox, named by the host.

Isolation first

…and run it like infrastructure

Supervised, and recoverable

damage does not accumulate

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.

Membership is the operator's decision

joining is a security act

Which agents take part is a rule the operator writes and the host evaluates. Nothing inside a sandbox can add itself.

Observable, in three outcomes

"could not tell" is not "fine"

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

Five parts, one boundary: they meet only at the contract, and only one side is trusted

The protocol

contract
amap-spec

The directory layout, message and verdict shapes as JSON Schema, and golden fixtures. Each side proves conformance against the fixtures, without the other.

The runtime

trusted
amap-router-local

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.

The connector

untrusted
amap-connector-claude

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.

The isolation environment

sandbox
OpenShell

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.

The deployment

operator's tool
amap-deploy-openshell

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

The boundary is a directory tree

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 hostWritten byIn the sandboxHolds
instances/<name>/deploymentOne agent's lanes, created by provision. The name is the OpenShell sandbox name and the agent's identity.
inbox/runtimeread-onlyMail delivered to this agent.
peer/runtimeread-onlyTasks from agents allowed to send them.
outbox/agentwritableInert requests; the runtime's verdicts come back in results/.
payload/deploymentread-onlyThe connector's binaries, the main-process wrapper, the session lister, the MCP config and the agent's policy text, at /opt/amap/payload.
roster/runtimeread-onlyWho exists in the fleet, at /opt/amap/roster. Never who may task whom.
router-state/runtimehost onlyLedgers, 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

Mail and delegation carry different trust

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.

Mail

a b
  • Mutual: both sides must list each other.
  • Delivery is a content-free doorbell; the agent reads with the inbox tool.
  • Content is untrusted: reported, not obeyed.

Delegation

a b a may task b
  • Directed: a may task b says nothing about b tasking a.
  • The request itself is injected into the session.
  • The runtime authorised it, so it is meant to be acted on, within what the agent may already do.

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

Three outcomes, never two

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.

PASS

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.

FAIL

Known to be broken. A stopped router is a FAIL, not an UNKNOWN: its evidence is stale, not missing.

UNKNOWN

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

Five properties AMAP needs from a host, and how OpenShell provides each

AMAP needsOpenShell, with this deployment, provides it asShown live
A space per agentOne 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 forgeThe 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 mountsTwo 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 processOpenShell 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 membershipThe 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

Egress only to the provider

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

One shared uid

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

The price: bind mounts

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

One host, one operator

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.

What it protects

  • Agents from the host. Each agent's inbox and peer lanes are read-only to it, in both the mount and Landlock. Its only egress is the provider's endpoint. It has no route to the gateway.
  • Agents from each other. Each member's lanes are its own. Delegation is directed by the task graph. The router binds each reply to the sender it answers.

What it does not protect

  • The host from its own operator. Anything that can call the gateway on loopback is trusted, because it is the operator.
  • So the mounts interceptor guards against the operator's mistakes, not against another person.

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

A wrong mount is refused at creation, not found afterwards

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.

  • A bind source that is not exactly one of that name's own: its instances/<name>/ lanes, the payload or the roster. Not a parent, not a subtree.
  • A read-only flag that differs from what provision renders. Only the member's own outbox may be read-write.
  • A name outside the fleet, or a workspace that is not the fleet's.
  • A source with a symlink in it, or one that is not a normalised absolute path.
  • Anything it cannot check: a templated create, an unknown field, a driver other than Docker, a mount type other than bind.
  • Itself failing: a timeout, transport error or invalid result is a refusal. The gateway does not start until it listens.

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

A host, then the pieces

Linux 6.2+ (Landlock ABI 3)DockerOpenShell v0.1.2+Python 3.9+a Console API keythe Claude Code version you measured

1The host

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:

inside the VM
./provision-guest.sh            # checks, and the plan
./provision-guest.sh --apply

On 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.

2The pieces

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.

the code
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 --apply
every terminal, until the home is recorded
export 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

Deploy the mounts interceptor

from the clone, a dry run first
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
what it registers in gateway.toml
[[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"]
--home
Builds the interceptor's hash-locked virtualenv, installs a systemd user unit ordered before the gateway, merges the fragment into gateway.toml and restarts the gateway. It also records the home, so later commands find it without the export (D25).
fail_closed, exact
A timeout, transport error or invalid result is a refusal. The gateway does not start if the configured and declared bindings differ.
allow_insecure_transport
The gateway signs its calls to extensions by default, and this interceptor does not verify tokens. It opts out (D24), and the gateway logs a warning at every start. The boundary is the socket: 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

Bring it up

a dry run, then the real one
# 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:

  1. build the sandbox image, with the one uid:gid
  2. check the gateway config
  3. install: the payload, the fleet policy, the roster
  4. import the provider profile
  5. provision alpha and beta
  6. render the router's config and start the router
  7. verify

AMAP 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.

check it any time
python3 amap-openshell.py list     # each line: member
python3 amap-openshell.py verify   # exit 0 before going on

Run it yourself · 4 of 4

Send your first delegation

two terminals
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

What the live runs established

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.

RunWhat it establishedOutcome
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

The L2 rerun, with the mounts 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.

CheckOutcome
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 reasonsPASS
Latency well inside the 2 s timeout; the gateway keeps working after the interceptor restartsPASS
Addresses use the derived domain, alpha@openshell.<host>.internalPASS
Step 10, both directions: alpha found beta in the roster, the task and the reply arrived as turnsPASS
l1-run: Pass criteria 2-5, Unknowns 1, 2, 4, 5, 6, 7PASS
l1-run: Pass criterion 1, for L1's three classesUNKNOWN
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.