Status: unsupported until verified. Nothing in this repository has run
against a live OpenShell yet. This runbook is the procedure that verifies it. It
settles PLAN.md phase 1 and the L1 unknowns of IMPLEMENTATION-PLAN.md, and its
deliverable is docs/POC-REPORT.md, made from the template in step 9. Every
OpenShell claim in DESIGN.md stays [docs] or [unverified] until step 8
records evidence for it. UNKNOWN is never a pass: a check you could not run is
reported as UNKNOWN.
The scenario is the one in PLAN.md phase 1: two agents, alpha and beta, in two OpenShell sandboxes, with the router on the host. Alpha may task beta. Beta may not task alpha. It is the same demo as amap-deploy-sandy's tutorial, so the two compare directly.
The kit writes files and never runs a program. It does not run openshell or
docker, and it does not invent a sandbox ID. Every command that touches
OpenShell, Docker or the router is yours to run, in the order below.
l1-run runs steps 0 to 9 below as one command. Unlike the kit, it runs
openshell, docker and the router, so read the steps once before you use it,
and first set AMAP_OPENSHELL_HOME, CLAUDE_CODE_VERSION and
ANTHROPIC_API_KEY (step 0). It finds amap-router-local, amap-connector-claude
and amap-spec beside this repository, where siblings --apply puts them. Their
variables are needed only when a checkout is not beside this one. Without --apply it prints each command
and runs nothing:
python3 amap-openshell.py l1-run
python3 amap-openshell.py l1-run --apply
python3 amap-openshell.py l1-run --from 7 --apply
python3 amap-openshell.py l1-run --only 8 --apply- Gates. Step 0 always runs: it checks the variables, reports where it found each sibling checkout, the key (never
printed) and D6's posture as the gateway's file states it. Step 2, the live
posture, runs whenever the selection includes a step from 3 to 8, even with
--fromor--only. If either fails, the runner refuses to go on. - Evidence. Every command it runs is recorded under
$AMAP_OPENSHELL_HOME/evidence/step-<N>/, with its argv, output, exit status and time. The key and any messaging token are redacted. A record is written once and never rewritten. - The report. Step 9 drafts
$AMAP_OPENSHELL_HOME/evidence/POC-REPORT.mdfrom the evidence. Read every line, fill in the columns and tags it leaves to you, and then move it todocs/POC-REPORT.md. - The provider profile. Step 3 also reads
node's andclaude's real paths from the built image, renders$AMAP_OPENSHELL_HOME/providers/amap-claude-code.jsonfrom them, lints it, and imports it. When the gateway already has a copy that differs, it updates that copy instead. It never deletes a profile or a provider. A rerun imports nothing. - Reruns. A step that is already done is skipped, with a note. A rerun after a complete run changes nothing but the evidence it adds.
- What it never deletes. No sandbox except the throwaway probe of Unknown 3 that it created itself, no lane, and no router container. It never edits or restarts the gateway.
- Exit codes. 0 means every selected step completed and every check is PASS. 1 means a step failed or the runner refused. 3 means every step completed but some check is FAIL or UNKNOWN: UNKNOWN is never a pass.
- Where its evidence differs from the manual commands. The lister cannot
be run through
sandbox execas step 8 shows: the supervisor starts an exec'd process itself, so it is not a descendant of the main process and the lister refuses it. The runner loads the lister as a module and asks it for the sessions instead. And the router removes each outcome file once it has read it, so there is nothing to list in the outbox. The runner reads the router's audit log, which the agents cannot write, for the outcomes.
Prerequisites (gate G3).
- Linux 6.2 or later (Landlock ABI 3), with Docker. Docker is the only compute driver this deployment uses (D3).
- OpenShell
v0.1.2or later, installed with your approval. - A Console API key (D10) for this deployment's provider,
amap-claude-code, exported asANTHROPIC_API_KEYin the terminal that creates each sandbox. - The router image, which step 6 builds from the amap-router-local checkout.
- The Claude Code version you have measured and recorded. There is no default.
Accepted gateway posture (D6, Decision 1). Bind mounts need the gateway's admission checks off (DESIGN.md section 6), so any caller that may create a sandbox on this gateway can mount any host path the container runtime can see. That is acceptable only if all of these hold, and step 2 checks each one:
- the gateway is dedicated to this work and reachable on loopback only;
- it has no OIDC, and no other user holds its credentials;
- no other sandboxes run on it.
If the host is shared, stop: the interceptor of DESIGN.md section 6 has to come first. Record the posture you accepted in the report.
Variables. Set these in every terminal you use:
export AMAP_OPENSHELL_HOME="<absolute path of a new directory>"
export CLAUDE_CODE_VERSION="<the Claude Code version you measured>"The sibling checkouts. l1-run finds amap-router-local,
amap-connector-claude and amap-spec (or .amap-spec) beside this repository or
beside one of its parent directories, and step 0 reports where it found each.
These variables are needed only when a checkout is not beside this one. When
one is set and not empty, it is the only place searched:
export AMAP_ROUTER_REPO="<absolute path of the amap-router-local checkout>"
export AMAP_CONNECTOR_REPO="<absolute path of the amap-connector-claude checkout>"
export AMAP_SPEC_DIR="<absolute path of the amap-spec directory>"The lines you run by hand in steps 6, 8 and 9 spell the checkouts as
$AMAP_ROUTER_REPO and $AMAP_SPEC_DIR. Set those two before you run those
lines yourself.
AMAP_OPENSHELL_HOME must not be inside, or contain, this repository or any of
the sibling checkouts. The kit refuses it if it is. The parent of the directory
must exist.
Every command runs from this repository's root. Change to it first. Host
paths are always rooted at one of the variables above, or are relative to this
repository. Paths inside a sandbox are absolute, and placeholders look like
<file>.
Where the flags come from. Each OpenShell subcommand below is cited at
OpenShell main@acbac9c, and its flags are checked against
crates/openshell-cli/src/main.rs at that revision. The fleet's workspace is
default, from examples/fleet.json, and every command names it with the
global --workspace flag (main.rs:466-475), so an OPENSHELL_WORKSPACE
variable cannot move it.
Record the OpenShell version, which the report needs (--version is global,
main.rs:497-499):
openshell --versionThe recipe is image/Dockerfile (D4). Its build arguments are the Claude Code
version and the one uid:gid (D7): the account in the image is the same uid:gid
as the router container and every sandbox's run_as_user. Use your own.
docker build --build-arg CLAUDE_CODE_VERSION="$CLAUDE_CODE_VERSION" --build-arg SANDBOX_UID="$(id -u)" --build-arg SANDBOX_GID="$(id -g)" -t amap-openshell-agent image/The build refuses an empty, non-numeric or zero uid or gid. The tag
amap-openshell-agent is what step 3 passes as --image.
The claude flags the sandboxes use (--settings and
--dangerously-skip-permissions) were read from Claude Code 2.1.284's
claude --help. If you pin another version, check that its claude --help
lists both.
If you built the host with examples/vms/proxmox/provision-guest.sh, it has
already written this file, restarted the gateway and registered it. Run the
checks below anyway.
Merge the fragment into the gateway's configuration file. The fragment is DESIGN.md
section 6. Step 3 writes it to $AMAP_OPENSHELL_HOME/gateway-fragment.toml for
reference, and its text is in l1_kit.py. The gateway reads
$XDG_CONFIG_HOME/openshell/gateway.toml when neither --config nor
OPENSHELL_GATEWAY_CONFIG is set. For the Debian/Ubuntu package's systemd user
service that is usually .config/openshell/gateway.toml in your home directory
(OpenShell main@acbac9c:docs/how-it-works/gateways/configuration.mdx:11 and
32). The commands below fall back to .config in your home directory when
XDG_CONFIG_HOME is unset, as it usually is on Ubuntu:
GATEWAY_TOML="${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateway.toml"
mkdir -p "$(dirname "$GATEWAY_TOML")"The file needs the schema version and the Docker driver selected, and then the fragment's tables:
[openshell]
version = 2
[openshell.gateway]
compute_driver = "docker"
[openshell.drivers.docker]
allow_driver_config = true
enable_bind_mounts = true
[openshell.drivers.docker.resource_admission]
enabled = falseThe schema version is configuration.mdx:47-48 and 69-70, and the driver selector is configuration.mdx:66. The three driver settings are OpenShell main@acbac9c:docs/how-it-works/sandboxes/runtimes.mdx:120-131. If your file already has other tables, keep them and add only what is missing.
Check the D6 posture before you restart the gateway:
grep -n "bind_address" "$GATEWAY_TOML"
grep -n "oidc" "$GATEWAY_TOML"Neither command should print a line. With no bind_address, the gateway uses
its built-in 127.0.0.1:17670 listener, which is loopback
(configuration.mdx:36-38). If either prints a line, read it: a non-loopback
address or an OIDC table breaks the accepted posture.
Validate the file and restart the gateway. The systemd user service is the package default (configuration.mdx:1219-1223). If yours is started another way, restart it the way you started it:
openshell-gateway config preflight --path "$GATEWAY_TOML"
systemctl --user restart openshell-gatewayIf openshell status then reports no active gateway, the CLI never registered
the package's gateway. That happens when the gateway wasn't running while the
installer ran. Register it: with --local and this name, the CLI collects its
client certificate from the package's TLS directory (OpenShell
main@acbac9c:docs/how-it-works/gateways/authentication.mdx:52):
openshell gateway add https://127.0.0.1:17670 --local --name openshell
openshell statusThen check that the listener is loopback and that no other sandbox exists.
sandbox list is OpenShell main@acbac9c:crates/openshell-cli/src/main.rs:1610:
ss -ltn
openshell --workspace default sandbox listThe listener on port 17670 must be 127.0.0.1, and the list must be empty. If
either is not true, stop and record why.
l1-kit prepare writes everything the host needs before any sandbox exists,
under $AMAP_OPENSHELL_HOME: the payload (copied byte for byte from the
connector checkout and this repository), every member's lanes, each member's
policy and create command, the fleet policy and the gateway fragment. It
creates $AMAP_OPENSHELL_HOME/roster empty. OpenShell refuses a bind mount
whose source does not exist (OpenShell
main@acbac9c:crates/openshell-driver-docker/src/lib.rs:3753-3761), so this
comes first.
It is a dry run without --apply. Read the dry run first:
python3 amap-openshell.py l1-kit prepare --home "$AMAP_OPENSHELL_HOME" --fleet examples/fleet.json --run-as "$(id -u):$(id -g)" --image amap-openshell-agentEvery path line starts with would create. Then write:
python3 amap-openshell.py l1-kit prepare --home "$AMAP_OPENSHELL_HOME" --fleet examples/fleet.json --run-as "$(id -u):$(id -g)" --image amap-openshell-agent --apply--run-as is the same uid:gid as the image's build arguments in step 1. The
kit renders it into both policies. It refuses root.
--restart-policy. OpenShell v0.1.2 has no --restart-policy flag on
sandbox create, and main has one (OpenShell
main@acbac9c:crates/openshell-cli/src/main.rs:1538-1544). The default is the
command that works on both. If openshell sandbox create --help lists
--restart-policy on your host and you want it, add --restart-policy to the
command above. The kit then puts --restart-policy on-failure in each create
command. Running the command again is safe: it reports present for what is
already right and would update for what differs.
Step 4's --provider amap-claude-code names a profile this deployment ships in
providers/amap-claude-code.json. OpenShell does not load its own example
profile (OpenShell main@acbac9c:providers/claude-code.yaml:4-6), and it would not
load this one by itself either: a profile must be imported. And a
rule's binaries match the real path of the executable that opens the
connection, which for an npm-installed Claude Code is not /usr/bin/claude
(OpenShell main@acbac9c:docs/how-it-works/policies/network-rules.mdx:57-77). So
the shipped file has no binaries, and the kit renders a copy with the image's
real paths. Read those from the built image, and pass each as --binary:
docker run --rm --network none --entrypoint sh amap-openshell-agent -c 'printf "node=%s\nclaude=%s\n" "$(readlink -f "$(command -v node)")" "$(readlink -f "$(command -v claude)")"'
python3 amap-openshell.py l1-kit profile --home "$AMAP_OPENSHELL_HOME" --binary "<node path>" --binary "<claude path>" --apply
openshell --workspace default provider profile lint -f "$AMAP_OPENSHELL_HOME/providers/amap-claude-code.json"
openshell --workspace default provider list-profiles -o json
openshell --workspace default provider profile import -f "$AMAP_OPENSHELL_HOME/providers/amap-claude-code.json"Leave out --apply on the kit line for a dry run. Import the profile only when
list-profiles does not show amap-claude-code. The import is create-only
(OpenShell main@acbac9c:docs/how-it-works/providers/profiles.mdx:308, 317). When
the listing shows a copy that differs, add that copy's resource_version to a
copy of the rendered file and update it. The gateway refuses an update without a
non-zero resource_version
(OpenShell main@acbac9c:crates/openshell-server/src/grpc/provider.rs:2894-2908):
openshell --workspace default provider profile update amap-claude-code -f <file with resource_version>The profile goes into the fleet's workspace, not the global scope, which needs
Platform Admin (profiles.mdx:247). Every subcommand and flag above is at
OpenShell main@acbac9c and at v0.1.2, on the same lines of
crates/openshell-cli/src/main.rs: provider 583-585, provider list-profiles -o/--output 917-927 (dispatch 3948-3957; v0.1.2 3938-3947), provider profile 929-931, provider profile import -f 1108-1126, provider profile update <id> -f 1128-1141, provider profile lint -f 1143-1161, and the json
output format 766-771. The implementations are
crates/openshell-cli/src/commands/provider.rs:1590-1615 and 1701-1830, also
identical at v0.1.2.
L1 must confirm two things, and neither is a new check. First, that after an import the listing's fields compare equal to the file, so a rerun does not update every time. Second, which of the two listed binaries opens the connection.
Each member has a one-line create command in
$AMAP_OPENSHELL_HOME/commands/create-<name>.sh. Read it once. It is the
openshell sandbox create argument vector from the renderer, unchanged.
A script can run it with no terminal, because it carries three flags. --detach
returns once the sandbox is ready instead of attaching to it. --tty gives the
main process a terminal whatever the caller has. --auto-providers creates the
amap-claude-code provider without asking. They are OpenShell
main@acbac9c:crates/openshell-cli/src/main.rs:1523-1555 (also v0.1.2,
main.rs:1523-1547). --detach is run.rs:649-653 and 1165-1172 and
docs/how-it-works/sandboxes/overview.mdx:29-33, --tty is run.rs:641-642 and
675, and --auto-providers is commands/provider.rs:481-497. The provider is
created from ANTHROPIC_API_KEY without a question
(OpenShell main@acbac9c:docs/how-it-works/providers/overview.mdx:344-356).
That provider is created from the profile imported in step 3 and from
ANTHROPIC_API_KEY in the creating terminal (commands/provider.rs:413-447 and
508-521; crates/openshell-providers/src/discovery.rs:33-63). The settings file's
env switches off Claude Code's non-essential traffic (Claude Code CHANGELOG
2.0.17, 2.1.105 and 2.1.120), and the profile allows only the Anthropic API
either way.
Each command returns when its sandbox is ready, so both run from one terminal.
The main process is the payload's wrapper, which runs claude with
--settings /opt/amap/payload/claude-settings.json and bypass permissions (D8).
Before it starts anything, the wrapper seeds Claude Code's first-run answers
(D9) into /sandbox/.claude.json.
test -n "$ANTHROPIC_API_KEY" || echo "set ANTHROPIC_API_KEY first"
sh "$AMAP_OPENSHELL_HOME/commands/create-alpha.sh"
sh "$AMAP_OPENSHELL_HOME/commands/create-beta.sh"Attach with sandbox connect to look at a session:
openshell --workspace default sandbox connect alphaIf Claude Code is showing any question, D9's seed missed it. Write down exactly what it asked, answer it, and put it in the report as a finding for D9.
Ctrl-D and Ctrl-P Ctrl-Q both disconnect without ending the main process
(OpenShell main@acbac9c:docs/how-it-works/sandboxes/overview.mdx:263-272).
sandbox connect is main.rs:1730. If a create fails, read its message and stop.
Do not go on with one sandbox missing.
sandbox get --output json prints the sandbox's id, name and workspace
(OpenShell main@acbac9c:crates/openshell-cli/src/run.rs:2789-2791 and
docs/how-it-works/sandboxes/overview.mdx:603-607; the command is main.rs:1594-1606).
The snippet below asserts the name and the workspace before it prints the ID, so
an ID from the wrong sandbox is refused. Then l1-kit record writes it into
membership.json:
ALPHA_ID=$(openshell --workspace default sandbox get alpha --output json | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["name"]=="alpha" and d["workspace"]=="default"; print(d["id"])')
python3 amap-openshell.py l1-kit record --home "$AMAP_OPENSHELL_HOME" alpha "$ALPHA_ID" --applyBETA_ID=$(openshell --workspace default sandbox get beta --output json | python3 -c 'import json,sys; d=json.load(sys.stdin); assert d["name"]=="beta" and d["workspace"]=="default"; print(d["id"])')
python3 amap-openshell.py l1-kit record --home "$AMAP_OPENSHELL_HOME" beta "$BETA_ID" --applyLeave off --apply to see what would be written. After alpha, only
membership.json exists. After beta, every member is recorded, and the kit also
writes selected.json (the router's discovery verdict) and router.json, and
creates $AMAP_OPENSHELL_HOME/router-state. Neither file exists before then, so
the router never sees a partial fleet.
The record is pinned to the ID: OpenShell reuses a deleted sandbox's name under a
new ID, and record refuses the new ID with an ID mismatch message. If you
delete and re-create a sandbox, that is a different sandbox. The kit never
deletes a file, so remove $AMAP_OPENSHELL_HOME/membership.json yourself and
record every member again.
The router starts last, so its first sight of each instance follows the
sandboxes. Its $AMAP_ROUTER_REPO/docker/run.sh derives the container's mounts
from router.json and refuses a state_dir that does not exist, which is why
record created $AMAP_OPENSHELL_HOME/router-state. The container runs as your
uid:gid (amap-router-local docker/run.sh:81), the same one as every sandbox (D7).
"$AMAP_ROUTER_REPO/docker/build.sh"
"$AMAP_ROUTER_REPO/docker/run.sh" --config "$AMAP_OPENSHELL_HOME/router.json" --detachCheck that it runs, and read its log for any refusal:
docker ps --filter name=amap-router-local
docker logs amap-router-localAttach to both agents in two terminals (sandbox connect alpha and
sandbox connect beta, as in step 4). Ask alpha:
Read
/opt/amap/roster/roster.jsonand find beta's address. Then ask beta, throughinbox-submit, to list the files in its workspace and report back.
Alpha reads the roster, which the router publishes to
$AMAP_OPENSHELL_HOME/roster/<roster-file> and every sandbox sees read-only. Alpha
calls submit, which writes an inert request into its outbox. The router checks
its graph, binds the sender to alpha's outbox, and places a notice in beta's
read-only peer lane. Beta's delivery daemon injects the request into beta's
session, where it arrives as a turn from the runtime-asserted sender. Beta
replies with submit, the router binds the reply to alpha, and it arrives in
alpha's session the same way. Alpha can check the router's verdict with
submit_result.
A delegation that is delivered and answered is the only proof that the whole chain works. Confirm that it was injected into beta's session, and not only found in its inbox: beta's terminal shows it as a turn, without beta polling.
Record, for each item, the command you ran, its output, and one outcome: PASS, FAIL or UNKNOWN. Put each into the report of step 9.
amap-spec/fixtures/validate.pygates every document the connector reads and writes;
Run check_document from the amap-spec validator on one live document of each
class the run produced. This shell function loads validate.py from
$AMAP_SPEC_DIR and prints PASS or FAIL with every error:
check() { # usage: check <fixture-name> <file>; the name's prefix picks the schema
PYTHONDONTWRITEBYTECODE=1 python3 - "$AMAP_SPEC_DIR" "$1" "$2" <<'EOF'
import importlib.util, json, os, sys
spec_dir, name, path = sys.argv[1:4]
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location("validate", os.path.join(spec_dir, "fixtures", "validate.py"))
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
with open(path, encoding="utf-8") as fh:
errors = mod.check_document(name, json.load(fh))
print(("FAIL " if errors else "PASS ") + name + "".join("\n " + e for e in errors))
sys.exit(1 if errors else 0)
EOF
}Then, one document of each class from the delegation of step 7. The request is
in its processed directory once the router has handled it, and in the outbox root while it
waits:
check roster-live.json "$AMAP_OPENSHELL_HOME/roster/<roster-file>"
check request-live.json "$AMAP_OPENSHELL_HOME/instances/alpha/outbox/processed/req-<id>.json"
check result-live.json "$AMAP_OPENSHELL_HOME/instances/alpha/outbox/results/<file>"
check peer-live.json "$AMAP_OPENSHELL_HOME/instances/beta/peer/notices/notice-<id>.json"
check message-live.json "$AMAP_OPENSHELL_HOME/instances/beta/peer/messages/notice-<id>.json"The schema for each class is picked by the fixture name's prefix. A class the
run did not produce (the mail-lane notice-, directory- and identity-
documents) is UNKNOWN in the report, not PASS. Say which check ran on which
schema: this is the validator's document-level check, and it says nothing about
the runtime-enforced items in step 9.
the connector's operational checks pass;
These are the operational checks that $AMAP_SPEC_DIR/fixtures/validate.py prescribes
for what no document validator can see. The run above exercises them: the
connector ran against inbound trees it cannot write. Confirm each on the live
run:
- the delivery daemon started and relayed the pending notice with read-only
lanes: beta's outcome files say
delivered(the outcomes are under beta's outbox); - the body was resolved: beta's session read the message, and the reply exists;
- outbound worked with no inbound write: alpha's
submit_resultsays the request was accepted; - exactly one
claudesession is visible to the lister, which refuses to guess between two.
find "$AMAP_OPENSHELL_HOME/instances/beta/outbox" -type f
openshell --workspace default sandbox exec --name beta -- python3 /opt/amap/payload/openshell-sessionssandbox exec is OpenShell main@acbac9c:crates/openshell-cli/src/main.rs:1681-1727.
The lister prints one row per claude process, and nothing when there is none.
a write to
inbound/from inside the sandbox is denied, and the host shows both layers: the:romount and the Landlockread_onlyentry. OpenShell logs no filesystem denials, so there is no log line to expect (DESIGN.md §5);
Try the write from inside alpha. It must fail with a read-only file system:
openshell --workspace default sandbox exec --name alpha -- touch /opt/amap/lanes/inbox/probeThen show both layers on the host. First the mount flag. Find alpha's container
with docker ps, and list its mounts and whether each is writable:
docker ps --format '{{.Names}} {{.Image}}'
docker inspect --format '{{range .Mounts}}{{.Source}} {{.Destination}} {{.RW}}{{println}}{{end}}' "<alpha container>"/opt/amap/lanes/inbox must show RW false, and so must the peer lane, the
payload and the roster. Only /opt/amap/lanes/outbox shows true. Then the
Landlock entry, from the policy the gateway holds
(OpenShell main@acbac9c:docs/how-it-works/sandboxes/overview.mdx:612):
openshell --workspace default sandbox get alpha --policy-only/opt/amap/lanes/inbox must be under read_only, and /opt/amap/lanes/outbox
under read_write. OpenShell logs no filesystem denials, so no log line is
expected: the failed write and the two listings are the evidence.
a direct network call to a mail API from inside the sandbox is denied and logged;
Make the call from inside alpha, to a host that is not a provider endpoint
(example.org is reserved, so it is never a real mail API). Then read the
sandbox's log. logs is OpenShell
main@acbac9c:crates/openshell-cli/src/main.rs:531-556, and --source, --level
and -n are its flags:
openshell --workspace default sandbox exec --name alpha -- python3 -c 'import urllib.request; urllib.request.urlopen("https://mail.example.org", timeout=15)'
openshell --workspace default logs alpha --source sandbox --level info -n 100The call must fail, and the log must show the denial of mail.example.org. A
policy with no network rules allows egress only through the provider, so the
denial is the expected result. If the call succeeds, that is a FAIL.
beta tasking alpha (never granted) is held at the router.
Beta was never granted the edge to alpha. In beta's session, ask beta to start a
new task for alpha, with inbox-submit (not a reply to alpha's delegation: a
reply is bound to the sender and does reach alpha). The connector writes the
request, because it holds no allowlist, and the router decides. Then, in beta's
session, call submit_result for that request. It must report that the request
was held. Read the router's own log too:
docker logs amap-router-local
find "$AMAP_OPENSHELL_HOME/instances/alpha/peer/notices" -type fThe listing must show no new notice for the request. Copy the router's line for the held request into the report.
Router writes appear inside a running sandbox (Q1).
After step 7 the router has written into beta's peer lane, after beta started. Show the notice from inside beta and on the host, and compare them:
openshell --workspace default sandbox exec --name beta -- ls -l /opt/amap/lanes/peer/notices
ls -l "$AMAP_OPENSHELL_HOME/instances/beta/peer/notices"If the sandbox lists the notice the host shows, the answer is yes. If it does not, the router's writes do not appear, and that is a FAIL for the design.
The errno of an agent's write to
inbox/isEROFS, as expected from the OpenShell agent's reading of the kernel (Q1).
Attempt a write into the inbox lane from inside alpha and read the error name. Python prints it, so the errno is not a guess:
openshell --workspace default sandbox exec --name alpha -- python3 -c 'import os; os.open("/opt/amap/lanes/inbox/probe", os.O_WRONLY | os.O_CREAT)'Errno 30 is EROFS, the read-only mount. Errno 13 is EACCES, which would
mean Landlock denied it first. Record which one it is. If it is not EROFS,
say so: the expectation came from a reading of the kernel, and this is the check.
A lane that is mounted but missing from the policy is unreadable (Q1).
Use a throwaway sandbox named probe, so alpha and beta are untouched. Copy
$AMAP_OPENSHELL_HOME/policies/alpha.yaml to
$AMAP_OPENSHELL_HOME/policies/<probe>.yaml and delete the
/opt/amap/lanes/peer line from its read_only list. Copy
$AMAP_OPENSHELL_HOME/commands/create-alpha.sh to
$AMAP_OPENSHELL_HOME/commands/create-<probe>.sh, and in the copy: change
--name alpha to --name probe, point --policy at the edited file, and
replace everything after -- with sleep 3600. The copy already has
--detach and --tty. The mount of
/opt/amap/lanes/peer stays in --driver-config-json: the lane is mounted and
absent from the policy. Then:
sh "$AMAP_OPENSHELL_HOME/commands/create-<probe>.sh"
openshell --workspace default sandbox exec --name probe -- ls /opt/amap/lanes/peer
openshell --workspace default sandbox exec --name probe -- ls /opt/amap/lanes/inbox
openshell --workspace default sandbox delete probeThe first ls must fail (Permission denied) and the second must succeed. Only
alpha's lanes are mounted, and the probe is not a fleet member, so the router
never sees it. sandbox delete is main.rs:1642.
Injection works over
AF_UNIX(Q5). Also test a negative case: set the receiver to refuse, send one notice, and confirm the outcome isrefusedorheld, notdelivered. A socket directory the receiver doesn't recognise produces no receipt, and the daemon counts silence asdelivered(amap-connector-claude's maintainers).
The positive case is step 7: beta's terminal shows the delegation as a turn
from the runtime-asserted sender, and beta's outcome file says delivered. Read
it, and name its path in the report:
find "$AMAP_OPENSHELL_HOME/instances/beta/outbox" -type fThe lister's socket is the one Claude Code reported to the payload's
SessionStart hook. Its directory must be under /tmp or /sandbox, because the
daemon binds its reply socket beside it. Its key file is the session's own peer
key, /sandbox/.claude/sessions/<pid>.<id>.key, which Claude Code writes. The
hook records only the socket, the pid and the start time, never the messaging
token.
The negative case proves the outcome is not just silence. Set beta's receiver to
refuse, so a socket that answers nothing cannot be counted as delivered. The
setting is crossSessionInbound, and Claude Code reads it at launch. Write it
into beta, restart beta, and send one delegation from alpha again:
openshell --workspace default sandbox exec --name beta -- python3 -c 'import json; open("/sandbox/.claude/settings.local.json", "w").write(json.dumps({"crossSessionInbound": "refuse"}))'
openshell --workspace default sandbox stop beta
openshell --workspace default sandbox start beta
openshell --workspace default sandbox connect betasandbox stop and sandbox start are main.rs:1654 and 1662. Whether a stopped
sandbox keeps /sandbox and starts the main process again is itself unverified:
record what you saw. Then repeat step 7 once and read beta's outcome file. It
must say refused or held, and never delivered. Alpha's submit_result
and docker logs amap-router-local show what the sender was told. If the
outcome is delivered with the setting on refuse, the daemon counted silence as
receipt, and that is a FAIL. When done, put beta back:
openshell --workspace default sandbox exec --name beta -- rm /sandbox/.claude/settings.local.json
openshell --workspace default sandbox stop beta
openshell --workspace default sandbox start betaHow Claude Code's cross-session receiver setting is fixed to
acceptin a place the agent cannot write (Q4). Read sandy'sverify_cross_session_inboundfor the two files involved.
--settings /opt/amap/payload/claude-settings.json sets crossSessionInbound
to accept from the read-only payload. Per Claude Code's docs, the project and
local settings files can only tighten it. So the agent can hold or refuse its
own delegations through the writable /sandbox/.claude/settings.local.json, but
cannot undo the operator's value.
What L1 must still confirm.
- Step 7 is delivered, which needs
acceptfrom the flag file. - Unknown 4's negative case, which tightens through
settings.local.json, givesrefusedorheld. - Whether
/sandbox/.claude/settings.json, which the agent can also write, changes the value. The docs name only the project and local files.
List the files from inside beta, and check whether the agent's user can write them:
openshell --workspace default sandbox exec --name beta -- ls -la /sandbox/.claude
openshell --workspace default sandbox exec --name beta -- touch /sandbox/.claude/probe
openshell --workspace default sandbox exec --name beta -- rm /sandbox/.claude/probeThe accepted gateway posture (Decision 1) is written into the report.
Fill in the posture line of the report from what step 2 showed: the gateway is dedicated, loopback only, has no OIDC and no other user, and runs no other sandbox. The report also states the risk that was accepted: any caller that may create a sandbox on this gateway can bind-mount any host path the container runtime can see, including another agent's outbox. Attach the output of the checks of step 2.
The shared uid holds end to end. The router (
--user), the lane directories and every sandbox'srun_as_userare the same uid, and the agent can read a delivered 0600 notice.
Show each place the uid appears, and compare them. All of them must be one uid:gid, and it must be yours:
id -u
id -g
openshell --workspace default sandbox exec --name alpha -- id
openshell --workspace default sandbox exec --name beta -- id
stat -c '%u:%g %a' "$AMAP_OPENSHELL_HOME/instances/alpha/outbox" "$AMAP_OPENSHELL_HOME/instances/beta/peer/notices"
docker inspect --format '{{.Config.User}}' amap-router-local
grep run_as "$AMAP_OPENSHELL_HOME/policies/alpha.yaml" "$AMAP_OPENSHELL_HOME/policies/beta.yaml"Then the read: the router writes notices with mode 0600, and the agent must be able to read one. From inside beta:
openshell --workspace default sandbox exec --name beta -- stat -c '%u:%g %a' /opt/amap/lanes/peer/notices/notice-<id>.json
openshell --workspace default sandbox exec --name beta -- cat /opt/amap/lanes/peer/messages/notice-<id>.jsonThe mode must be 600, owned by the same uid:gid, and cat must print the
document. If the agent cannot read a notice the router wrote, the shared uid
does not hold end to end: FAIL.
Copy the template below to docs/POC-REPORT.md and fill it in. Nothing is
filled in for you: every outcome is one you observed. The NOT-CHECKED table has
one row for each item in the "NOT CHECKED HERE" list of
$AMAP_SPEC_DIR/fixtures/validate.py.
List the items from your spec checkout, so the table follows the version you
tested against, and do not copy them from anywhere else:
python3 -c 'import sys; t = open(sys.argv[1], encoding="utf-8").read(); print(t[t.index("NOT CHECKED HERE"):].split("\n\n")[0])' "$AMAP_SPEC_DIR/fixtures/validate.py"For each item, mark exactly one of three columns: enforced by OpenShell, still operational, or not applicable, and give the evidence. An item you did not check is UNKNOWN, and it goes in the evidence column, not in a column of the three.
# POC-REPORT: amap-deploy-openshell, L1
Date: <date>
Host: <kernel version, Docker version>
OpenShell: <version, from the version command>
Claude Code: <version>
Fleet: <the fleet policy used, with its task graph>
Accepted gateway posture (D6, Decision 1): <the accepted posture: a dedicated
gateway, loopback only, no OIDC, one operator, no other sandboxes; and the risk
accepted: any caller that may create a sandbox on it can bind-mount any host
path the container runtime can see>
Every outcome below is PASS, FAIL or UNKNOWN. UNKNOWN is not a pass.
## What worked
<A short write-up: what ran, what was delivered and answered, what did not.>
## Pass criteria
### Pass criterion 1: validate.py gates every document
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the check lines, the classes checked, and the classes not produced>
### Pass criterion 2: the connector's operational checks
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <outcome files, the lister's row, submit_result>
### Pass criterion 3: a write to the inbox lane is denied at both layers
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the failed write, the mount listing, the policy listing>
### Pass criterion 4: a network call to a mail API is denied and logged
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the failed call and the log line>
### Pass criterion 5: beta tasking alpha is held at the router
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <submit_result, the router's log line, the empty peer lane>
## Unknowns
### Unknown 1: router writes appear inside a running sandbox
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <both listings>
### Unknown 2: the errno of a write to the inbox lane
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the errno seen: EROFS or another>
### Unknown 3: a lane mounted but missing from the policy
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <both listings from the probe sandbox>
### Unknown 4: injection over AF_UNIX, and the refusing receiver
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the delivered outcome, and the refused or held outcome>
### Unknown 5: where the cross-session receiver setting is fixed
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the files, and whether the agent can write them>
### Unknown 6: the accepted gateway posture
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <the outputs of the posture checks>
### Unknown 7: the shared uid holds end to end
Outcome: <PASS | FAIL | UNKNOWN>
Evidence: <every uid seen, and the read of a notice>
## NOT-CHECKED mapping
One row for each item in the NOT CHECKED HERE list of the amap-spec validator,
filled in during L1. Mark one column with X.
| NOT-CHECKED item | enforced by OpenShell | still operational | not applicable | evidence |
|---|---|---|---|---|
| <item> | | | | <evidence, or UNKNOWN> |
## DESIGN.md tags
<Each tag moved to verified, with the evidence that moved it. A claim the run did
not show stays as it was.>