researcher: row 86 (#1580) I1 spike packet, manifest 0381c233

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 15:05:20 -05:00
co-authored by Claude Opus 5.5
parent bb909a6bf2
commit ad0bdc2095
32 changed files with 2615 additions and 0 deletions
@@ -0,0 +1,31 @@
4ce4248a1fc2d0b93754bd86392ad5a1c4a2fe766a20cb78d80b7ccd26b5c11a REPORT.md
a4ebb103bf308da433fffdfec52027cdce95abdffd3812963fb014e5fb32ef73 lib.sh
a07a33ce06252ad4bcc48244259a63fa50cbdba8888ea74338abc637967f515c out/s0-clean-images.out
4075063dee067025121a1f20af853872d67771861a927ba0590b9a433aacfbc8 out/s1-endpoint-pin.out
f8d514d3cd21a7b74318b4775d7fb005645c042a3de83cc4ba408612bb0a1cda out/s2-uid-mapping.out
4dbd43bd4c16fa61a0c536ab14223c78c17ba4db2451615d7e623b4cb6c5f438 out/s3-socket-restart.out
18e97cbbdb508e9b6d694ed77b855dac517ad6005365a77986a4025fb21b2357 out/s4-hostile-client.out
acfeb13d58436def70389b8da025d143a8fe6665c2fb665ccdad0c1a1fbdb8b9 out/s5-internal-network-proxy.out
c747b9aa1716f37924245a099158846111ecda545ac745f988b1c9cef2be05cd out/s6-provider-clis-proxy.out
ee289503fad406e9df5407ea72e632a9733b6168da9b877c701e4a4b37928c1a out/s7-read-only-root.out
6e3a7ef1cf32fc2de8014975381634a18ae39add254d13de0873693d55dbc3e9 out/s8-stdin-capability.out
230dd529c3af8aac3fbe02eed85564558a7a3baa1d187999f90d417b25c0cbe5 out/s9-hardening.out
3867e501df7e5942f1b1d470a1f4d22ae56e560bfe631c40959346c505e3597d s0-clean-images.sh
9f3cfdea46f11ba2b7fb5d8fa0c50e6cc0b08a3b1ddb7a4efa3cdebc1972912d s1-endpoint-pin.sh
444199c1efb7fc22c48edee37ee2dfb4f01d9ffabd8f8c3ad794c314bf0fe944 s2-uid-mapping.sh
c0c1fda7a3be1ad32b7da16254370f991f5336e5c109f5ecb386b5d14a302e2b s3-socket-restart.sh
197c1f372c7b95988946f0914c0b8eeb2824f59ce6feddfae3be57ec040c9ccb s4-hostile-client.sh
3c9b82e41dcad0e6e83ecfcbe89b6c5f88aaf06d860f932f4848a52a2973213f s5-internal-network-proxy.sh
791e53e26dba2a921f80d98e8c720b0c263a2dd3890fe104ed0c86746d910eea s6-provider-clis-proxy.sh
049547addb8d975dfdb0c87d6ee0e6a572d6ba226b9ae7e05610b15a0db0b668 s7-read-only-root.sh
f197a522e12a4e2c8d417bb0dc34720d159d3cc32ac538aa2c1fbb01f531d771 s8-stdin-capability.sh
f5eb0aaaf9777d31dfdfc0d32211402617624fc3c2c8bd2c6532cd37ec2d5b22 s9-hardening.sh
265b98f47870b49542144abd68b3a606c52c9748e29de37f56ad6de8c5afbdd9 tools/Dockerfile.clis
1ad4f617a1272d861f2e21b30e2070301a35172d4881dad27d04c85dc8456395 tools/connect-proxy.mjs
b60874146354cd654e3adf24bd0bb1804310cd4a6e8a78d295f949415a931574 tools/hardening-probe.mjs
acfef3e3fd38696a64759831d96c31e3353bb7c03d96faa7baaf5e6c8d187976 tools/hostile.mjs
07c9a10df41d64f125f133720a9c15410610b76b4c3671d3faefaed26e281600 tools/netprobe.mjs
322815189285b2fd28166a574ff12b9e641c83d2ad92e573759031de36d50645 tools/probe.mjs
b63b4c5778565ee0c5bb42c43f39a291b948f6e5ea7ebdcc181095367d41d51d tools/s4-broker.mjs
d6805b92a3f08402bb9e85a6c11039d22e77ee34bbe62f59fa20e6d4823800de tools/sockserver.mjs
467b800c472e59ecbca883c0d7f65f5709bd7232361b4918e6d5919d34b73a71 tools/stdin-cap.mjs
+629
View File
@@ -0,0 +1,629 @@
# I1 spikes: what the rootless daemon does
Row 86, Gitea #1580, Vikunja #153. Researcher, 2026-10-12. Reviewer: Darkwing.
Brief: `docs/plans/2026-10-12_i1-containerized-install.md`, "I1 spikes".
Every spike below ran on this host against `unix:///run/user/1000/docker.sock`
and passed. Each script exits 0 when the observed behavior matches its
assertions, 1 when it does not, and 2 when a rule refused the run or something
was left behind. Output is also saved to `out/<spike>.out`.
## Setup and rules kept
| Item | Value |
|---|---|
| Docker | client 29.7.2, server 29.7.2, linux/amd64, rootless, cgroup v2, systemd driver |
| Delegated controllers | cpuset cpu io memory pids |
| Kernel | 7.2.2-1-cachyos |
| Host node | v26.8.1 (broker driver in S4 only) |
| Test image | `node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20` (`node:24-bookworm-slim`, node 24.21.0), pulled 2026-10-12T19:09Z |
| Proxy image | the same node image, running `tools/connect-proxy.mjs` (see S5) |
| Provider CLIs | Claude Code 2.1.296 and pi 0.85.1, installed inside a built image by exact version (`tools/Dockerfile.clis`), never on the host |
- Every `docker` call goes through `D()` in `lib.sh`, which names the endpoint.
Each script's first step refuses (exit 2) unless `docker info` lists `name=rootless`.
- The one call to the system socket is `docker info` in S1, as the brief asks.
- Bind mounts are subdirectories of a per-spike scratch directory made by
`mktemp -d` under `$TMPDIR` (`/mnt/storage/scratch/tmp` here). No named
volumes, no `daemon.json` edit, no daemon restart, no engine socket in a
container, no `--privileged`.
- No credential of any kind. Where a provider CLI needs a key to make a request, the
script generates a random string shaped like one (`FAKE` in `lib.sh`, `OAT` in S6). No
provider issued it; the provider answers "invalid".
- S4 runs the repository's broker (`startBroker`) on a temp dataRoot under the scratch
directory, not on `~/.mosaic-dev` and not the live bus.
- Everything a spike creates carries the label `mosaic.i1spike=<spike>`. An EXIT trap
removes labelled containers, networks, images and the scratch directory, then
checks. A leftover turns the exit status into 2.
- Files are under `agents/researcher/work/i1-spikes/` only. Nothing was committed or
posted.
### How to run
```
cd agents/researcher/work/i1-spikes
for s in s1-endpoint-pin s2-uid-mapping s3-socket-restart s4-hostile-client \
s5-internal-network-proxy s6-provider-clis-proxy s7-read-only-root \
s8-stdin-capability s9-hardening s0-clean-images; do ./$s.sh >/dev/null; echo "$s $?"; done
```
Run S0 last. The spikes share the pinned node image and S6 and S7 each build an
image, so S0 removes the node image (by digest), any image still carrying the
label, and the build cache, then asserts the end state: `alpine:3` (there before
the spikes) is the only image, only `bridge host none` networks, no container, no
volume. A rerun starts by pulling the node image again by digest
(`need_node_image`). The whole sequence takes about 5 minutes; the two builds are most of it.
Final state, checked after the last run:
```
$ docker --host unix:///run/user/1000/docker.sock ps -a -> (no rows)
$ docker --host unix:///run/user/1000/docker.sock network ls -> bridge host none
$ docker --host unix:///run/user/1000/docker.sock image ls -a -> alpine:3
$ docker --host unix:///run/user/1000/docker.sock volume ls -> (no rows)
```
S0 prunes the whole BuildKit cache of this daemon, not only ours. It was 0 B before
the first spike (`docker system df`, 2026-10-12T19:20Z), so nothing of anyone's
is lost today. If someone builds on this daemon between spike runs, S0 removes
that cache too. BuildKit cache records carry no label, so it cannot be scoped.
## S1. Endpoint pin
**Question.** What does the launcher parse to refuse a daemon that is not
rootless, and what does a missing socket print?
**Command.** `./s1-endpoint-pin.sh`; the core call:
```
docker --host unix:///run/user/1000/docker.sock info --format '{{json .SecurityOptions}}'
```
**Output (trimmed).**
```
rootless: ["name=seccomp,profile=builtin","name=rootless","name=cgroupns"]
system: ["name=seccomp,profile=builtin","name=cgroupns"]
--host with DOCKER_HOST=<system socket>: still the rootless list
missing socket: exit 1, stdout "null",
"failed to connect to the docker API at unix://<scratch>/none.sock; check if the path is correct and if the daemon is running: dial unix <scratch>/none.sock: connect: no such file or directory"
regular file as endpoint: exit 1, "Cannot connect to the Docker daemon at unix://<scratch>/notasocket. Is the docker daemon running?"
{{.Rootless}}: template error, there is no such field
```
Plain-text `docker info` prints a bare `rootless` line under "Security Options";
only the JSON form carries the `name=` prefix.
**Answer.** Yes. The field is `SecurityOptions`, a JSON array, and the rootless
daemon has the element `name=rootless`; the system daemon does not.
**Settles.** The launcher refusal: run `docker --host <endpoint> info --format
'{{json .SecurityOptions}}'`, check the exit status first, then require an array
element exactly equal to `name=rootless`. A missing socket gives exit 1 and
stdout `null`, so "stdout parses as JSON" is not a sufficient check. Always pass
`--host`; it beats `DOCKER_HOST`.
## S2. uid mapping for files and sockets
**Question.** Which uid does a container run as on the host, and which uids can
read a 0600 file and connect to a 0600 socket owned by the host user?
**Command.** `./s2-uid-mapping.sh`. A host node server makes the socket; a
container mounts the directory and runs `tools/probe.mjs` as several uids.
**Output (trimmed).**
```
/proc/self/uid_map: 0 1000 1 1 100000 65536 (gid_map identical)
host fixture: -rw------- jwoltje:jwoltje file; srw------- jwoltje:jwoltje sock
0600, owned by the host user
user 0:0 file ok, socket ok
user 0:0 + cap-drop ALL, no-new-privileges, read-only file ok, socket ok
user 1000:1000 EACCES, EACCES
user 2000:2000 EACCES, EACCES
0660
1000:1000, no group EACCES
1000:1000 + --group-add 0 file ok, socket ok (container gid 0 is the host user's gid 1000)
2000:2000 + --group-add 0 socket ok
0666
2000:2000 socket ok
file chowned to container uid 1000, seen from host: -rw------- 100999:100999
host user cannot read it; container uid 1000 can; a container root chown puts it back
socket made by container uid 1000 in a mounted directory: host sees srw------- 100999:100999
host connect at 0600 EACCES; at 0660 EACCES (not in group); at 0666 ok
container to container (broker 1001:2000 makes the socket, mode 0660; host sees 101000:101999)
client 1000:1000 + --group-add 2000, cap-drop ALL, :ro directory mount ok
client 1000:1000, no group EACCES
client 0:0, default capabilities ok
client 0:0, cap-drop ALL EACCES
client 0:0, cap-drop ALL, --group-add 2000 ok
```
**Answer.**
- Container uid 0 is the host user. Container uid N >= 1 is host uid
`100000 + N - 1` (container 1000 is host 100999). Same for gids.
- Container root reads the host user's 0600 file and connects to its 0600
socket, with or without capabilities: owner access is a mode-bit check.
- A non-root container uid gets EACCES on both, until the file's group is added
with `--group-add` and the mode allows group access.
- The host user cannot read a 0600 file or use a 0600/0660 socket that a
non-root container uid created or owns. It gets in only at 0666.
- Container root with **default capabilities** connected to a 0660 socket it
was neither owner nor group of. That is `CAP_DAC_OVERRIDE` over inodes mapped
into the container's user namespace. With `--cap-drop ALL` it was refused.
- The host user's groups (`3,90,108,...,1000,3009`) do not include any mapped
gid in the 100000 range, so a host-side `chgrp` to a container gid is not
available to the host user. Handing a file to container gid 2000 needs a
container root (`chown`/`chgrp`), as the script does.
**Settles.**
- `cap_drop ALL` is part of the access control, not only hygiene. Without it, a
container running as root ignores the socket's mode and group.
- Broker sockets: the broker container runs as a non-root uid with its own gid,
creates the socket 0660 in its own directory, and each client is a non-root uid
given `--group-add <that gid>` with `cap_drop ALL`. Agent containers get the
agent socket's directory; only the Console gets the human socket's. Using the
broker as container root (the host user) also works, but then the sockets and
every file it makes belong to the host user and any root client can use them.
- Secret files (decision 108's token file): three mechanisms work. (a) Owned by
the host user, mode 0600, read by a process running as container uid 0 with
`cap_drop ALL`. (b) Owned by a container uid N, mode 0600, read by that uid;
the host user then cannot read it without a container. (c) Mode 0640 with a
group the reader has through `--group-add`; gid 0 inside a container is the
host user's primary group, so prefer a dedicated gid. Which one the design uses
is the design row's call; the constraint to carry is that the host user cannot
set a file's group to a container gid.
- The installer on the host creates directories the host user owns. A container
creating files in them leaves files the host user cannot read (100999-style
owners). Anything the host must read (logs, receipts) comes from a uid the host
can read, or the container runs as root.
## S3. A socket directory across a restart
**Question.** After the host server stops and starts again on the same path, does
a running container reach it without a restart? Directory mount versus file mount.
**Command.** `./s3-socket-restart.sh`.
**Output (trimmed).**
```
A. directory mounted, clean stop
up: pong first; stopped: ENOENT; restarted on the same path: pong second (no container restart)
B. directory mounted, kill -9 (stale socket file stays on the host)
stale file: ECONNREFUSED; new server removes the stale file and listens: pong third
C. socket file mounted directly, clean stop, restart on the same path
up: pong one; restarted: ECONNREFUSED (the bind mount pins the old inode)
a fresh container on the same path: pong two
-v with a missing path: container started, host path became a directory
D. --mount type=bind, source missing: exit 125 "bind source path does not exist"; nothing created
```
**Answer.** A directory mount survives a restart of the server. A mounted
socket file does not: the container keeps the dead inode until it is recreated.
**Settles.**
- Mount the socket's directory, never the socket file.
- Create mounts with `--mount type=bind,...`, not `-v`: `-v` creates a missing
source as a directory and starts anyway.
- After `kill -9` the socket file stays and connecting gives ECONNREFUSED, not
ENOENT. The real `serve()` refuses to listen if the path exists
(`packages/bus/src/server.mjs:8-11`, `socket-exists`). I read that code; I did
not run the real broker through a crash. The spike's server removed the stale
file first. A broker container that restarts after a crash needs its entrypoint
to remove a stale socket (and only after confirming no live writer), or it will
refuse to start.
## S4. A hostile client over a mounted broker socket
**Question.** What does the current single socket answer to a client in a
container, for forged identities and oversize requests? This is the baseline for
the two-listener change.
**Command.** `./s4-hostile-client.sh`. `tools/s4-broker.mjs` starts the
repository's `startBroker` on a temp dataRoot with one throwaway capability for a
made-up business. The dataRoot's `bus/` directory is mounted `:ro` into a
container; `tools/hostile.mjs` sends the requests.
**Output (trimmed).**
```
valid capability, role.claim {"ok":true,...}
valid capability, agents {"ok":true,"result":[... the claim ...]}
made-up capability unauthenticated
human envelope, made-up proof human-required
human envelope, the container's own pid human-required
human envelope, pid 1 human-required
cap and human together invalid-envelope
unknown key (role: human) invalid-envelope
valid capability, verb sql unknown-verb
not JSON invalid-envelope
exactly 65536 bytes unauthenticated (read in full)
65537 bytes request-too-large
70000 bytes, no newline request-too-large
1 MiB request-too-large
connect and send nothing request-timeout (5 s)
partial line, then close incomplete-request
bus/ mounted: broker.sock, bus.sqlite, bus.sqlite-shm, bus.sqlite-wal, writer.lock, all 0600 root
container root reads bus.sqlite ("SQLite format 3"); uid 1000 gets EACCES; :ro blocks writes
broker events table afterwards: 2 rows (the claim's session.launched, the unknown-verb action.refused)
no row for the forged capability, the human-required cases, or any oversize or malformed request
```
**Answer.**
- The transport refuses before any identity exists. Forged, malformed, oversize
and slow requests leave no row in the events table and nothing in the broker's
output (the process prints nothing of its own).
- A human envelope from a container is refused whatever pid it names. That path
reaches `verifyHuman` (`packages/bus/src/runtime.mjs:66-70`), which reads the
claimed pid from the broker's own `/proc` and requires a live
`human-cli.mjs` process with a nonce in its environment
(`packages/bus/src/human.mjs`).
- Mounting `bus/` gives a container the database and its WAL, not only the socket.
**Settles.**
- The agent and human sockets cannot share a directory with `bus.sqlite`. Each
socket gets its own directory, and neither agent nor Console containers mount
`bus/` (agrees with decision 105).
- Baseline for the two-listener change: forged capability gives `unauthenticated`;
human envelope gives `human-required`; 65536 bytes is read in full and 65537 is
`request-too-large`; silence times out at 5 s. The new agent listener should
answer the same way, and additionally refuse a `human` envelope without running
any proof code.
- What this does not show: the broker ran on the host here, as the bus tests run
it. A broker inside a container reads its own container's `/proc`, so
`verifyHuman` cannot see a human CLI on the host. That agrees with decision
105's new `via` value (the mount, not a process proof, authorizes the Console),
but the design row has to say what replaces the pid, start-time and nonce
proof on the human socket. I did not test a containerized broker.
## S5. The internal network and one egress proxy
**Question.** From a container on an `internal: true` network, what fails, what
works through the proxy, and does `-p 127.0.0.1:P:P` publish a port for a
container that is only on that network?
**Command.** `./s5-internal-network-proxy.sh`.
**Proxy choice.** `tools/connect-proxy.mjs` (43 lines) on the same pinned node
image. It allows `CONNECT host:443` for names in an allowlist, refuses everything
else with 403, and logs one line per decision (method, host, port; it cannot see
inside the TLS tunnel). Reasons: no second image to pin and trust, the whole
allowlist logic is readable in one screen, and the log is easy to assert on. What the spike settles is network
behavior, which does not depend on the proxy; a production proxy (squid,
tinyproxy) sits in the same place and the design row can choose it. It is a spike
tool, not a recommendation for production.
**Output (trimmed).**
```
internal network: internal=true; external bridge: internal=false
the proxy is on both networks, aliased "proxy" on the internal one
agent (internal network only):
dns "proxy" 172.18.0.2
dns api.anthropic.com EAI_AGAIN
tcp 1.1.1.1:443 ENETUNREACH
tls api.anthropic.com directly EAI_AGAIN
CONNECT api.anthropic.com:443 + TLS 200, TLSv1.3, certificate verified
CONNECT example.com:443 403
CONNECT api.anthropic.com:22 403
CONNECT 1.1.1.1:443 403
CONNECT <network gateway>:443 403
node fetch, NODE_USE_ENV_PROXY=1 + HTTPS_PROXY HTTP 401 from the provider
proxy log: CONNECT api.anthropic.com 443 / REFUSE example.com 443 / REFUSE api.anthropic.com 22 / REFUSE 1.1.1.1 443 / REFUSE 172.18.0.1 443
-p 127.0.0.1:42485:8080 on a container only on the internal network:
docker accepts it (exit 0), PORTS column shows "8080/tcp" with no host mapping,
host connect to 127.0.0.1:42485 -> ECONNREFUSED
control on an ordinary bridge: the same -p answers "hello"
```
**Answer.** Yes to all three. An internal network has no external DNS and no
route; a proxy on both networks is the only way out; the allowlist decides by
name, port 443 only. `-p` is accepted without error but not published for an
internal-only container.
**Settles.**
- Agent containers sit on the internal network only and reach the world through
the proxy at `http://proxy:3128` (alias on that network).
- The Console needs a non-internal network of its own for its listener, because
`-p` publishes nothing on an internal-only container, and `docker run -p` gives
no error to tell you.
- A silent `-p` is the failure to guard against: a compose check should assert
that the Console's published port answers.
- Limit: the proxy matches the hostname in the CONNECT line and resolves it
itself. It does not and cannot check what is inside the TLS tunnel.
## S6. Provider CLIs through the proxy
**Question.** With no credential, do Claude Code 2.1.296 and pi 0.85.1 reach their
provider through `HTTPS_PROXY`, from the S5 network? Which proxy variables do
they ignore, and is `ANTHROPIC_BASE_URL` needed?
**Command.** `./s6-provider-clis-proxy.sh`. Image built from
`tools/Dockerfile.clis`. Container flags: `--user 1000:1000 --read-only --cap-drop
ALL --security-opt no-new-privileges`, tmpfs `/tmp` and HOME.
**Output (trimmed).**
```
claude 2.1.296 (Claude Code), pi 0.85.1
controls, no proxy variable:
claude exit 124 (the 90 s timeout), no output, no proxy line
pi exit 1 "Connection error.", no proxy line
HTTPS_PROXY only, no ANTHROPIC_BASE_URL, made-up API key:
claude "Invalid API key · Fix external API key" CONNECT api.anthropic.com (many); REFUSE http-intake.logs.us5.datadoghq.com
pi anthropic 401 authentication_error "invalid x-api-key" CONNECT api.anthropic.com
pi openai 401 "Incorrect API key provided" CONNECT api.openai.com
decision 108's variable, CLAUDE_CODE_OAUTH_TOKEN:
made-up sk-ant-oat01-... : "Failed to authenticate. API Error: 401 OAuth access token is invalid." CONNECT api.anthropic.com
made-up sk-ant-api03-... : "Not logged in" (12 CONNECTs first; no "OAuth access token is invalid" answer)
no credential:
claude "Not logged in", but 5 CONNECTs to api.anthropic.com first
pi "No API key found for the selected model", no network
proxy variables (each alone):
HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY
claude honoured honoured honoured honoured honoured*
pi honoured honoured honoured honoured IGNORED
NODE_USE_ENV_PROXY=1 added: no change for either
* with ALL_PROXY, claude also sends plain GET/POST lines the CONNECT-only proxy refuses (malformed-request)
claude's other host: http-intake.logs.us5.datadoghq.com (REFUSE)
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -> only api.anthropic.com
DISABLE_TELEMETRY=1 -> only api.anthropic.com (recorded, not asserted)
```
**Answer.**
- Yes. Both CLIs take `HTTPS_PROXY` (and `https_proxy`, `HTTP_PROXY`,
`http_proxy`), reach the provider with a CONNECT, and fail on authentication, not
on the network. Neither needs `NODE_USE_ENV_PROXY`, whose effect on plain
`fetch` S5 showed.
- `ANTHROPIC_BASE_URL` is not needed for routing. It stays in `ENV_ALLOW`
(`packages/seat/src/session.mjs:63`) for people who point at a gateway; the
containerized path does not use it.
- pi ignores `ALL_PROXY`. Claude honours it but also issues plain HTTP request lines
that a CONNECT-only proxy refuses.
- Without a proxy variable pi fails in seconds and Claude hangs until its caller gives up.
- Claude Code with no credential still makes requests to `api.anthropic.com` before it reports "Not logged in".
**Settles.**
- Set `HTTPS_PROXY=http://proxy:3128` on every agent container. Set nothing else
proxy-related; do not rely on `ALL_PROXY`.
- The allowlist for Claude plus pi-anthropic is one host, `api.anthropic.com:443`,
provided `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` is set; without it Claude
also tries a Datadog intake host. Add `api.openai.com:443` only if pi runs the
OpenAI provider.
- The runner needs its own timeout for Claude, and the entrypoint should refuse to
start an agent without the proxy variable.
- Decision 108's `CLAUDE_CODE_OAUTH_TOKEN` is read by Claude Code and takes the same
proxy path. A value shaped like an API key in that variable is not treated as a login ("Not logged in").
Caveats. All "authentication failed" results come from made-up values; no real
token was used, so a real OAuth session's traffic is unverified. A real OAuth
session may contact other hosts (a token refresh, for example). The first run with a
real token should be read against the proxy's `REFUSE` lines. The request counts
vary run to run (Claude retries); the scripts assert hosts and outcomes, not counts.
The proxy log in one run showed a single `UPSTREAM-ERROR api.anthropic.com 443`
(the proxy failed to reach upstream for one tunnel); that run still ended with the
expected "Not logged in" answer. I did not chase the cause. A production proxy needs the same visibility, because
an upstream failure and an allowlist refusal look alike to the CLI.
## S7. A read-only root
**Question.** Do Claude Code and pi start and make a request with `read_only`,
tmpfs `/tmp` and tmpfs HOME? What do they write under HOME? What breaks if HOME is
not a tmpfs?
**Command.** `./s7-read-only-root.sh`. Each CLI gets its own container so its writes
are its own. Names only are listed, never contents.
**Output (trimmed).**
```
A. read-only root, tmpfs /tmp, tmpfs HOME (uid 1000) docker diff of the writable layer: empty for both
claude under HOME: .claude.json .claude/backups/.claude.json.backup.<ms>
.claude/projects/-/<uuid>.jsonl .claude/projects/-/memory/ .claude/sessions/
under /tmp: claude-1000/ cc-socks/
pi under HOME: .pi/agent/auth.json .pi/agent/models-store.json
under /tmp: (nothing)
B. the same with noexec,nosuid on both tmpfs mounts both start and make the request
C. read-only root, tmpfs /tmp only, HOME not a tmpfs
claude --version ok; the request still reaches the provider ("Invalid API key")
pi --version ok; request: "No API key found" although the key is in the environment
D. read-only root, no tmpfs at all
claude --version ok; request: EROFS: read-only file system, mkdir '/tmp/claude-1000'
pi --version ok; request: "No API key found"
```
**Answer.** Both run on a read-only root with tmpfs `/tmp` and tmpfs HOME, and
also with `noexec,nosuid` on both mounts. `/tmp` holds Claude's `claude-<uid>`
and `cc-socks` directories; HOME holds the paths above. Nothing reaches the
container's writable layer.
**Settles.**
- tmpfs list for an agent container: `/tmp` and HOME (owned by the run uid),
both `noexec,nosuid`.
- Both CLIs need a writable HOME. pi will not find the key in its environment
without one. Claude's request path works without one, but a real session writes
its transcript under `~/.claude/projects/`, so HOME has to be writable. That
Claude's request still goes through a read-only HOME proves the request path
only.
- A one-shot run needs no mounted state directory. If a managed session must
resume across container runs, the transcript path (`~/.claude/projects/...`) and
pi's session directory need a mount; this spike ran pi with `--no-session` and
did not observe pi's session files.
- pi writes `.pi/agent/auth.json` even when the key comes from the environment.
I did not read it; whether it stores the key is unknown. Keeping HOME a tmpfs
means it vanishes with the container.
- Both runs used the image's default working directory (`/`); a real worker's
workspace mount is separate from this list.
## S8. The capability line on stdin, and how a container stops
**Question.** Does a capability written to `docker run -i` reach the entrypoint
without appearing anywhere an observer can read? Which signal does pid 1 get from
`docker stop` and `docker kill`, with and without `--init`?
**Command.** `./s8-stdin-capability.sh`. The string is random and made up; the
script prints only its length and sha256.
**Output (trimmed).**
```
entrypoint says: got len=57 sha256=<matches the host's>
Config.Env: ["READ=1","HANDLE=1","PATH=...","NODE_VERSION=24.21.0","YARN_VERSION=1.22.22"]
Config.Cmd: ["node","/t/stdin-cap.mjs"]
absent from: docker inspect (whole document), docker top, docker logs,
/proc/1/environ, /proc/1/cmdline, the host's ps -eo args
pid 1 gets exit time
docker stop -t 3, no init, no handler nothing (ignored) 137 3.0 s (SIGKILL after the grace period)
docker stop, no init, handler SIGTERM as pid 1 0 19 ms
docker stop, --init, no handler tini forwards 143 18 ms
docker stop, --init, handler SIGTERM, not pid 1 0 31 ms
docker kill, any SIGKILL 137 ~20 ms (handler never runs)
docker kill -s TERM, no init, no handler ignored; container keeps running
docker kill -s TERM, --init, no handler ends 143
docker kill -s TERM, no init, handler SIGTERM as pid 1 0
```
**Answer.** Yes, the line arrives and appears in none of those places. Pid 1 in
a container ignores `SIGTERM` unless it has a handler, so `docker stop` waits out
the whole grace period and then kills it.
**Settles.**
- Deliver the capability as one line on stdin to `docker run -i`. Not in `-e`,
`Cmd`, or a mounted file.
- The entrypoint must either install a `SIGTERM` handler (exit 0, immediate) or
run under `--init` (exit 143, immediate). Without either, every stop costs the
full grace period and ends 137. Use `docker stop -t <grace>`; do not use
`docker kill -s TERM` on a pid 1 without a handler, because it does nothing.
- The host launcher already has this shape: `close()` sends SIGTERM to each runner
every second because a runner that is pid 1 of its namespace "never sees a signal
sent before it has" a handler, then SIGKILLs after `stopTimeoutMs`
(`packages/cli/src/launcher.mjs:393-411`). A container stop path must keep the
same two steps. Read exit status as 0 = handled the stop, 143 = terminated by
the stop, 137 = killed after the grace period or by `docker kill`. A stop that
ends 137 within the grace period is a kill, not a clean stop.
- Limit: S8 checked pid 1 and the host process list. A child the entrypoint spawns
with the capability in its environment is outside what was tested.
## S9. Hardening under rootless Docker and cgroup v2
**Question.** Does the rootless daemon accept and enforce `cap_drop ALL`,
`no-new-privileges`, `read_only`, `pids_limit`, `mem_limit` and `cpus`?
**Command.** `./s9-hardening.sh` with `tools/hardening-probe.mjs`, which prints
one JSON line from inside the container (uid, `CapEff`, `CapBnd`, `NoNewPrivs`,
`Seccomp`, `pids.max`, `memory.max`, `memory.swap.max`, `cpu.max`, write tests).
**Output (trimmed).**
```
daemon: cgroup v2, systemd driver, no warnings; controllers delegated: cpuset cpu io memory pids
control, no flags, uid 0: CapEff 00000000a80425fb NoNewPrivs 0 Seccomp 2 pids.max 76510 memory.max max cpu.max "max 100000" / writable
control, no flags, uid 1000: CapEff 0 / not writable (EACCES)
each flag alone: exit 0, 0 warning lines
--cap-drop ALL CapEff 0 even for container root
--security-opt no-new-privileges NoNewPrivs 1
--read-only / and /tmp EROFS; with --tmpfs /tmp, /tmp writable
--pids-limit 32 pids.max 32
--memory 128m memory.max 134217728
--memory 128m --memory-swap 128m memory.swap.max 0
--cpus 0.5 cpu.max "50000 100000"
all together, docker inspect HostConfig:
CapDrop ["ALL"] SecurityOpt ["no-new-privileges"] ReadonlyRootfs true Tmpfs {"/tmp":"rw,noexec,nosuid,size=16m"}
PidsLimit 32 Memory 134217728 MemorySwap 134217728 NanoCpus 500000000 Privileged false
inside: CapEff 0 CapBnd 0 NoNewPrivs 1 Seccomp 2 pids.max 32 memory.max 134217728 swap 0 cpu.max "50000 100000" / EROFS /tmp ok
fork burst of 200: 24 started, 176 refused
allocate 400 MiB under 128m: OOM-killed, exit 137, OOMKilled=true
container root + the same flags: CapEff 0
```
**Answer.** Yes. The daemon accepts every flag without a warning, `docker inspect`
shows each one, and the container sees each one enforced: capability sets empty,
`NoNewPrivs 1`, read-only root, a pids ceiling that refuses forks, a memory limit
that kills an over-allocation, a CPU quota. The default seccomp filter stays on
(`Seccomp: 2`). Without a pids limit the cgroup ceiling is 76510, effectively the host's.
**Settles.**
- The hardening set in decision 105 applies as written under rootless and cgroup
v2. No flag is rejected, and none is accepted without effect.
- Set `--memory-swap` equal to `--memory`; otherwise the swap limit is `max`
(the control shows `swapMax: max`). With them equal it is 0.
- Always set `pids_limit`; the default is the host-wide ceiling.
- Not tested: block-I/O limits (the `io` controller is delegated, no flag
tried), `--ulimit`, network bandwidth, a custom seccomp profile.
## Settled design choices
1. **Endpoint check** (S1): `info --format '{{json .SecurityOptions}}'`, exit status
first, then an element exactly equal to `name=rootless`; always `--host`.
2. **Mounts** (S3): the socket's directory, never the socket file; `--mount
type=bind`, never `-v`; the broker container's entrypoint removes a stale
socket, because `serve()` refuses an existing path.
3. **Socket access** (S2, S4): each socket in its own directory, with neither `bus/`
nor `bus.sqlite` mounted anywhere else; broker as a non-root uid with its own
gid; sockets 0660; clients are non-root uids given `--group-add` and run
`cap_drop ALL`. Root with default capabilities bypasses a 0660 socket.
4. **Secret files** (S2): the host user cannot set a file's group to a container gid
and cannot read files a non-root container uid owns. Pick one of the three
mechanisms under S2 for the token file. Do not share gid 0.
5. **Agent network** (S5): `internal: true` network plus one proxy on both networks,
aliased `proxy`; CONNECT allowlist by name on port 443. The Console gets a
separate non-internal network, because `-p` is silently not published on an
internal-only container; assert the Console's port answers.
6. **Provider CLIs** (S6): `HTTPS_PROXY=http://proxy:3128` only;
`CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`; allowlist `api.anthropic.com:443`
(plus `api.openai.com:443` if pi uses OpenAI); no `ANTHROPIC_BASE_URL`; no
reliance on `ALL_PROXY`; a runner timeout for Claude.
7. **Read-only root** (S7): tmpfs `/tmp` and HOME, `noexec,nosuid`; a one-shot run
needs no state mount; a resumable session needs its transcript path mounted.
8. **Capability delivery** (S8): one line on stdin to `docker run -i`.
9. **Stop path** (S8): `docker stop -t <grace>`; pid 1 handles `SIGTERM` or
the container runs under `--init`; exit 0 and 143 are stops, 137 is a kill.
10. **Hardening flags** (S9): the set in decision 105 as written, plus
`--memory-swap` equal to `--memory`.
## Contradictions with decisions 105 to 108
None found. Four things the design row has to settle that the spikes expose
without contradicting a decision:
1. **Human proof in a containerized broker** (S4). `verifyHuman` reads the claimed
pid from the broker's own `/proc`. In a container that is the container's pid
namespace. Decision 105's separate human socket and new `via` value are
consistent with this, but nothing tested what authenticates a human on that
socket once the pid proof cannot run.
2. **Stale socket after a crash** (S3). The broker refuses to start when the path
exists. A restart policy alone does not recover a crashed broker container.
3. **Real tokens through the proxy** (S6). Decision 108's path works with made-up
values. A real OAuth session's host list is unverified.
4. **Secret ownership** (S2). The host user cannot hand a file to a container gid,
and cannot read what a non-root container uid owns. The installer and the
operator's own tooling are affected.
## Files
```
lib.sh shared framework (endpoint pin, labels, cleanup, proxy network helpers)
s0-clean-images.sh run last; removes the shared image and build cache, asserts the end state
s1-endpoint-pin.sh ... s9-hardening.sh
tools/ probe.mjs sockserver.mjs hostile.mjs s4-broker.mjs connect-proxy.mjs
netprobe.mjs stdin-cap.mjs hardening-probe.mjs Dockerfile.clis
out/ one saved output per script
MANIFEST.sha256 sha256 of every file above except itself
```
+126
View File
@@ -0,0 +1,126 @@
# Shared by the I1 spike scripts (row 86, #1580). Sourced, never run.
#
# Rules it enforces for every spike (docs/plans/2026-10-12_i1-containerized-install.md):
# - every docker call goes through D, which names the rootless endpoint;
# - the first step refuses unless that endpoint reports name=rootless;
# - everything a spike creates carries the label mosaic.i1spike=<spike>;
# - on exit, pass or fail, the spike's containers, networks, built images and
# scratch directory are removed, and the script checks that nothing is left.
#
# Exit codes: 0 the observed behavior matched the assertions, 1 it did not,
# 2 a rule refused the run (wrong daemon, leftovers). Output is also saved to
# out/<spike>.out.
EP=unix:///run/user/1000/docker.sock
# node:24-bookworm-slim, linux/amd64, pulled 2026-10-12T19:09Z. The worker's base.
NODE_IMG=node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO=$(cd "$HERE/../../../.." && pwd)
D() { docker --host "$EP" "$@"; }
FAILS=0
say() { printf '%s\n' "$*"; }
# check DESCRIPTION CONDITION-EXIT-STATUS: records one assertion.
check() {
if [ "$2" -eq 0 ]; then say "ok $1"; else say "FAIL $1"; FAILS=$((FAILS + 1)); fi
}
# spike_begin NAME: pin the endpoint, set up output, scratch and cleanup.
spike_begin() {
SPIKE=$1
mkdir -p "$HERE/out"
exec > >(tee "$HERE/out/$SPIKE.out") 2>&1
say "# $SPIKE $(date -u +%Y-%m-%dT%H:%M:%SZ)"
local sec
if ! sec=$(D info --format '{{json .SecurityOptions}}' 2>&1); then
say "refused: $EP does not answer: $sec"; trap - EXIT; exit 2
fi
case $sec in
*'"name=rootless"'*) say "endpoint $EP: name=rootless" ;;
*) say "refused: $EP does not report name=rootless: $sec"; trap - EXIT; exit 2 ;;
esac
LABEL="mosaic.i1spike=$SPIKE"
# The scratch parent is mktemp's 0700 directory; spikes mount only subdirectories.
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/i1spike-$SPIKE.XXXXXX")
say "scratch $SCRATCH"
trap spike_end EXIT
}
# Labels and the --rm flag a spike adds to every container it starts.
RUNFLAGS() { printf '%s\n' --label "$LABEL"; }
spike_end() {
local rc=$?
trap - EXIT
set +e
local ids
ids=$(D ps -a -q --filter "label=$LABEL")
[ -n "$ids" ] && D rm -f $ids >/dev/null 2>&1
ids=$(D network ls -q --filter "label=$LABEL")
[ -n "$ids" ] && D network rm $ids >/dev/null 2>&1
ids=$(D image ls -q --filter "label=$LABEL")
[ -n "$ids" ] && D image rm -f $ids >/dev/null 2>&1
# Rootless uid 0 in a container is the host user; other container uids map into
# the subuid range and may have left files this user cannot remove. Remove them
# from inside a throwaway container as container root (host jwoltje).
rm -rf "$SCRATCH" 2>/dev/null
if [ -e "$SCRATCH" ]; then
D run --rm --label "$LABEL" -v "$SCRATCH:/s" "$NODE_IMG" sh -c 'rm -rf /s/* /s/.[!.]* 2>/dev/null' >/dev/null 2>&1
rm -rf "$SCRATCH" 2>/dev/null
fi
local left=0
[ -n "$(D ps -a -q --filter "label=$LABEL")" ] && left=1
[ -n "$(D network ls -q --filter "label=$LABEL")" ] && left=1
[ -n "$(D image ls -q --filter "label=$LABEL")" ] && left=1
[ -e "$SCRATCH" ] && left=1
if [ "$left" -eq 0 ]; then
say "cleanup: no container, network, image or scratch directory labelled $SPIKE is left"
else
say "cleanup: LEFTOVERS for $SPIKE"; rc=2
fi
if [ "$rc" -eq 0 ] && [ "$FAILS" -ne 0 ]; then rc=1; fi
if [ "$rc" -eq 0 ]; then say "result: pass"; else say "result: exit $rc ($FAILS failed assertion(s))"; fi
exit "$rc"
}
# Pull the pinned base image if the rootless daemon does not hold it. The image
# is shared by the spikes and removed by s0-clean-images.sh, not per spike.
need_node_image() {
D image inspect "$NODE_IMG" >/dev/null 2>&1 || D pull -q "$NODE_IMG" >/dev/null
}
# net_up ALLOWED-HOSTS: an internal network INT, an ordinary bridge EXT and the
# allowlisting CONNECT proxy PROXY (tools/connect-proxy.mjs on the pinned node
# image, port 443 only) on both, reachable from INT as http://proxy:3128. Sets
# FAKE, a random API-key-shaped string that no provider has issued. With no key
# at all, pi says "No API key found" (no network) and Claude Code says "Not logged
# in" (after startup connections), so neither shows a model-request answer from the provider;
# a made-up key makes the provider answer "invalid key", which is the
# authentication failure S6 wants. It is not a credential.
net_up() {
mkdir -p "$SCRATCH/tools"; cp "$HERE/tools/connect-proxy.mjs" "$SCRATCH/tools/"
INT="i1${SPIKE%%-*}-int-$$"; EXT="i1${SPIKE%%-*}-ext-$$"; PROXY="i1${SPIKE%%-*}-proxy-$$"
D network create --label "$LABEL" --internal "$INT" >/dev/null
D network create --label "$LABEL" "$EXT" >/dev/null
D run -d --name "$PROXY" --label "$LABEL" --network "$EXT" --user 0:0 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-e ALLOW="$1" -e PORTS=443 -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/connect-proxy.mjs >/dev/null
D network connect --alias proxy "$INT" "$PROXY"
for _ in $(seq 50); do D logs "$PROXY" 2>&1 | grep -q listening && break; sleep 0.1; done
FAKE=$(node -e 'process.stdout.write("sk-ant-api03-FAKE" + require("crypto").randomBytes(20).toString("hex"))')
}
# plines: how many lines the proxy has logged so far; proxy_new N: the lines after N.
plines() { D logs "$PROXY" 2>&1 | wc -l; }
proxy_new() { sleep 0.3; D logs "$PROXY" 2>&1 | tail -n +"$(($1 + 1))"; }
# Build the S6/S7 image (tools/Dockerfile.clis) from an otherwise empty context
# in the spike's scratch directory, labelled so spike_end removes it. Sets CLI_IMG.
build_cli_image() {
mkdir -p "$SCRATCH/ctx"
cp "$HERE/tools/Dockerfile.clis" "$SCRATCH/ctx/Dockerfile"
CLI_IMG="mosaic-i1spike-clis:$SPIKE"
say "building $CLI_IMG from $NODE_IMG (npm install -g inside the image, exact versions)"
D build -q --label "$LABEL" --build-arg "NODE_IMG=$NODE_IMG" -t "$CLI_IMG" "$SCRATCH/ctx" >/dev/null || return 1
say "built $CLI_IMG: $(D image inspect "$CLI_IMG" --format '{{.Id}} {{.Size}} bytes')"
}
@@ -0,0 +1,35 @@
# s0-clean-images 2026-10-12T20:03:57Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s0-clean-images.F70oLX
## before
node:<none> d6aa754f16b3
alpine:3 294b683cb724
build cache: Build Cache 1.059GB
## remove
ok no container (of anyone) is using the pinned node image
Untagged: node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20
Deleted: sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20
ID RECLAIMABLE SIZE LAST ACCESSED
sr9sdhk5ctu0ejbx66p42ay2t* true 8.192kB 2 minutes ago
ji3dt9kpsxg8d8pru84e1g2kh true 977.8MB 2 minutes ago
ruf87bmr4mgctx0cj3mnffggp* true 4.096kB 2 minutes ago
omaff0d52h0w7mzd1v7fopak1 true 447B 2 minutes ago
seyz9u65l1hecazjivt2q19d3 true 1.713MB 3 minutes ago
mm60ehx30ctc1rwcynyi3geij true 50.86MB 3 minutes ago
qw5ss0cy31mioby0sm7zugf3n true 3.313kB 3 minutes ago
ng5v8kg6pcmv2fl5dmx3sycua true 28.25MB 3 minutes ago
Total: 1.059GB
## after
network: bridge bridge
network: host host
network: none null
image: alpine:3 294b683cb724
Build Cache 0B
ok the pinned node image is gone
ok alpine:3 (not ours) is still there
ok no image carries a mosaic.i1spike label
ok no container is left
ok only the default networks are left (bridge host none)
ok no volume was ever created
cleanup: no container, network, image or scratch directory labelled s0-clean-images is left
result: pass
@@ -0,0 +1,39 @@
# s1-endpoint-pin 2026-10-12T19:54:35Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s1-endpoint-pin.c8StkX
## rootless endpoint, the field the launcher parses
$ docker --host unix:///run/user/1000/docker.sock info --format '{{json .SecurityOptions}}'
["name=seccomp,profile=builtin","name=rootless","name=cgroupns"]
ok rootless endpoint lists name=rootless
## one-per-line form, exact-match test
name=seccomp,profile=builtin
name=rootless
name=cgroupns
ok exact line name=rootless present
## plain-text form (what a human sees)
41: Security Options:
42- seccomp
43- Profile: builtin
44- rootless
45- cgroupns
## the Rootless field of the info struct
{{.Rootless}} ->
template: :1:2: executing "" at <.Rootless>: can't evaluate field Rootless in type system.dockerInfo
## system (rootful) endpoint, info only
["name=seccomp,profile=builtin","name=cgroupns"]
ok system endpoint does not list name=rootless
## --host wins over DOCKER_HOST
["name=seccomp,profile=builtin","name=rootless","name=cgroupns"]
ok --host overrides a DOCKER_HOST naming the system socket
## missing socket
exit 1
null
failed to connect to the docker API at unix://<scratch>/none.sock; check if the path is correct and if the daemon is running: dial unix <scratch>/none.sock: connect: no such file or directory
ok missing socket: nonzero exit, no rootless text
## a socket path that is not a daemon (a plain unix server answering garbage is not tested; a regular file is)
exit 1
null
Cannot connect to the Docker daemon at unix://<scratch>/notasocket. Is the docker daemon running?
ok regular file as endpoint: nonzero exit
cleanup: no container, network, image or scratch directory labelled s1-endpoint-pin is left
result: pass
@@ -0,0 +1,65 @@
# s2-uid-mapping 2026-10-12T19:54:36Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s2-uid-mapping.RoiHmZ
## the mapping
host uid/gid: 1000/1000; /etc/subuid line count for this user: 1
0 1000 1
1 100000 65536
0 1000 1
1 100000 65536
host fixture: -rw------- jwoltje:jwoltje <scratch>/d/file;srw------- jwoltje:jwoltje <scratch>/d/sock;
## 0600 file and 0600 socket owned by the host user
uid 0 (user: 0:0) -> {"uid":0,"gid":0,"groups":[0],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 1"}}
ok uid 0 reads the file
ok uid 0 connects to the socket
uid 1000 (user: 1000:1000) -> {"uid":1000,"gid":1000,"groups":[1000],"file":{"ok":false,"err":"EACCES"},"sock":{"ok":false,"err":"EACCES"}}
ok uid 1000 cannot read the file
ok uid 1000 cannot connect to the socket
uid 2000 (user: 2000:2000) -> {"uid":2000,"gid":2000,"groups":[2000],"file":{"ok":false,"err":"EACCES"},"sock":{"ok":false,"err":"EACCES"}}
ok uid 2000 cannot read the file
ok uid 2000 cannot connect to the socket
uid 0 with the hardening set (--cap-drop ALL, no-new-privileges, --read-only) -> {"uid":0,"gid":0,"groups":[0],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 2"}}
ok uid 0 with cap-drop ALL still reads the host user's 0600 file
ok uid 0 with cap-drop ALL still connects to the host user's 0600 socket
## 0660 and the host group, non-root container uid with the group added
host fixture: -rw-rw---- jwoltje:jwoltje;srw-rw---- jwoltje:jwoltje;
uid 1000, no extra group -> {"uid":1000,"gid":1000,"groups":[1000],"file":{"ok":false,"err":"EACCES"},"sock":{"ok":false,"err":"EACCES"}}
ok uid 1000 without the group is still refused (file)
uid 1000 + --group-add 0 -> {"uid":1000,"gid":1000,"groups":[0,1000],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 3"}}
ok uid 1000 + group 0 reads the file
ok uid 1000 + group 0 connects to the socket
uid 2000:2000 + --group-add 0 -> {"uid":2000,"gid":2000,"groups":[0,2000],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 4"}}
ok uid 2000 + group 0 connects (two roles would share it)
## world-accessible (0666) for comparison
uid 2000, no group -> {"uid":2000,"gid":2000,"groups":[2000],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 5"}}
ok uid 2000 connects when the socket is 0666
## a file chowned to a container uid, seen from the host
host sees: -rw------- 100999:100999
ok the host user can no longer read a file owned by container uid 1000 (mode 0600)
uid 1000 owner -> {"uid":1000,"gid":1000,"groups":[1000],"file":{"ok":true,"head":"not-a-secret\n"},"sock":{"ok":true,"reply":"pong host 6"}}
ok container uid 1000 reads its own file
chowned back, host sees: -rw------- 1000:1000
## the reverse: a socket created by container uid 1000 in a mounted directory
host sees: srw------- 100999:100999 (host user is 1000:1000)
host connect, mode 0600 -> {"uid":1000,"gid":1000,"groups":[3,90,108,955,956,979,982,983,985,989,995,998,1000,3009],"sock":{"ok":false,"err":"EACCES"}}
ok host user cannot connect to a 0600 socket owned by container uid 1000
after the container root chmods it 0660 (group is the container's gid 1000): host sees srw-rw---- 100999:100999
host connect, mode 0660 -> {"uid":1000,"gid":1000,"groups":[3,90,108,955,956,979,982,983,985,989,995,998,1000,3009],"sock":{"ok":false,"err":"EACCES"}}
ok host user is still refused at 0660 (not in the owning group)
host connect, mode 0666 -> {"uid":1000,"gid":1000,"groups":[3,90,108,955,956,979,982,983,985,989,995,998,1000,3009],"sock":{"ok":true,"reply":"pong container 1"}}
ok host user connects at 0666
## container to container: a socket made by one container uid, reached from another container
host sees the socket: srw-rw---- 101000:101999 (made by container 1001:2000, mode 0660)
client 1000:1000 + --group-add 2000 -> {"uid":1000,"gid":1000,"groups":[1000,2000],"sock":{"ok":true,"reply":"pong broker 1"}}
ok a different uid in the socket's group connects (mode 0660, :ro directory mount, cap-drop ALL)
client 1000:1000, no group -> {"uid":1000,"gid":1000,"groups":[1000],"sock":{"ok":false,"err":"EACCES"}}
ok a different uid outside the group is refused
client 0:0, default capabilities -> {"uid":0,"gid":0,"groups":[0],"sock":{"ok":true,"reply":"pong broker 2"}}
(container root is the host user, who owns nothing here; default capabilities include CAP_DAC_OVERRIDE over inodes mapped into the container)
ok container root with default capabilities connects: CAP_DAC_OVERRIDE overrides the mode
client 0:0, --cap-drop ALL -> {"uid":0,"gid":0,"groups":[0],"sock":{"ok":false,"err":"EACCES"}}
ok container root with cap-drop ALL is refused: it is neither owner nor group
client 0:0, --cap-drop ALL, --group-add 2000 -> {"uid":0,"gid":0,"groups":[0,2000],"sock":{"ok":true,"reply":"pong broker 3"}}
ok container root with cap-drop ALL and the group connects
cleanup: no container, network, image or scratch directory labelled s2-uid-mapping is left
result: pass
@@ -0,0 +1,44 @@
# s3-socket-restart 2026-10-12T19:54:47Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s3-socket-restart.Le6hE2
## A. directory mounted, clean stop, restart on the same path
server 1 up, container connects:
{"uid":0,"gid":0,"sock":{"ok":true,"reply":"pong first 1"}}
ok container reaches server 1
server stopped (socket file present on host: no), container connects:
{"uid":0,"gid":0,"sock":{"ok":false,"err":"ENOENT"}}
ok while stopped the connect fails
server 2 up on the same path, same container, no restart:
{"uid":0,"gid":0,"sock":{"ok":true,"reply":"pong second 1"}}
ok container reaches server 2 without a restart
## B. directory mounted, kill -9 (stale socket file left), restart
after kill -9 the socket file is on the host: yes; container connects:
{"uid":0,"gid":0,"sock":{"ok":false,"err":"ECONNREFUSED"}}
ok stale socket file: connect fails
server 3 up (removes the stale file first), same container:
{"uid":0,"gid":0,"sock":{"ok":true,"reply":"pong third 1"}}
ok container reaches server 3 without a restart
## C. socket file mounted directly, clean stop, restart on the same path
(container started although the file did not exist)
what -v made of the missing path on the host: directory
server 1 up, container connects through the mounted file:
{"uid":0,"gid":0,"sock":{"ok":true,"reply":"pong one 1"}}
ok container reaches the file-mounted server 1
server 2 up on the same host path, same container, no restart:
{"uid":0,"gid":0,"sock":{"ok":false,"err":"ECONNREFUSED"}}
ok file mount: the container does NOT reach the restarted server (bind mount pins the old inode)
a fresh container on the same path does reach it:
{"uid":0,"gid":0,"sock":{"ok":true,"reply":"pong two 1"}}
ok a new container reaches server 2
## D. --mount type=bind with a missing source
exit 125: docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: <scratch>/sf/absent
Run 'docker run --help' for more information
ok --mount refuses a missing source
ok --mount did not create the path on the host
## findings
directory mount, clean stop: connect error while down = ENOENT; after restart = works
directory mount, kill -9 : connect error with stale file = ECONNREFUSED; after restart = works
file mount, restart : ok=false err=ECONNREFUSED (container keeps the old inode)
cleanup: no container, network, image or scratch directory labelled s3-socket-restart is left
result: pass
@@ -0,0 +1,68 @@
# s4-hostile-client 2026-10-12T19:54:52Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s4-hostile-client.eOCm0T
## the broker, on a temp dataRoot (/mnt/storage/src/mosaic-stack/packages/bus/src/index.mjs, node v26.8.1)
ready <scratch>/dr/bus/broker.sock
dataRoot: drwx------ jwoltje bus/: drwx------ jwoltje bus/ holds: broker.sock bus.sqlite bus.sqlite-shm bus.sqlite-wal writer.lock
broker.sock: srw------- jwoltje
ok broker is listening
## the container: root, bus/ mounted read-only at /bus
{"case":"valid capability, verb role.claim","answer":"{\"ok\":true,\"result\":{\"role\":\"cto\",\"run\":\"spike\"}}"}
{"case":"valid capability, verb agents","answer":"{\"ok\":true,\"result\":[{\"seq\":1,\"at\":\"2026-10-12T19:54:53.338Z\",\"business\":\"demo\",\"role\":\"cto\",\"op\":\"claim\",\"holder_run\":\"spike\",\"harness\":\"pi\",\"address\":null,\"by\":\"spike\",\"reason\":null,\"decision\":null}]}"}
{"case":"made-up capability","answer":"{\"ok\":false,\"error\":\"unauthenticated\"}"}
{"case":"human envelope, made-up proof","answer":"{\"ok\":false,\"error\":\"human-required\"}"}
{"case":"human envelope, this container's own pid and start time","answer":"{\"ok\":false,\"error\":\"human-required\"}"}
{"case":"human envelope, pid 1","answer":"{\"ok\":false,\"error\":\"human-required\"}"}
{"case":"both cap and human","answer":"{\"ok\":false,\"error\":\"invalid-envelope\"}"}
{"case":"unknown key (role: human)","answer":"{\"ok\":false,\"error\":\"invalid-envelope\"}"}
{"case":"valid capability, unknown verb sql","answer":"{\"ok\":false,\"error\":\"unknown-verb\"}"}
{"case":"not json","answer":"{\"ok\":false,\"error\":\"invalid-envelope\"}"}
{"case":"request of exactly 65536 bytes (forged cap)","answer":"{\"ok\":false,\"error\":\"unauthenticated\"}"}
{"case":"request of 65537 bytes (forged cap)","answer":"{\"ok\":false,\"error\":\"request-too-large\"}"}
{"case":"70000 bytes, no newline","answer":"{\"ok\":false,\"error\":\"request-too-large\"}"}
{"case":"1 MiB of junk, newline-terminated","answer":"{\"ok\":false,\"error\":\"request-too-large\"}"}
{"case":"connect and send nothing","answer":"{\"ok\":false,\"error\":\"request-timeout\"}"}
{"case":"partial line then close","answer":"{\"ok\":false,\"error\":\"incomplete-request\"}"}
ok hostile client ran to the end
## assertions
ok a real capability works through a :ro mount of bus/ (role.claim)
ok and a read verb after the claim (agents)
ok made-up capability -> unauthenticated
ok human envelope with a made-up proof -> human-required
ok human envelope with the container's own pid -> human-required
ok human envelope with pid 1 -> human-required
ok cap and human together -> invalid-envelope
ok unknown envelope key -> invalid-envelope
ok valid capability, verb sql -> unknown-verb
ok non-JSON -> invalid-envelope
ok 65536-byte request is read in full (forged cap -> unauthenticated)
ok 65537-byte request -> request-too-large
ok 70000 bytes with no newline -> request-too-large
ok 1 MiB -> request-too-large or a reset ({"ok":false,"error":"request-too-large"})
ok silent connection -> request-timeout after the 5 s idle limit
ok partial line then close -> incomplete-request
## what a mounted bus/ also exposes (the reason the socket needs its own directory)
total 164
drwx------ 2 root root 4096 Oct 12 19:54 .
drwxr-xr-x 1 root root 4096 Oct 12 19:54 ..
srw------- 1 root root 0 Oct 12 19:54 broker.sock
-rw------- 1 root root 4096 Oct 12 19:54 bus.sqlite
-rw------- 1 root root 32768 Oct 12 19:54 bus.sqlite-shm
-rw------- 1 root root 115392 Oct 12 19:54 bus.sqlite-wal
-rw------- 1 root root 47 Oct 12 19:54 writer.lock
{"uid":0,"gid":0,"groups":[0],"file":{"ok":true,"head":"SQLite format 3\u0000"}}
ok container root can read bus.sqlite when bus/ is mounted (the two-listener change must split the directory)
as uid 1000: {"uid":1000,"gid":1000,"groups":[1000],"file":{"ok":false,"err":"EACCES"},"sock":{"ok":false,"err":"EACCES"}}
:ro write test: touch: cannot touch '/bus/x': Read-only file system
:ro write test: write attempt exit 1
## what the broker logged
stdout/stderr of the broker process while serving:
ready <scratch>/dr/bus/broker.sock
events table after close:
event {"seq":1,"kind":"session.launched","business":"demo","actor_role":"cto","subject":"spike","body":"{\"harness\":\"pi\",\"address\":null,\"pid\":null,\"startTime\":null}"}
event {"seq":2,"kind":"action.refused","business":"demo","actor_role":"cto","subject":null,"body":"{\"code\":\"unknown-verb\"}"}
events-total 2
ok no event row for a forged capability, a human-required refusal, an oversize or malformed request (the transport refuses before any identity exists)
ok the broker process printed nothing of its own while serving (only the driver's ready and event lines)
cleanup: no container, network, image or scratch directory labelled s4-hostile-client is left
result: pass
@@ -0,0 +1,54 @@
# s5-internal-network-proxy 2026-10-12T19:55:01Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s5-internal-network-proxy.xq0VUz
## networks
internal: internal=true driver=bridge subnet=172.18.0.0/16 gateway=172.18.0.1
external: internal=false driver=bridge subnet=172.19.0.0/16 gateway=172.19.0.1
## the proxy: on i1s5-ext-4170636, then also on i1s5-int-4170636 as 'proxy' (allow api.anthropic.com, api.openai.com on 443)
proxy networks: i1s5-ext i1s5-int
proxy log: 2026-10-12T19:55:02.608Z listening 3128 allow=api.anthropic.com,api.openai.com ports=443
## the agent: on i1s5-int-4170636 only
{"case":"dns: the proxy by name","ok":true,"address":"172.18.0.2"}
{"case":"dns: the allowed host","ok":false,"err":"EAI_AGAIN"}
{"case":"direct tcp to 1.1.1.1:443","ok":false,"err":"ENETUNREACH"}
{"case":"direct tls to the allowed host","ok":false,"err":"EAI_AGAIN"}
{"case":"proxy: CONNECT allowed host:443 + TLS handshake","status":200,"tls":"TLSv1.3","authorized":true}
{"case":"proxy: CONNECT denied host:443","status":403}
{"case":"proxy: CONNECT allowed host:22","status":403}
{"case":"proxy: CONNECT raw IP 1.1.1.1:443","status":403}
{"case":"proxy: CONNECT the proxy's own network gateway","status":403}
{"case":"node fetch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY","status":401}
agent networks: i1s5-int-4170636 only (it was started with --network i1s5-int-4170636)
## assertions
ok the agent resolves 'proxy' on the internal network
ok direct TCP to 1.1.1.1:443 fails
ok direct TLS to api.anthropic.com fails
ok CONNECT to api.anthropic.com:443 through the proxy -> 200 and a TLS handshake
ok the handshake verified the server certificate
ok CONNECT to example.com -> 403
ok CONNECT to the allowed host on port 22 -> 403
ok CONNECT to a raw IP -> 403
ok CONNECT to the network gateway -> 403
ok node fetch honours HTTPS_PROXY with NODE_USE_ENV_PROXY=1 (any HTTP status)
direct DNS for api.anthropic.com from the internal network: {"case":"dns: the allowed host","ok":false,"err":"EAI_AGAIN"}
## the proxy's log
2026-10-12T19:55:02.608Z listening 3128 allow=api.anthropic.com,api.openai.com ports=443
2026-10-12T19:55:03.230Z CONNECT api.anthropic.com 443
2026-10-12T19:55:03.272Z REFUSE example.com 443
2026-10-12T19:55:03.275Z REFUSE api.anthropic.com 22
2026-10-12T19:55:03.277Z REFUSE 1.1.1.1 443
2026-10-12T19:55:03.279Z REFUSE 172.18.0.1 443
2026-10-12T19:55:03.322Z CONNECT api.anthropic.com 443
ok log shows CONNECT api.anthropic.com 443
ok log shows REFUSE example.com 443
ok log shows REFUSE api.anthropic.com 22
## does -p 127.0.0.1:P:8080 publish a port for a container only on the internal network?
run on i1s5-int-4170636 with -p 127.0.0.1:36131:8080 -> exit 0: e1c2705145bffd01abe0dfec136efc2f39b4120757f9d755890f346fbddd5a9b
docker ps PORTS column: '8080/tcp'
host connect to 127.0.0.1:36131 -> error:ECONNREFUSED
control, same on ordinary bridge i1s5-ext-4170636: host connect to 127.0.0.1:43629 -> answer:"hello\n"
ok control: -p works on an ordinary bridge (the test method is sound)
published port reachable for a container only on the internal network: no
ok -p on a container only on an internal network is accepted by docker but not published (host connect: error:ECONNREFUSED)
cleanup: no container, network, image or scratch directory labelled s5-internal-network-proxy is left
result: pass
@@ -0,0 +1,103 @@
# s6-provider-clis-proxy 2026-10-12T20:00:28Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s6-provider-clis-proxy.LSLwhw
building mosaic-i1spike-clis:s6-provider-clis-proxy from node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 (npm install -g inside the image, exact versions)
built mosaic-i1spike-clis:s6-provider-clis-proxy: sha256:b10c7ac2372b68b68b96ae4e3f828fff40ae59a0b9834a5008192532d68557d5 340535329 bytes
ok the test image builds (npm install -g inside the image, exact versions)
internal network internal=true; proxy allows api.anthropic.com, api.openai.com on 443
## versions
claude: 2.1.296 (Claude Code)
pi: 0.85.1
ok claude is 2.1.296
ok pi is 0.85.1
## controls: no proxy variable, internal network only
--- claude, no proxy variable
exit 124; output:
proxy decisions:
ok claude without a proxy variable cannot reach the provider (no CONNECT, no auth answer)
--- pi, no proxy variable
exit 1; output: Connection error.
proxy decisions:
ok pi without a proxy variable cannot reach the provider (no CONNECT, no auth answer)
## through the proxy: HTTPS_PROXY only, no ANTHROPIC_BASE_URL
--- claude, HTTPS_PROXY
exit 1; output: Invalid API key · Fix external API key
proxy decisions: 14 CONNECT api.anthropic.com;1 REFUSE http-intake.logs.us5.datadoghq.com;
ok claude: proxy log shows CONNECT api.anthropic.com
ok claude: it fails on authentication, not on the network
--- pi --provider anthropic, HTTPS_PROXY
exit 1; output: 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":"req_011CfxwX4b1KZku853DzRDEd"}
proxy decisions: 1 CONNECT api.anthropic.com;
ok pi (anthropic): proxy log shows CONNECT api.anthropic.com
ok pi (anthropic): it fails on authentication, not on the network
--- pi --provider openai, HTTPS_PROXY
exit 1; output: OpenAI API error (401): {"message":"Incorrect API key provided: sk-ant-a*********************************************8bb3. You can find your API key at https://platform.openai.com/account/api-keys.","type":"invalid_reque
proxy decisions: 1 CONNECT api.openai.com;
ok pi (openai): proxy log shows CONNECT api.openai.com
ok pi (openai): it fails on authentication, not on the network
hosts tried (CONNECT or REFUSE): claude: api.anthropic.com http-intake.logs.us5.datadoghq.com | pi anthropic: api.anthropic.com | pi openai: api.openai.com
## decision 108's variable: CLAUDE_CODE_OAUTH_TOKEN (a made-up oat01-shaped value)
--- claude, HTTPS_PROXY, CLAUDE_CODE_OAUTH_TOKEN
exit 1; output: Failed to authenticate. API Error: 401 OAuth access token is invalid.
proxy decisions: 11 CONNECT api.anthropic.com;
ok claude with CLAUDE_CODE_OAUTH_TOKEN: CONNECT api.anthropic.com
ok claude with CLAUDE_CODE_OAUTH_TOKEN: the provider answers 'OAuth access token is invalid' (an authentication failure, through the proxy)
--- claude, HTTPS_PROXY, a token not shaped like an OAuth token in CLAUDE_CODE_OAUTH_TOKEN
exit 1; output: Not logged in · Please run /login
proxy decisions: 12 CONNECT api.anthropic.com;1 REFUSE http-intake.logs.us5.datadoghq.com;
ok an sk-ant-api03-shaped value there is not taken as a login: 'Not logged in', no 'OAuth access token is invalid' answer
## no credential at all (the shape of an unconfigured session)
--- claude, HTTPS_PROXY, no credential
exit 1; output: Not logged in · Please run /login
proxy decisions: 5 CONNECT api.anthropic.com;
ok claude with no credential ends with 'Not logged in' and no model request
claude still contacted: api.anthropic.com (startup traffic, before any model request)
--- pi, HTTPS_PROXY, no credential
exit 1; output: No API key found for the selected model. Use /login to log into a provider via OAuth or API key. See: /usr/local/lib/node_modules/@earendil-works/pi-coding-agent/docs/providers.md /usr/local/lib/node_modules/@earend
proxy decisions:
ok pi with no credential says 'No API key found' and contacts nothing
## which proxy variables does each CLI honour? (success = the proxy logged a CONNECT to the provider)
claude HTTPS_PROXY: honoured (exit 1; auth failure; proxy: 14 CONNECT api.anthropic.com;1 REFUSE http-intake.logs.us5.datadoghq.com;)
ok claude honours HTTPS_PROXY: CONNECT api.anthropic.com, fails on authentication
claude https_proxy: honoured (exit 1; auth failure; proxy: 14 CONNECT api.anthropic.com;1 REFUSE http-intake.logs.us5.datadoghq.com;)
ok claude honours https_proxy: CONNECT api.anthropic.com, fails on authentication
claude HTTP_PROXY: honoured (exit 1; auth failure; proxy: 12 CONNECT api.anthropic.com;)
ok claude honours HTTP_PROXY: CONNECT api.anthropic.com, fails on authentication
claude http_proxy: honoured (exit 1; auth failure; proxy: 12 CONNECT api.anthropic.com;)
ok claude honours http_proxy: CONNECT api.anthropic.com, fails on authentication
claude ALL_PROXY: honoured (exit 1; auth failure; proxy: 3 CONNECT api.anthropic.com;11 REFUSE malformed-request;)
ok claude honours ALL_PROXY: CONNECT api.anthropic.com, fails on authentication
ok claude with NODE_USE_ENV_PROXY=1 added still works
pi HTTPS_PROXY: honoured (exit 1; auth failure; proxy: 1 CONNECT api.anthropic.com;)
ok pi honours HTTPS_PROXY: CONNECT api.anthropic.com, fails on authentication
pi https_proxy: honoured (exit 1; auth failure; proxy: 1 CONNECT api.anthropic.com;)
ok pi honours https_proxy: CONNECT api.anthropic.com, fails on authentication
pi HTTP_PROXY: honoured (exit 1; auth failure; proxy: 1 CONNECT api.anthropic.com;)
ok pi honours HTTP_PROXY: CONNECT api.anthropic.com, fails on authentication
pi http_proxy: honoured (exit 1; auth failure; proxy: 1 CONNECT api.anthropic.com;)
ok pi honours http_proxy: CONNECT api.anthropic.com, fails on authentication
pi ALL_PROXY: ignored (exit 1; network failure; proxy: )
ok pi ignores ALL_PROXY: no CONNECT, fails on the network
ok pi with NODE_USE_ENV_PROXY=1 added still works
## Claude Code's other traffic
--- claude, HTTPS_PROXY, default
exit 1; output: Invalid API key · Fix external API key
proxy decisions: 14 CONNECT api.anthropic.com;1 REFUSE http-intake.logs.us5.datadoghq.com;
ok default Claude Code tries a host other than the API (telemetry): api.anthropic.com http-intake.logs.us5.datadoghq.com
--- claude, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
exit 1; output: Invalid API key · Fix external API key
proxy decisions: 3 CONNECT api.anthropic.com;
ok with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 the only host is api.anthropic.com: api.anthropic.com
--- claude, DISABLE_TELEMETRY=1
exit 1; output: Invalid API key · Fix external API key
proxy decisions: 9 CONNECT api.anthropic.com;
hosts with DISABLE_TELEMETRY=1: api.anthropic.com
## the proxy's log in full (one line per decision)
143 CONNECT api.anthropic.com 443
1 CONNECT api.openai.com 443
1 listening 3128 allow=api.anthropic.com,api.openai.com
6 REFUSE http-intake.logs.us5.datadoghq.com 443
8 REFUSE malformed-request "GET
3 REFUSE malformed-request "POST
cleanup: no container, network, image or scratch directory labelled s6-provider-clis-proxy is left
result: pass
@@ -0,0 +1,139 @@
# s7-read-only-root 2026-10-12T19:58:58Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s7-read-only-root.tDeGOD
building mosaic-i1spike-clis:s7-read-only-root from node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 (npm install -g inside the image, exact versions)
built mosaic-i1spike-clis:s7-read-only-root: sha256:7dc00a3bdae97dd139ef67a3e42a21bac9887d3248a03cd8ad08d8193b24cb74 340533914 bytes
ok the test image builds
## A. read-only root, tmpfs /tmp and tmpfs HOME (the target configuration)
--- a-claude (claude, --tmpfs /home/node:uid=1000,gid=1000 --tmpfs /tmp)
--version: exit 0 (2.1.296 (Claude Code) )
request: exit 1; Invalid API key · Fix external API key
docker diff of the writable layer: (empty)
written under HOME (type path), 9 entries:
d .claude
d .claude/backups
d .claude/projects
d .claude/projects/-
d .claude/projects/-/memory
d .claude/sessions
f .claude.json
f .claude/backups/.claude.json.backup.1791835145182
f .claude/projects/-/9bde0939-05c6-43e5-b632-15583d8df5df.jsonl
written under /tmp: 2 entries
d cc-socks
d claude-1000
ok claude starts (--version exit 0)
ok claude makes its request and fails on authentication (the made-up key)
ok claude leaves the container's writable layer empty (docker diff)
top-level names written under HOME: .claude .claude.json ; under /tmp: cc-socks claude-1000
ok claude writes only .claude and .claude.json under HOME (.claude .claude.json )
ok claude writes only cc-socks and claude-<uid> under /tmp (cc-socks claude-1000 )
--- a-pi (pi, --tmpfs /home/node:uid=1000,gid=1000 --tmpfs /tmp)
--version: exit 0 (0.85.1 )
request: exit 1; 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":"req_011CfxwDpKYNaGQV27A3w1ar"}
docker diff of the writable layer: (empty)
written under HOME (type path), 4 entries:
d .pi
d .pi/agent
f .pi/agent/auth.json
f .pi/agent/models-store.json
written under /tmp: 0 entries
ok pi starts (--version exit 0)
ok pi makes its request and fails on authentication (the made-up key)
ok pi leaves the container's writable layer empty (docker diff)
top-level names written under HOME: .pi ; under /tmp: (none)
ok pi writes only .pi under HOME (.pi )
ok pi writes nothing under /tmp
## B. the same with noexec,nosuid on both tmpfs mounts
--- b-claude (claude, --tmpfs /home/node:uid=1000,gid=1000,noexec,nosuid --tmpfs /tmp:noexec,nosuid)
--version: exit 0 (2.1.296 (Claude Code) )
request: exit 1; Invalid API key · Fix external API key
docker diff of the writable layer: (empty)
written under HOME (type path), 9 entries:
d .claude
d .claude/backups
d .claude/projects
d .claude/projects/-
d .claude/projects/-/memory
d .claude/sessions
f .claude.json
f .claude/backups/.claude.json.backup.1791835149497
f .claude/projects/-/f0f970ce-e030-4d57-a1b9-27747609509b.jsonl
written under /tmp: 2 entries
d cc-socks
d claude-1000
ok claude starts with noexec,nosuid tmpfs
ok claude with noexec,nosuid tmpfs: request fails on authentication
--- b-pi (pi, --tmpfs /home/node:uid=1000,gid=1000,noexec,nosuid --tmpfs /tmp:noexec,nosuid)
--version: exit 0 (0.85.1 )
request: exit 1; 401 {"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":"req_011CfxwE7jJJFF2SUShhaM4a"}
docker diff of the writable layer: (empty)
written under HOME (type path), 4 entries:
d .pi
d .pi/agent
f .pi/agent/auth.json
f .pi/agent/models-store.json
written under /tmp: 0 entries
ok pi starts with noexec,nosuid tmpfs
ok pi with noexec,nosuid tmpfs: request fails on authentication
## C. read-only root, tmpfs /tmp only, HOME not a tmpfs (what breaks)
--- c-claude (claude, --tmpfs /tmp)
--version: exit 0 (2.1.296 (Claude Code) )
request: exit 1; Invalid API key · Fix external API key
docker diff of the writable layer: (empty)
written under HOME (type path), 3 entries:
f .bash_logout
f .bashrc
f .profile
written under /tmp: 2 entries
d cc-socks
d claude-1000
HOME not writable: version exit 0, request exit 1
ok claude --version still works with a read-only HOME
ok claude's request still reaches the provider with a read-only HOME (request path only)
--- c-pi (pi, --tmpfs /tmp)
--version: exit 0 (0.85.1 )
request: exit 1; No API key found for the selected model. Use /login to log into a provider via OAuth or API key. See: /usr/local/lib/node_modules/@earendil-works/p
docker diff of the writable layer: (empty)
written under HOME (type path), 3 entries:
f .bash_logout
f .bashrc
f .profile
written under /tmp: 0 entries
HOME not writable: version exit 0, request exit 1
ok pi --version still works with a read-only HOME
ok pi fails without a writable HOME: 'No API key found' even though the key is in the environment
## D. read-only root, no tmpfs at all
--- d-claude (claude, )
--version: exit 0 (2.1.296 (Claude Code) )
request: exit 1; EROFS: read-only file system, mkdir '/tmp/claude-1000'
docker diff of the writable layer: (empty)
written under HOME (type path), 3 entries:
f .bash_logout
f .bashrc
f .profile
written under /tmp: 0 entries
no tmpfs: version exit 0, request exit 1
ok claude --version works on a bare read-only root
ok claude fails to make its /tmp/claude-<uid> directory: EROFS
ok claude: with no tmpfs /tmp holds only what the image holds (nothing: the Dockerfile empties it)
--- d-pi (pi, )
--version: exit 0 (0.85.1 )
request: exit 1; No API key found for the selected model. Use /login to log into a provider via OAuth or API key. See: /usr/local/lib/node_modules/@earendil-works/p
docker diff of the writable layer: (empty)
written under HOME (type path), 3 entries:
f .bash_logout
f .bashrc
f .profile
written under /tmp: 0 entries
no tmpfs: version exit 0, request exit 1
ok pi --version works on a bare read-only root
ok pi fails on a bare read-only root
ok pi: with no tmpfs /tmp holds only what the image holds (nothing: the Dockerfile empties it)
cleanup: no container, network, image or scratch directory labelled s7-read-only-root is left
result: pass
@@ -0,0 +1,41 @@
# s8-stdin-capability 2026-10-12T19:59:20Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s8-stdin-capability.c0Ac1N
made-up capability: 57 bytes, sha256 1decc1fe009c90df6a374f47142efc61ccf6e6776265026f80934cb31a3112a2 (the string itself is never printed)
## 1. one line on stdin
ok entrypoint started and printed ready
entrypoint says: got len=57 sha256=1decc1fe009c90df6a374f47142efc61ccf6e6776265026f80934cb31a3112a2
entrypoint says: ready pid=1 ppid=0
ok the entrypoint received the exact string (length and sha256 match)
Config.Env: ["READ=1","HANDLE=1","PATH=...","NODE_VERSION=24.21.0","YARN_VERSION=1.22.22"]
Config.Cmd: ["node","/t/stdin-cap.mjs"] Path: docker-entrypoint.sh Args: ["node","/t/stdin-cap.mjs"]
ok capability absent from docker inspect (whole document, incl. Config.Env, Args, Cmd)
ok capability absent from docker top
ok capability absent from docker logs
ok capability absent from /proc/1/environ
ok capability absent from /proc/1/cmdline
ok capability absent from the host process list (ps -eo args)
stdin of pid 1 after the line: pipe:[1935249370]
## 2. signals: what pid 1 receives, and the exit status
stop-plain: [] handle=0 action='stop -t 3' -> running=false exit=137 stopped-after-ms=3033 log=ready pid=1 ppid=0
ok docker stop, no init, no handler: pid 1 ignores SIGTERM, killed by SIGKILL after the grace period (exit 137, >= 3 s)
stop-handler: [] handle=1 action='stop -t 3' -> running=false exit=0 stopped-after-ms=19 log=ready pid=1 ppid=0;signal SIGTERM pid=1
ok docker stop, no init, handler: pid 1 receives SIGTERM as pid=1, exits 0 promptly
stop-init: [--init] handle=0 action='stop -t 3' -> running=false exit=143 stopped-after-ms=18 log=ready pid=7 ppid=1
ok docker stop, --init, no handler: tini forwards SIGTERM, the default action ends node (exit 143, prompt)
stop-init-hand: [--init] handle=1 action='stop -t 3' -> running=false exit=0 stopped-after-ms=31 log=ready pid=7 ppid=1;signal SIGTERM pid=7
ok docker stop, --init, handler: node receives SIGTERM as a child, not pid 1
kill-plain: [] handle=0 action='kill' -> running=false exit=137 stopped-after-ms=21 log=ready pid=1 ppid=0
ok docker kill (SIGKILL), no init: exit 137
kill-handler: [] handle=1 action='kill' -> running=false exit=137 stopped-after-ms=20 log=ready pid=1 ppid=0
ok docker kill (SIGKILL), handler: handler never runs, exit 137
kill-init: [--init] handle=0 action='kill' -> running=false exit=137 stopped-after-ms=20 log=ready pid=7 ppid=1
ok docker kill (SIGKILL), --init: exit 137
killterm-plain: [] handle=0 action='kill -s TERM' -> running=true exit=0 stopped-after-ms=n/a log=ready pid=1 ppid=0
ok docker kill -s TERM, no init, no handler: ignored, container still running
killterm-init: [--init] handle=0 action='kill -s TERM' -> running=false exit=143 stopped-after-ms=22 log=ready pid=7 ppid=1
ok docker kill -s TERM, --init, no handler: ends the container (exit 143)
killterm-hand: [] handle=1 action='kill -s TERM' -> running=false exit=0 stopped-after-ms=22 log=ready pid=1 ppid=0;signal SIGTERM pid=1
ok docker kill -s TERM, no init, handler: pid 1 receives it, exit 0
cleanup: no container, network, image or scratch directory labelled s8-stdin-capability is left
result: pass
@@ -0,0 +1,72 @@
# s9-hardening 2026-10-12T19:59:38Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1spike-s9-hardening.GZv4Fb
## the daemon
cgroup version 2, driver systemd, warnings null
controllers delegated to the user manager: cpuset cpu io memory pids
## control: no hardening flags (the defaults), as container root and as uid 1000
user 0:0 {"uid":0,"CapEff":"00000000a80425fb","CapBnd":"00000000a80425fb","NoNewPrivs":"0","Seccomp":"2","pidsMax":"76510","memoryMax":"max","swapMax":"max","cpuMax":"max 100000","writeRoot":"ok","writeTmp":"ok"}
ok control (0:0): default CapEff is non-zero (00000000a80425fb)
ok control (0:0): NoNewPrivs 0 without the flag
ok control (0:0): default seccomp filter active (Seccomp: 2)
ok control (0:0): pids.max is the cgroup default, not 32 (76510)
ok control (0:0): no memory limit (max)
ok control (0:0): root filesystem is writable
user 1000 {"uid":1000,"CapEff":"0000000000000000","CapBnd":"00000000a80425fb","NoNewPrivs":"0","Seccomp":"2","pidsMax":"76510","memoryMax":"max","swapMax":"max","cpuMax":"max 100000","writeRoot":"EACCES","writeTmp":"ok"}
ok control (1000): a non-root uid already has CapEff 0 and cannot write / (EACCES, a permission, not a mount)
## each flag alone: does the daemon accept it quietly?
--cap-drop ALL (as 0:0) -> exit 0; 0 warning line(s)
ok cap-drop ALL: CapEff 0 even for container root
--security-opt no-new-privileges -> exit 0; 0 warning line(s)
ok no-new-privileges: NoNewPrivs 1
--read-only -> exit 0; 0 warning line(s)
ok read-only: writes to / fail with EROFS, /tmp too without tmpfs
--read-only --tmpfs /tmp -> exit 0; 0 warning line(s)
ok read-only + tmpfs: /tmp writable, / not
--pids-limit 32 -> exit 0; 0 warning line(s)
ok pids-limit: pids.max 32
--memory 128m -> exit 0; 0 warning line(s)
ok memory: memory.max 134217728
--memory 128m --memory-swap 128m -> exit 0; 0 warning line(s)
ok memory-swap equal to memory: memory.swap.max 0
--cpus 0.5 -> exit 0; 0 warning line(s)
ok cpus: cpu.max '50000 100000'
## all together, read back from docker inspect and from inside
HostConfig.CapDrop ["ALL"]
HostConfig.CapAdd null
HostConfig.SecurityOpt ["no-new-privileges"]
HostConfig.ReadonlyRootfs true
HostConfig.Tmpfs {"/tmp":"rw,noexec,nosuid,size=16m"}
HostConfig.PidsLimit 32
HostConfig.Memory 134217728
HostConfig.MemorySwap 134217728
HostConfig.NanoCpus 500000000
HostConfig.Privileged false
HostConfig.CgroupnsMode "private"
ok inspect: CapDrop is [ALL]
ok inspect: SecurityOpt carries no-new-privileges
ok inspect: ReadonlyRootfs true
ok inspect: PidsLimit 32
ok inspect: Memory 134217728
ok inspect: NanoCpus 500000000
ok inspect: not privileged
inside: {"uid":1000,"CapEff":"0000000000000000","CapBnd":"0000000000000000","NoNewPrivs":"1","Seccomp":"2","pidsMax":"32","memoryMax":"134217728","swapMax":"0","cpuMax":"50000 100000","writeRoot":"EROFS","writeTmp":"ok","fork":{"attempted":200,"started":24,"refused":176}}
ok inside: CapEff 0, CapBnd 0
ok inside: NoNewPrivs 1
ok inside: Seccomp 2 (default profile still on)
ok inside: pids.max 32
ok inside: memory.max 134217728, swap 0
ok inside: cpu.max 50000 100000
ok inside: / read-only, /tmp tmpfs writable
fork burst: 200 attempted, 24 started, 176 refused
ok the pids limit holds: some spawns refused, started <= 32
/tmp mount inside: tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime,size=16384k,uid=1000,gid=1000,inode64,huge=advise 0 0
## memory limit enforced
exit 137; OOMKilled=true; output tail: {"uid":1000,"CapEff":"0000000000000000","CapBnd":"0000000000000000","NoNewPrivs":"1","Seccomp":"2","pidsMax":"32","memor
ok allocating 400 MiB under a 128 MiB limit is OOM-killed (exit 137)
## things the flags do not stop (recorded, not asserted as hardening)
container root with the same flags (user 0:0) maps to host uid 1000; CapEff:
0000000000000000
ok container root with cap-drop ALL has CapEff 0
cleanup: no container, network, image or scratch directory labelled s9-hardening is left
result: pass
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# S0, run last: removes what the spikes share and no single spike removes. The
# pinned node image is pulled once and used by every spike; the S6/S7 builds leave
# BuildKit cache. This script removes the node image (by digest), any image still
# carrying a mosaic.i1spike label, and the build cache, then prints `ps -a`,
# `network ls`, `image ls -a`, `volume ls` and `system df` so the end state is
# visible. It leaves alpine:3, which was on the daemon before the spikes began and
# is not ours. The build cache was 0 B before the first spike (Researcher,
# 2026-10-12T19:20Z, `docker system df`), so pruning it removes only our cache.
. "$(dirname "$0")/lib.sh"
spike_begin s0-clean-images
cd "$HERE" || exit 2
say "## before"
D image ls -a --format ' {{.Repository}}:{{.Tag}} {{.ID}}'
say " build cache: $(D system df --format '{{.Type}} {{.Size}}' | grep -i 'Build Cache')"
say "## remove"
USERS=$(D ps -a -q --filter "ancestor=$NODE_IMG")
check "no container (of anyone) is using the pinned node image" "$([ -z "$USERS" ]; echo $?)"
if [ -z "$USERS" ]; then D image rm "$NODE_IMG" 2>&1 | sed 's/^/ /'; fi
IDS=$(D image ls -a -q --filter "label=mosaic.i1spike" ); [ -n "$IDS" ] && D image rm -f $IDS 2>&1 | sed 's/^/ /'
D builder prune -f 2>&1 | sed 's/^/ /'
say "## after"
D ps -a --format ' container: {{.Names}} {{.Image}}' ; D network ls --format ' network: {{.Name}} {{.Driver}}' ; D volume ls --format ' volume: {{.Name}}'
D image ls -a --format ' image: {{.Repository}}:{{.Tag}} {{.ID}}'
say " $(D system df --format '{{.Type}} {{.Size}}' | grep -i 'Build Cache')"
check "the pinned node image is gone" "$(D image inspect "$NODE_IMG" >/dev/null 2>&1; [ $? -ne 0 ]; echo $?)"
check "alpine:3 (not ours) is still there" "$(D image inspect alpine:3 >/dev/null 2>&1; echo $?)"
check "no image carries a mosaic.i1spike label" "$([ -z "$(D image ls -a -q --filter label=mosaic.i1spike)" ]; echo $?)"
check "no container is left" "$([ -z "$(D ps -a -q)" ]; echo $?)"
check "only the default networks are left (bridge host none)" "$([ "$(D network ls --format '{{.Name}}' | sort | tr '\n' ' ')" = 'bridge host none ' ]; echo $?)"
check "no volume was ever created" "$([ -z "$(D volume ls -q)" ]; echo $?)"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# S1, endpoint pin. Which field of `docker info` the launcher parses to refuse a
# non-rootless endpoint, what the rootful daemon reports, what a missing socket
# prints. Creates nothing. The one call against the system socket is `info`,
# which the brief's S1 asks for; no other spike touches it.
. "$(dirname "$0")/lib.sh"
spike_begin s1-endpoint-pin
say "## rootless endpoint, the field the launcher parses"
say "\$ docker --host $EP info --format '{{json .SecurityOptions}}'"
R=$(D info --format '{{json .SecurityOptions}}'); say "$R"
case $R in *'"name=rootless"'*) check 'rootless endpoint lists name=rootless' 0 ;; *) check 'rootless endpoint lists name=rootless' 1 ;; esac
say "## one-per-line form, exact-match test"
L=$(D info --format '{{range .SecurityOptions}}{{println .}}{{end}}'); say "$L"
printf '%s\n' "$L" | grep -qx 'name=rootless'; check 'exact line name=rootless present' $?
say "## plain-text form (what a human sees)"
D info 2>/dev/null | grep -n -A4 'Security Options' | sed 's/^/ /'
say "## the Rootless field of the info struct"
RF=$(D info --format '{{.Rootless}}' 2>&1); say "{{.Rootless}} -> $RF"
say "## system (rootful) endpoint, info only"
S=$(docker --host unix:///var/run/docker.sock info --format '{{json .SecurityOptions}}' 2>&1); say "$S"
case $S in *'"name=rootless"'*) check 'system endpoint does not list name=rootless' 1 ;; *) check 'system endpoint does not list name=rootless' 0 ;; esac
say "## --host wins over DOCKER_HOST"
W=$(DOCKER_HOST=unix:///var/run/docker.sock docker --host "$EP" info --format '{{json .SecurityOptions}}' 2>&1); say "$W"
case $W in *'"name=rootless"'*) check '--host overrides a DOCKER_HOST naming the system socket' 0 ;; *) check '--host overrides DOCKER_HOST' 1 ;; esac
say "## missing socket"
M=$(docker --host unix://$SCRATCH/none.sock info --format '{{json .SecurityOptions}}' 2>&1); MRC=$?
say "exit $MRC"; say "$M" | sed "s#$SCRATCH#<scratch>#g"
[ "$MRC" -ne 0 ] && ! printf '%s' "$M" | grep -q rootless; check 'missing socket: nonzero exit, no rootless text' $?
say "## a socket path that is not a daemon (a plain unix server answering garbage is not tested; a regular file is)"
: > "$SCRATCH/notasocket"
N=$(docker --host unix://$SCRATCH/notasocket info --format '{{json .SecurityOptions}}' 2>&1); NRC=$?
say "exit $NRC"; say "$N" | sed "s#$SCRATCH#<scratch>#g"
[ "$NRC" -ne 0 ]; check 'regular file as endpoint: nonzero exit' $?
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# S2, uid mapping for files and sockets under the rootless daemon. A host
# directory owned by the host user holds a 0600 file and a 0600 socket (a node
# server on the host). A container mounts it and tries both as container uid 0,
# as 1000:1000 and as 2000:2000, then as 1000:1000 with the host group added.
# The reverse direction: a socket created by a non-root container uid in a
# mounted directory, reached from the host. Fixtures hold no secret.
. "$(dirname "$0")/lib.sh"
spike_begin s2-uid-mapping
need_node_image
cd "$HERE" || exit 2
HOSTUID=$(id -u); HOSTGID=$(id -g)
mkdir -p "$SCRATCH/tools" "$SCRATCH/d"
cp tools/probe.mjs tools/sockserver.mjs "$SCRATCH/tools/"
echo "not-a-secret" > "$SCRATCH/d/file"; chmod 600 "$SCRATCH/d/file"
chmod 755 "$SCRATCH/d"
say "## the mapping"
say "host uid/gid: $HOSTUID/$HOSTGID; /etc/subuid line count for this user: $(grep -c "^$(id -un):" /etc/subuid)"
D run --rm --label "$LABEL" "$NODE_IMG" cat /proc/self/uid_map /proc/self/gid_map | sed 's/^/ /'
node tools/sockserver.mjs "$SCRATCH/d/sock" 600 host > "$SCRATCH/server.log" 2>&1 &
SRV=$!
trap '_rc=$?; kill $SRV 2>/dev/null; (exit $_rc); spike_end' EXIT
for _ in $(seq 50); do [ -S "$SCRATCH/d/sock" ] && break; sleep 0.1; done
say "host fixture: $(stat -c '%A %U:%G %n' "$SCRATCH/d/file" "$SCRATCH/d/sock" | sed "s#$SCRATCH#<scratch>#" | tr '\n' ';')"
probe() { # label, docker-run extra flags...
local label=$1; shift
local out
out=$(D run --rm --label "$LABEL" "$@" -v "$SCRATCH/d:/d" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" \
node /t/probe.mjs --file /d/file --sock /d/sock 2>&1)
say " $label -> $out"
PROBE=$out
}
jget() { node -e 'const o=JSON.parse(process.argv[1]);const p=process.argv[2].split(".");let v=o;for(const k of p)v=v?.[k];console.log(v)' "$PROBE" "$1"; }
say "## 0600 file and 0600 socket owned by the host user"
probe "uid 0 (user: 0:0)" --user 0:0
check "uid 0 reads the file" "$([ "$(jget file.ok)" = true ]; echo $?)"
check "uid 0 connects to the socket" "$([ "$(jget sock.ok)" = true ]; echo $?)"
probe "uid 1000 (user: 1000:1000)" --user 1000:1000
check "uid 1000 cannot read the file" "$([ "$(jget file.ok)" = false ]; echo $?)"
check "uid 1000 cannot connect to the socket" "$([ "$(jget sock.ok)" = false ]; echo $?)"
probe "uid 2000 (user: 2000:2000)" --user 2000:2000
check "uid 2000 cannot read the file" "$([ "$(jget file.ok)" = false ]; echo $?)"
check "uid 2000 cannot connect to the socket" "$([ "$(jget sock.ok)" = false ]; echo $?)"
# The hardening set of decision 105-108 drops every capability; owner access to a
# file the host user owns is a mode-bit check, not a capability.
probe "uid 0 with the hardening set (--cap-drop ALL, no-new-privileges, --read-only)" --user 0:0 --cap-drop ALL --security-opt no-new-privileges --read-only
check "uid 0 with cap-drop ALL still reads the host user's 0600 file" "$([ "$(jget file.ok)" = true ]; echo $?)"
check "uid 0 with cap-drop ALL still connects to the host user's 0600 socket" "$([ "$(jget sock.ok)" = true ]; echo $?)"
say "## 0660 and the host group, non-root container uid with the group added"
chmod 660 "$SCRATCH/d/file" "$SCRATCH/d/sock"
say "host fixture: $(stat -c '%A %U:%G' "$SCRATCH/d/file" "$SCRATCH/d/sock" | tr '\n' ';')"
probe "uid 1000, no extra group" --user 1000:1000
check "uid 1000 without the group is still refused (file)" "$([ "$(jget file.ok)" = false ]; echo $?)"
probe "uid 1000 + --group-add 0" --user 1000:1000 --group-add 0
check "uid 1000 + group 0 reads the file" "$([ "$(jget file.ok)" = true ]; echo $?)"
check "uid 1000 + group 0 connects to the socket" "$([ "$(jget sock.ok)" = true ]; echo $?)"
probe "uid 2000:2000 + --group-add 0" --user 2000:2000 --group-add 0
check "uid 2000 + group 0 connects (two roles would share it)" "$([ "$(jget sock.ok)" = true ]; echo $?)"
say "## world-accessible (0666) for comparison"
chmod 666 "$SCRATCH/d/file" "$SCRATCH/d/sock"
probe "uid 2000, no group" --user 2000:2000
check "uid 2000 connects when the socket is 0666" "$([ "$(jget sock.ok)" = true ]; echo $?)"
say "## a file chowned to a container uid, seen from the host"
chmod 600 "$SCRATCH/d/file"
D run --rm --label "$LABEL" --user 0:0 -v "$SCRATCH/d:/d" "$NODE_IMG" chown 1000:1000 /d/file
say "host sees: $(stat -c '%A %u:%g' "$SCRATCH/d/file")"
cat "$SCRATCH/d/file" >/dev/null 2>&1; HOSTREAD=$?
check "the host user can no longer read a file owned by container uid 1000 (mode 0600)" "$([ "$HOSTREAD" -ne 0 ]; echo $?)"
probe "uid 1000 owner" --user 1000:1000
check "container uid 1000 reads its own file" "$([ "$(jget file.ok)" = true ]; echo $?)"
D run --rm --label "$LABEL" --user 0:0 -v "$SCRATCH/d:/d" "$NODE_IMG" chown 0:0 /d/file
say "chowned back, host sees: $(stat -c '%A %u:%g' "$SCRATCH/d/file")"
say "## the reverse: a socket created by container uid 1000 in a mounted directory"
mkdir -p "$SCRATCH/rev"; chmod 777 "$SCRATCH/rev"
D run -d --name "i1s2-rev-$$" --label "$LABEL" --user 1000:1000 -v "$SCRATCH/rev:/r" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" \
node /t/sockserver.mjs /r/sock 600 container >/dev/null
for _ in $(seq 50); do [ -S "$SCRATCH/rev/sock" ] && break; sleep 0.1; done
say "host sees: $(stat -c '%A %u:%g' "$SCRATCH/rev/sock") (host user is $HOSTUID:$HOSTGID)"
HC=$(node tools/probe.mjs --sock "$SCRATCH/rev/sock"); say "host connect, mode 0600 -> $HC"
PROBE=$HC
check "host user cannot connect to a 0600 socket owned by container uid 1000" "$([ "$(jget sock.ok)" = false ]; echo $?)"
D exec --user 0:0 "i1s2-rev-$$" chmod 660 /r/sock
say "after the container root chmods it 0660 (group is the container's gid 1000): host sees $(stat -c '%A %u:%g' "$SCRATCH/rev/sock")"
HC=$(node tools/probe.mjs --sock "$SCRATCH/rev/sock"); say "host connect, mode 0660 -> $HC"
PROBE=$HC
check "host user is still refused at 0660 (not in the owning group)" "$([ "$(jget sock.ok)" = false ]; echo $?)"
D exec --user 0:0 "i1s2-rev-$$" chmod 666 /r/sock
HC=$(node tools/probe.mjs --sock "$SCRATCH/rev/sock"); say "host connect, mode 0666 -> $HC"
PROBE=$HC
check "host user connects at 0666" "$([ "$(jget sock.ok)" = true ]; echo $?)"
D rm -f "i1s2-rev-$$" >/dev/null
say "## container to container: a socket made by one container uid, reached from another container"
# The broker will be a container that makes its own socket; the Console and the agent
# containers are other containers on the same daemon, so they share one uid map.
mkdir -p "$SCRATCH/c2c"; chmod 777 "$SCRATCH/c2c"
D run -d --name "i1s2-c2c-$$" --label "$LABEL" --user 1001:2000 -v "$SCRATCH/c2c:/r" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" \
node /t/sockserver.mjs /r/sock 660 broker >/dev/null
for _ in $(seq 50); do [ -S "$SCRATCH/c2c/sock" ] && break; sleep 0.1; done
say "host sees the socket: $(stat -c '%A %u:%g' "$SCRATCH/c2c/sock") (made by container 1001:2000, mode 0660)"
cprobe() { # label, docker-run extra flags...
local label=$1; shift
PROBE=$(D run --rm --label "$LABEL" "$@" -v "$SCRATCH/c2c:/r:ro" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/probe.mjs --sock /r/sock 2>&1)
say " $label -> $PROBE"
}
cprobe "client 1000:1000 + --group-add 2000" --user 1000:1000 --group-add 2000 --cap-drop ALL
check "a different uid in the socket's group connects (mode 0660, :ro directory mount, cap-drop ALL)" "$([ "$(jget sock.ok)" = true ]; echo $?)"
cprobe "client 1000:1000, no group" --user 1000:1000 --cap-drop ALL
check "a different uid outside the group is refused" "$([ "$(jget sock.ok)" = false ]; echo $?)"
cprobe "client 0:0, default capabilities" --user 0:0
say " (container root is the host user, who owns nothing here; default capabilities include CAP_DAC_OVERRIDE over inodes mapped into the container)"
check "container root with default capabilities connects: CAP_DAC_OVERRIDE overrides the mode" "$([ "$(jget sock.ok)" = true ]; echo $?)"
cprobe "client 0:0, --cap-drop ALL" --user 0:0 --cap-drop ALL
check "container root with cap-drop ALL is refused: it is neither owner nor group" "$([ "$(jget sock.ok)" = false ]; echo $?)"
cprobe "client 0:0, --cap-drop ALL, --group-add 2000" --user 0:0 --cap-drop ALL --group-add 2000
check "container root with cap-drop ALL and the group connects" "$([ "$(jget sock.ok)" = true ]; echo $?)"
D rm -f "i1s2-c2c-$$" >/dev/null
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
# S3, a host socket across a server restart. A long-running container mounts a
# socket, once as its directory and once as the socket file itself. The host
# server stops and starts again on the same path; the container is not
# restarted. Variants: a clean stop (server removes its socket) and a kill -9
# (stale socket file left behind). Also what `-v` and `--mount` do when the
# socket path does not exist yet. Containers run as container root, which is
# the host user under rootless (S2). Fixtures hold no secret.
. "$(dirname "$0")/lib.sh"
spike_begin s3-socket-restart
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools" "$SCRATCH/sd" "$SCRATCH/sf"
cp tools/probe.mjs tools/sockserver.mjs "$SCRATCH/tools/"
SRV=
trap '_rc=$?; [ -n "$SRV" ] && kill -9 $SRV 2>/dev/null; (exit $_rc); spike_end' EXIT
start_srv() { # path tag
node tools/sockserver.mjs "$1" 600 "$2" >> "$SCRATCH/server.log" 2>&1 &
SRV=$!
for _ in $(seq 50); do [ -S "$1" ] && return 0; sleep 0.1; done
return 1
}
stop_srv() { kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=; }
kill_srv() { kill -KILL "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=; }
cprobe() { # container path
PROBE=$(D exec "$1" node /t/probe.mjs --sock "$2" 2>&1)
say " $PROBE" | sed 's/"groups":\[[^]]*\],//'
}
jget() { node -e 'const o=JSON.parse(process.argv[1]);const p=process.argv[2].split(".");let v=o;for(const k of p)v=v?.[k];console.log(v)' "$PROBE" "$1"; }
is() { [ "$(jget "$1")" = "$2" ]; echo $?; }
say "## A. directory mounted, clean stop, restart on the same path"
D run -d --name "i1s3-dir-$$" --label "$LABEL" --user 0:0 -v "$SCRATCH/sd:/sd" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" sleep 600 >/dev/null
start_srv "$SCRATCH/sd/sock" first
say "server 1 up, container connects:"; cprobe "i1s3-dir-$$" /sd/sock
check "container reaches server 1" "$(is sock.reply 'pong first 1')"
stop_srv
say "server stopped (socket file present on host: $([ -e "$SCRATCH/sd/sock" ] && echo yes || echo no)), container connects:"; cprobe "i1s3-dir-$$" /sd/sock
check "while stopped the connect fails" "$(is sock.ok false)"
DOWNERR=$(jget sock.err)
start_srv "$SCRATCH/sd/sock" second
say "server 2 up on the same path, same container, no restart:"; cprobe "i1s3-dir-$$" /sd/sock
check "container reaches server 2 without a restart" "$(is sock.reply 'pong second 1')"
say "## B. directory mounted, kill -9 (stale socket file left), restart"
kill_srv
say "after kill -9 the socket file is on the host: $([ -e "$SCRATCH/sd/sock" ] && echo yes || echo no); container connects:"; cprobe "i1s3-dir-$$" /sd/sock
check "stale socket file: connect fails" "$(is sock.ok false)"
STALEERR=$(jget sock.err)
start_srv "$SCRATCH/sd/sock" third
say "server 3 up (removes the stale file first), same container:"; cprobe "i1s3-dir-$$" /sd/sock
check "container reaches server 3 without a restart" "$(is sock.reply 'pong third 1')"
stop_srv
say "## C. socket file mounted directly, clean stop, restart on the same path"
D run -d --name "i1s3-file-$$" --label "$LABEL" --user 0:0 -v "$SCRATCH/sf/sock:/sock" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" sleep 600 >/dev/null 2>&1 \
&& say "(container started although the file did not exist)" || say "(-v with a missing file path: container did not start)"
D rm -f "i1s3-file-$$" >/dev/null 2>&1
say "what -v made of the missing path on the host: $(stat -c '%F' "$SCRATCH/sf/sock" 2>&1 | sed "s#$SCRATCH#<scratch>#")"
rm -rf "$SCRATCH/sf/sock"
start_srv "$SCRATCH/sf/sock" one
D run -d --name "i1s3-file-$$" --label "$LABEL" --user 0:0 -v "$SCRATCH/sf/sock:/sock" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" sleep 600 >/dev/null
say "server 1 up, container connects through the mounted file:"; cprobe "i1s3-file-$$" /sock
check "container reaches the file-mounted server 1" "$(is sock.reply 'pong one 1')"
stop_srv
start_srv "$SCRATCH/sf/sock" two
say "server 2 up on the same host path, same container, no restart:"; cprobe "i1s3-file-$$" /sock
FILEOK=$(jget sock.ok); FILEERR=$(jget sock.err)
check "file mount: the container does NOT reach the restarted server (bind mount pins the old inode)" "$(is sock.ok false)"
say "a fresh container on the same path does reach it:"
FRESH=$(D run --rm --label "$LABEL" --user 0:0 -v "$SCRATCH/sf/sock:/sock" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/probe.mjs --sock /sock)
PROBE=$FRESH; say " $FRESH" | sed 's/"groups":\[[^]]*\],//'
check "a new container reaches server 2" "$(is sock.reply 'pong two 1')"
stop_srv
say "## D. --mount type=bind with a missing source"
MM=$(D run --rm --label "$LABEL" --mount "type=bind,src=$SCRATCH/sf/absent,dst=/x" "$NODE_IMG" true 2>&1); MMRC=$?
say "exit $MMRC: $MM" | sed "s#$SCRATCH#<scratch>#g"
check "--mount refuses a missing source" "$([ "$MMRC" -ne 0 ]; echo $?)"
[ ! -e "$SCRATCH/sf/absent" ]; check "--mount did not create the path on the host" $?
say "## findings"
say "directory mount, clean stop: connect error while down = $DOWNERR; after restart = works"
say "directory mount, kill -9 : connect error with stale file = $STALEERR; after restart = works"
say "file mount, restart : ok=$FILEOK err=$FILEERR (container keeps the old inode)"
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# S4, a hostile client over a mounted broker socket. The repository's broker
# (startBroker, as the bus tests start it) runs on the host with a temp
# dataRoot. The dataRoot's bus/ directory is mounted read-only into a container
# running as container root (the host user, see S2). The container sends a
# human envelope with made-up proofs, a made-up capability, and oversized
# requests, and the script records each answer and what the broker logged.
# The one capability used is a throwaway issued by the temp broker for a
# made-up business; nothing here is a real credential or the live bus.
. "$(dirname "$0")/lib.sh"
spike_begin s4-hostile-client
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools" "$SCRATCH/share"
cp tools/probe.mjs tools/hostile.mjs "$SCRATCH/tools/"
DR="$SCRATCH/dr"; mkdir -m 700 "$DR"
SRV=
trap '_rc=$?; [ -n "$SRV" ] && kill -9 $SRV 2>/dev/null; (exit $_rc); spike_end' EXIT
say "## the broker, on a temp dataRoot ($REPO/packages/bus/src/index.mjs, node $(node -v))"
node tools/s4-broker.mjs "$REPO" "$DR" "$SCRATCH/share/cap" > "$SCRATCH/broker.out" 2>&1 &
SRV=$!
for _ in $(seq 100); do grep -q '^ready' "$SCRATCH/broker.out" 2>/dev/null && break; sleep 0.1; done
sed "s#$SCRATCH#<scratch>#g" "$SCRATCH/broker.out"
say "dataRoot: $(stat -c '%A %U' "$DR") bus/: $(stat -c '%A %U' "$DR/bus") bus/ holds: $(ls -A "$DR/bus" | tr '\n' ' ')"
say "broker.sock: $(stat -c '%A %U' "$DR/bus/broker.sock")"
check "broker is listening" "$([ -S "$DR/bus/broker.sock" ]; echo $?)"
say "## the container: root, bus/ mounted read-only at /bus"
D run --rm --label "$LABEL" --user 0:0 -v "$DR/bus:/bus:ro" -v "$SCRATCH/share:/share:ro" -v "$SCRATCH/tools:/t:ro" \
"$NODE_IMG" node /t/hostile.mjs /bus/broker.sock /share/cap > "$SCRATCH/hostile.out" 2>&1
HRC=$?
sed 's/^/ /' "$SCRATCH/hostile.out"
check "hostile client ran to the end" "$HRC"
ans() { node -e '
const lines = require("fs").readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
const l = lines.find((x) => x.case === process.argv[2]);
process.stdout.write(l ? l.answer : "(case missing)");' "$SCRATCH/hostile.out" "$1"; }
has() { case "$(ans "$1")" in *"$2"*) echo 0 ;; *) echo 1 ;; esac; }
say "## assertions"
check "a real capability works through a :ro mount of bus/ (role.claim)" "$(has 'valid capability, verb role.claim' '"ok":true')"
check "and a read verb after the claim (agents)" "$(has 'valid capability, verb agents' '"ok":true')"
check "made-up capability -> unauthenticated" "$(has 'made-up capability' 'unauthenticated')"
check "human envelope with a made-up proof -> human-required" "$(has 'human envelope, made-up proof' 'human-required')"
check "human envelope with the container's own pid -> human-required" "$(has "human envelope, this container's own pid and start time" 'human-required')"
check "human envelope with pid 1 -> human-required" "$(has 'human envelope, pid 1' 'human-required')"
check "cap and human together -> invalid-envelope" "$(has 'both cap and human' 'invalid-envelope')"
check "unknown envelope key -> invalid-envelope" "$(has 'unknown key (role: human)' 'invalid-envelope')"
check "valid capability, verb sql -> unknown-verb" "$(has 'valid capability, unknown verb sql' 'unknown-verb')"
check "non-JSON -> invalid-envelope" "$(has 'not json' 'invalid-envelope')"
check "65536-byte request is read in full (forged cap -> unauthenticated)" "$(has 'request of exactly 65536 bytes (forged cap)' 'unauthenticated')"
check "65537-byte request -> request-too-large" "$(has 'request of 65537 bytes (forged cap)' 'request-too-large')"
check "70000 bytes with no newline -> request-too-large" "$(has '70000 bytes, no newline' 'request-too-large')"
A=$(ans '1 MiB of junk, newline-terminated')
case $A in *request-too-large*|*'socket error'*) check "1 MiB -> request-too-large or a reset ($A)" 0 ;; *) check "1 MiB -> request-too-large or a reset ($A)" 1 ;; esac
check "silent connection -> request-timeout after the 5 s idle limit" "$(has 'connect and send nothing' 'request-timeout')"
check "partial line then close -> incomplete-request" "$(has 'partial line then close' 'incomplete-request')"
say "## what a mounted bus/ also exposes (the reason the socket needs its own directory)"
D run --rm --label "$LABEL" --user 0:0 -v "$DR/bus:/bus:ro" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" \
sh -c 'ls -la /bus; node /t/probe.mjs --file /bus/bus.sqlite' | sed 's/^/ /'
EXPOSED=$(D run --rm --label "$LABEL" --user 0:0 -v "$DR/bus:/bus:ro" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/probe.mjs --file /bus/bus.sqlite)
case $EXPOSED in *'"ok":true'*SQLite*) check "container root can read bus.sqlite when bus/ is mounted (the two-listener change must split the directory)" 0 ;; *) check "container root can read bus.sqlite when bus/ is mounted" 1 ;; esac
D run --rm --label "$LABEL" --user 1000:1000 -v "$DR/bus:/bus:ro" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" \
node /t/probe.mjs --file /bus/bus.sqlite --sock /bus/broker.sock | sed 's/^/ as uid 1000: /'
D run --rm --label "$LABEL" --user 0:0 -v "$DR/bus:/bus:ro" "$NODE_IMG" sh -c 'touch /bus/x 2>&1; echo "write attempt exit $?"' | sed 's/^/ :ro write test: /'
say "## what the broker logged"
say "stdout/stderr of the broker process while serving:"; sed "s#$SCRATCH#<scratch>#g;s/^/ /" "$SCRATCH/broker.out"
kill -TERM "$SRV"; wait "$SRV" 2>/dev/null; SRV=
say "events table after close:"; sed -n '/^event\|^events-total/p' "$SCRATCH/broker.out" | cut -c1-260 | sed 's/^/ /'
grep -qE 'unauthenticated|human-required|request-too-large|invalid-envelope|incomplete-request|request-timeout' <(sed -n '/^event/p' "$SCRATCH/broker.out")
check "no event row for a forged capability, a human-required refusal, an oversize or malformed request (the transport refuses before any identity exists)" "$([ $? -ne 0 ]; echo $?)"
[ "$(grep -vcE '^(ready|event|events-total)' "$SCRATCH/broker.out")" -eq 0 ]
check "the broker process printed nothing of its own while serving (only the driver's ready and event lines)" $?
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# S5, an internal network plus one egress proxy. The agent container sits on an
# `internal: true` network only. The proxy container sits on that network and on
# an external bridge and tunnels CONNECT for two named hosts. From the agent:
# direct HTTPS fails, an allowed host connects through the proxy, others are
# refused. Also: does `-p 127.0.0.1:P:P` publish a port for a container that is
# only on the internal network (with a control on an ordinary bridge)?
#
# Proxy choice (recorded for REPORT.md): tools/connect-proxy.mjs, run on the same
# pinned node image as every other container. It adds no second image to pin and
# trust, the whole proxy is 43 readable lines, and its log is one line per decision.
# A production proxy (squid, tinyproxy) would sit in the same place; what this
# spike settles is the network behaviour, which does not depend on the proxy.
# No credential is used or sent.
. "$(dirname "$0")/lib.sh"
spike_begin s5-internal-network-proxy
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools"
cp tools/connect-proxy.mjs tools/netprobe.mjs "$SCRATCH/tools/"
INT="i1s5-int-$$"; EXT="i1s5-ext-$$"; PROXY="i1s5-proxy-$$"
ALLOWED=api.anthropic.com; ALLOWED2=api.openai.com; DENIED=example.com
say "## networks"
D network create --label "$LABEL" --internal "$INT" >/dev/null
D network create --label "$LABEL" "$EXT" >/dev/null
say "internal: $(D network inspect "$INT" --format 'internal={{.Internal}} driver={{.Driver}} subnet={{(index .IPAM.Config 0).Subnet}} gateway={{(index .IPAM.Config 0).Gateway}}')"
say "external: $(D network inspect "$EXT" --format 'internal={{.Internal}} driver={{.Driver}} subnet={{(index .IPAM.Config 0).Subnet}} gateway={{(index .IPAM.Config 0).Gateway}}')"
GATEWAY=$(D network inspect "$INT" --format '{{(index .IPAM.Config 0).Gateway}}')
say "## the proxy: on $EXT, then also on $INT as 'proxy' (allow $ALLOWED, $ALLOWED2 on 443)"
D run -d --name "$PROXY" --label "$LABEL" --network "$EXT" --user 0:0 \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-e ALLOW="$ALLOWED,$ALLOWED2" -e PORTS=443 -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/connect-proxy.mjs >/dev/null
D network connect --alias proxy "$INT" "$PROXY"
for _ in $(seq 50); do D logs "$PROXY" 2>&1 | grep -q listening && break; sleep 0.1; done
say "proxy networks: $(D inspect "$PROXY" --format '{{range $k,$v := .NetworkSettings.Networks}}{{$k}} {{end}}' | sed "s/-$$//g")"
D logs "$PROXY" 2>&1 | sed 's/^/ proxy log: /'
say "## the agent: on $INT only"
D run --rm --label "$LABEL" --network "$INT" --user 1000:1000 --read-only --cap-drop ALL --security-opt no-new-privileges \
-e NODE_USE_ENV_PROXY=1 -e HTTPS_PROXY=http://proxy:3128 -e HTTP_PROXY=http://proxy:3128 \
-e ALLOWED_HOST="$ALLOWED" -e DENIED_HOST="$DENIED" -e GATEWAY="$GATEWAY" \
-v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/netprobe.mjs > "$SCRATCH/net.out" 2>&1
sed 's/^/ /' "$SCRATCH/net.out"
D inspect "$PROXY" >/dev/null && say "agent networks: $INT only (it was started with --network $INT)"
ans() { node -e '
const lines = require("fs").readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
const l = lines.find((x) => x.case === process.argv[2]);
process.stdout.write(l ? JSON.stringify(l) : "(case missing)");' "$SCRATCH/net.out" "$1"; }
has() { case "$(ans "$1")" in *"$2"*) echo 0 ;; *) echo 1 ;; esac; }
say "## assertions"
check "the agent resolves 'proxy' on the internal network" "$(has 'dns: the proxy by name' '"ok":true')"
check "direct TCP to 1.1.1.1:443 fails" "$(has 'direct tcp to 1.1.1.1:443' '"ok":false')"
check "direct TLS to $ALLOWED fails" "$(has 'direct tls to the allowed host' '"ok":false')"
check "CONNECT to $ALLOWED:443 through the proxy -> 200 and a TLS handshake" "$(has 'proxy: CONNECT allowed host:443 + TLS handshake' '"status":200')"
check "the handshake verified the server certificate" "$(has 'proxy: CONNECT allowed host:443 + TLS handshake' '"authorized":true')"
check "CONNECT to $DENIED -> 403" "$(has 'proxy: CONNECT denied host:443' '"status":403')"
check "CONNECT to the allowed host on port 22 -> 403" "$(has 'proxy: CONNECT allowed host:22' '"status":403')"
check "CONNECT to a raw IP -> 403" "$(has 'proxy: CONNECT raw IP 1.1.1.1:443' '"status":403')"
check "CONNECT to the network gateway -> 403" "$(has "proxy: CONNECT the proxy's own network gateway" '"status":403')"
check "node fetch honours HTTPS_PROXY with NODE_USE_ENV_PROXY=1 (any HTTP status)" "$(has 'node fetch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY' '"status":')"
say "direct DNS for $ALLOWED from the internal network: $(ans 'dns: the allowed host')"
say "## the proxy's log"
D logs "$PROXY" 2>&1 | sed 's/^/ /'
PL=$(D logs "$PROXY" 2>&1)
printf '%s\n' "$PL" | grep -q "CONNECT $ALLOWED 443"; check "log shows CONNECT $ALLOWED 443" $?
printf '%s\n' "$PL" | grep -q "REFUSE $DENIED 443"; check "log shows REFUSE $DENIED 443" $?
printf '%s\n' "$PL" | grep -q "REFUSE $ALLOWED 22"; check "log shows REFUSE $ALLOWED 22" $?
say "## does -p 127.0.0.1:P:8080 publish a port for a container only on the internal network?"
PORT=$(node -e 'const s=require("net").createServer().listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')
HELLO='require("net").createServer((c)=>c.end("hello\n")).listen(8080)'
hostconnect() { node -e '
const s=require("net").connect({host:"127.0.0.1",port:+process.argv[1]});
let d="";s.setTimeout(3000,()=>{console.log("timeout");process.exit(0)});
s.on("data",(b)=>d+=b);s.on("end",()=>{console.log("answer:"+JSON.stringify(d));process.exit(0)});
s.on("error",(e)=>{console.log("error:"+e.code);process.exit(0)})' "$1"; }
PUB=$(D run -d --name "i1s5-pub-int-$$" --label "$LABEL" --network "$INT" -p "127.0.0.1:$PORT:8080" "$NODE_IMG" node -e "$HELLO" 2>&1); PUBRC=$?
say "run on $INT with -p 127.0.0.1:$PORT:8080 -> exit $PUBRC: $(printf '%s' "$PUB" | tail -c 200 | sed "s/$$//g")"
if [ "$PUBRC" -eq 0 ]; then
sleep 1
say "docker ps PORTS column: '$(D ps --filter "name=i1s5-pub-int-$$" --format '{{.Ports}}')'"
INTRES=$(hostconnect "$PORT"); say "host connect to 127.0.0.1:$PORT -> $INTRES"
else
INTRES="docker refused"
fi
D rm -f "i1s5-pub-int-$$" >/dev/null 2>&1
PORT2=$(node -e 'const s=require("net").createServer().listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})')
D run -d --name "i1s5-pub-ext-$$" --label "$LABEL" --network "$EXT" -p "127.0.0.1:$PORT2:8080" "$NODE_IMG" node -e "$HELLO" >/dev/null
sleep 1
EXTRES=$(hostconnect "$PORT2"); say "control, same on ordinary bridge $EXT: host connect to 127.0.0.1:$PORT2 -> $EXTRES"
case $EXTRES in answer:*hello*) check "control: -p works on an ordinary bridge (the test method is sound)" 0 ;; *) check "control: -p works on an ordinary bridge (the test method is sound)" 1 ;; esac
case $INTRES in answer:*hello*) PUBLISHED=yes ;; *) PUBLISHED=no ;; esac
say "published port reachable for a container only on the internal network: $PUBLISHED"
# Observed 2026-10-12: docker accepts the flag (exit 0), the PORTS column shows 8080/tcp with no
# host binding, and the host connect is refused. The "no" is the answer; it is asserted here.
[ "$PUBRC" -eq 0 ] && [ "$PUBLISHED" = no ]
check "-p on a container only on an internal network is accepted by docker but not published (host connect: $INTRES)" $?
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# S6, the provider CLIs through the egress proxy. Claude Code 2.1.296 and pi
# 0.85.1 (@earendil-works/pi-coding-agent) are installed inside a test image by
# exact version (tools/Dockerfile.clis); the host has neither. The agent
# container sits on an `internal: true` network with the allowlisting proxy of
# S5 as its only way out. Pass: the proxy log shows CONNECT to the provider host
# and the CLI fails on authentication, not on the network. Also recorded: which
# proxy variables each CLI ignores, whether ANTHROPIC_BASE_URL is needed (the
# seat code passes it through, packages/seat/src/session.mjs:63), and every host
# each CLI tries to reach.
#
# No credential is used. A made-up API-key-shaped string ($FAKE, see net_up in
# lib.sh) is needed because with no key neither CLI makes a model request (Claude
# Code still makes startup connections); the provider answers "invalid key", and that answer is what proves the
# route. Decision 108's variable, CLAUDE_CODE_OAUTH_TOKEN, is tried with a made-up
# value shaped like an OAuth token.
. "$(dirname "$0")/lib.sh"
spike_begin s6-provider-clis-proxy
need_node_image
cd "$HERE" || exit 2
build_cli_image; check "the test image builds (npm install -g inside the image, exact versions)" $?
net_up api.anthropic.com,api.openai.com
say "internal network $(D network inspect "$INT" --format 'internal={{.Internal}}'); proxy allows api.anthropic.com, api.openai.com on 443"
AGENT=(--rm --label "$LABEL" --network "$INT" --user 1000:1000 --read-only --cap-drop ALL
--security-opt no-new-privileges --tmpfs /tmp --tmpfs /home/node:uid=1000,gid=1000 -e HOME=/home/node)
PROXYV=(-e HTTPS_PROXY=http://proxy:3128)
# cli LABEL DOCKER-ENV-ARGS... -- COMMAND...: one run; sets OUT, RC, NEW (proxy lines
# added during the run), HOSTS (distinct hosts it tried) and CONNECTED (hosts it reached).
cli() {
local label=$1; shift
local envs=(); while [ "$1" != -- ]; do envs+=("$1"); shift; done; shift
local n; n=$(plines)
OUT=$(D run "${AGENT[@]}" "${envs[@]}" "$CLI_IMG" timeout 90 "$@" 2>&1); RC=$?
NEW=$(proxy_new "$n")
HOSTS=$(printf '%s\n' "$NEW" | awk '/CONNECT|REFUSE/ {print $3}' | sort -u | tr '\n' ' ')
CONNECTED=$(printf '%s\n' "$NEW" | awk '$2=="CONNECT" {print $3}' | sort -u | tr '\n' ' ')
say "--- $label"
say " exit $RC; output: $(printf '%s' "$OUT" | tr '\n' ' ' | cut -c1-220 | sed "s/$FAKE/<made-up-key>/g")"
say " proxy decisions: $(printf '%s\n' "$NEW" | awk 'NF {print $2" "$3}' | sort | uniq -c | sed 's/^ *//' | tr '\n' ';')"
}
reached() { case " $CONNECTED" in *" $1 "*) return 0 ;; esac; return 1; }
authfail() { printf '%s' "$OUT" | grep -qiE 'invalid (x-)?api[ -]?key|authentication_error|incorrect api key|oauth access token is invalid|\b401\b'; }
netfail() { printf '%s' "$OUT" | grep -qiE 'ENOTFOUND|ECONNREFUSED|EAI_AGAIN|Unable to connect|ConnectionRefused|fetch failed|getaddrinfo|ETIMEDOUT|Connection error|Cannot connect'; }
say "## versions"
V1=$(D run "${AGENT[@]}" "$CLI_IMG" claude --version 2>&1); V2=$(D run "${AGENT[@]}" "$CLI_IMG" pi --version 2>&1)
say "claude: $V1"; say "pi: $V2"
check "claude is 2.1.296" "$(case $V1 in 2.1.296*) echo 0 ;; *) echo 1 ;; esac)"
check "pi is 0.85.1" "$([ "$V2" = 0.85.1 ]; echo $?)"
say "## controls: no proxy variable, internal network only"
cli "claude, no proxy variable" -e ANTHROPIC_API_KEY="$FAKE" -- claude -p "say hi"
check "claude without a proxy variable cannot reach the provider (no CONNECT, no auth answer)" "$([ -z "$CONNECTED" ] && ! authfail; echo $?)"
cli "pi, no proxy variable" -e ANTHROPIC_API_KEY="$FAKE" -- pi -p "say hi" --provider anthropic --no-session
check "pi without a proxy variable cannot reach the provider (no CONNECT, no auth answer)" "$([ -z "$CONNECTED" ] && ! authfail; echo $?)"
say "## through the proxy: HTTPS_PROXY only, no ANTHROPIC_BASE_URL"
cli "claude, HTTPS_PROXY" "${PROXYV[@]}" -e ANTHROPIC_API_KEY="$FAKE" -- claude -p "say hi"
check "claude: proxy log shows CONNECT api.anthropic.com" "$(reached api.anthropic.com; echo $?)"
check "claude: it fails on authentication, not on the network" "$(authfail && ! netfail; echo $?)"
CLAUDE_HOSTS=$HOSTS
cli "pi --provider anthropic, HTTPS_PROXY" "${PROXYV[@]}" -e ANTHROPIC_API_KEY="$FAKE" -- pi -p "say hi" --provider anthropic --no-session
check "pi (anthropic): proxy log shows CONNECT api.anthropic.com" "$(reached api.anthropic.com; echo $?)"
check "pi (anthropic): it fails on authentication, not on the network" "$(authfail && ! netfail; echo $?)"
PI_HOSTS=$HOSTS
cli "pi --provider openai, HTTPS_PROXY" "${PROXYV[@]}" -e OPENAI_API_KEY="$FAKE" -- pi -p "say hi" --provider openai --no-session
check "pi (openai): proxy log shows CONNECT api.openai.com" "$(reached api.openai.com; echo $?)"
check "pi (openai): it fails on authentication, not on the network" "$(authfail && ! netfail; echo $?)"
say "hosts tried (CONNECT or REFUSE): claude: $CLAUDE_HOSTS| pi anthropic: $PI_HOSTS| pi openai: $HOSTS"
say "## decision 108's variable: CLAUDE_CODE_OAUTH_TOKEN (a made-up oat01-shaped value)"
OAT=$(node -e 'process.stdout.write("sk-ant-oat01-FAKE" + require("crypto").randomBytes(30).toString("hex"))')
cli "claude, HTTPS_PROXY, CLAUDE_CODE_OAUTH_TOKEN" "${PROXYV[@]}" -e CLAUDE_CODE_OAUTH_TOKEN="$OAT" -- claude -p "say hi"
check "claude with CLAUDE_CODE_OAUTH_TOKEN: CONNECT api.anthropic.com" "$(reached api.anthropic.com; echo $?)"
check "claude with CLAUDE_CODE_OAUTH_TOKEN: the provider answers 'OAuth access token is invalid' (an authentication failure, through the proxy)" "$(printf '%s' "$OUT" | grep -qi 'oauth access token is invalid'; echo $?)"
cli "claude, HTTPS_PROXY, a token not shaped like an OAuth token in CLAUDE_CODE_OAUTH_TOKEN" "${PROXYV[@]}" -e CLAUDE_CODE_OAUTH_TOKEN="$FAKE" -- claude -p "say hi"
check "an sk-ant-api03-shaped value there is not taken as a login: 'Not logged in', no 'OAuth access token is invalid' answer" "$(printf '%s' "$OUT" | grep -q 'Not logged in'; echo $?)"
say "## no credential at all (the shape of an unconfigured session)"
cli "claude, HTTPS_PROXY, no credential" "${PROXYV[@]}" -- claude -p "say hi"
check "claude with no credential ends with 'Not logged in' and no model request" "$(printf '%s' "$OUT" | grep -q 'Not logged in'; echo $?)"
say " claude still contacted: ${CONNECTED:-(nothing)} (startup traffic, before any model request)"
cli "pi, HTTPS_PROXY, no credential" "${PROXYV[@]}" -- pi -p "say hi" --provider anthropic --no-session
check "pi with no credential says 'No API key found' and contacts nothing" "$(printf '%s' "$OUT" | grep -q 'No API key found' && [ -z "$CONNECTED" ]; echo $?)"
say "## which proxy variables does each CLI honour? (success = the proxy logged a CONNECT to the provider)"
# The variables a CLI is expected to ignore are named in IGNORED; every other variable
# must be honoured, and an ignored one must fail on the network, not on authentication.
matrix() { # cli-name, provider-key-env, ignored-variables, command...
local name=$1 keyenv=$2 ignored=" $3 "; shift 3
for v in HTTPS_PROXY https_proxy HTTP_PROXY http_proxy ALL_PROXY; do
cli "$name with only $v" -e "$v=http://proxy:3128" -e "$keyenv=$FAKE" -- "$@" >/dev/null
if reached api.anthropic.com; then ANS=honoured; else ANS=ignored; fi
say " $name $v: $ANS (exit $RC; $(authfail && echo 'auth failure' || (netfail && echo 'network failure') || echo 'other'); proxy: $(printf '%s\n' "$NEW" | awk 'NF {print $2" "$3}' | sort | uniq -c | sed 's/^ *//' | tr '\n' ';'))"
case $ignored in
*" $v "*) check "$name ignores $v: no CONNECT, fails on the network" "$([ "$ANS" = ignored ] && netfail; echo $?)" ;;
*) check "$name honours $v: CONNECT api.anthropic.com, fails on authentication" "$([ "$ANS" = honoured ] && authfail; echo $?)" ;;
esac
done
cli "$name with HTTPS_PROXY and NODE_USE_ENV_PROXY=1" "${PROXYV[@]}" -e NODE_USE_ENV_PROXY=1 -e "$keyenv=$FAKE" -- "$@" >/dev/null
check "$name with NODE_USE_ENV_PROXY=1 added still works" "$(reached api.anthropic.com && authfail; echo $?)"
}
matrix claude ANTHROPIC_API_KEY "" claude -p "say hi"
matrix pi ANTHROPIC_API_KEY "ALL_PROXY" pi -p "say hi" --provider anthropic --no-session
say "## Claude Code's other traffic"
cli "claude, HTTPS_PROXY, default" "${PROXYV[@]}" -e ANTHROPIC_API_KEY="$FAKE" -- claude -p "say hi"
check "default Claude Code tries a host other than the API (telemetry): $HOSTS" "$(case $HOSTS in *datadoghq*) echo 0 ;; *) echo 1 ;; esac)"
cli "claude, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1" "${PROXYV[@]}" -e CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -e ANTHROPIC_API_KEY="$FAKE" -- claude -p "say hi"
check "with CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 the only host is api.anthropic.com: $HOSTS" "$([ "$(printf '%s' "$HOSTS" | xargs)" = api.anthropic.com ]; echo $?)"
cli "claude, DISABLE_TELEMETRY=1" "${PROXYV[@]}" -e DISABLE_TELEMETRY=1 -e ANTHROPIC_API_KEY="$FAKE" -- claude -p "say hi"
say "hosts with DISABLE_TELEMETRY=1: $HOSTS"
say "## the proxy's log in full (one line per decision)"
D logs "$PROXY" 2>&1 | awk '{print " " $2, $3, $4}' | sort | uniq -c | sed 's/^/ /'
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# S7, a read-only root filesystem. Do Claude Code 2.1.296 and pi 0.85.1 start and
# make a request when the container's root is read-only, with tmpfs on /tmp and on
# HOME? Which paths do they write under HOME (names only, never contents)? What
# breaks when HOME is not a tmpfs? Each CLI gets its own container so its writes
# are its own. The container sits on an internal network with the S5 proxy, as in
# S6, so the request path is real; no credential, a made-up key (see net_up).
. "$(dirname "$0")/lib.sh"
spike_begin s7-read-only-root
need_node_image
cd "$HERE" || exit 2
build_cli_image; check "the test image builds" $?
net_up api.anthropic.com,api.openai.com
BASE=(--label "$LABEL" --network "$INT" --user 1000:1000 --read-only --cap-drop ALL --security-opt no-new-privileges
-e HOME=/home/node -e HTTPS_PROXY=http://proxy:3128 -e ANTHROPIC_API_KEY="$FAKE")
HOMETMP=(--tmpfs /home/node:uid=1000,gid=1000)
authfail() { printf '%s' "$1" | grep -qiE 'invalid (x-)?api[ -]?key|authentication_error|\b401\b'; }
# ro_case NAME CLI TMPFS-ARGS...: run CLI (claude|pi) once in a read-only container
# with the given tmpfs mounts. Sets VER_RC, REQ_RC, REQ_OUT, DIFF, HOME_LIST, TMP_LIST.
ro_case() {
local name=$1 cli=$2; shift 2
local c="i1s7-$name-$$"
local req; if [ "$cli" = claude ]; then req=(claude -p "say hi"); else req=(pi -p "say hi" --provider anthropic --no-session); fi
D run -d --name "$c" "${BASE[@]}" "$@" "$CLI_IMG" sleep 300 >/dev/null
D exec "$c" "$cli" --version >"$SCRATCH/ver.out" 2>&1; VER_RC=$?
REQ_OUT=$(D exec "$c" timeout 90 "${req[@]}" 2>&1); REQ_RC=$?
DIFF=$(D diff "$c" 2>&1)
HOME_LIST=$(D exec "$c" sh -c 'find /home/node -mindepth 1 -printf "%y %P\n" 2>&1 | sort' 2>&1)
TMP_LIST=$(D exec "$c" sh -c 'find /tmp -mindepth 1 -printf "%y %P\n" 2>&1 | sort' 2>&1)
say "--- $name ($cli, $*)"
say " --version: exit $VER_RC ($(head -c 60 "$SCRATCH/ver.out" | tr '\n' ' '))"
say " request: exit $REQ_RC; $(printf '%s' "$REQ_OUT" | tr '\n' ' ' | cut -c1-150 | sed "s/$FAKE/<made-up-key>/g")"
say " docker diff of the writable layer: $([ -z "$DIFF" ] && echo '(empty)' || printf '%s' "$DIFF" | tr '\n' ' ')"
say " written under HOME (type path), $(printf '%s\n' "$HOME_LIST" | grep -c .) entries:"
printf '%s\n' "$HOME_LIST" | sed 's/^/ /'
say " written under /tmp: $(printf '%s\n' "$TMP_LIST" | grep -c .) entries"
printf '%s\n' "$TMP_LIST" | head -20 | sed 's/^/ /'
D rm -f "$c" >/dev/null
}
say "## A. read-only root, tmpfs /tmp and tmpfs HOME (the target configuration)"
for cli in claude pi; do
ro_case "a-$cli" "$cli" "${HOMETMP[@]}" --tmpfs /tmp
check "$cli starts (--version exit 0)" "$([ "$VER_RC" -eq 0 ]; echo $?)"
check "$cli makes its request and fails on authentication (the made-up key)" "$(authfail "$REQ_OUT"; echo $?)"
check "$cli leaves the container's writable layer empty (docker diff)" "$([ -z "$DIFF" ]; echo $?)"
TOP=$(printf '%s\n' "$HOME_LIST" | awk 'NF {print $2}' | cut -d/ -f1 | sort -u | tr '\n' ' ')
TOPTMP=$(printf '%s\n' "$TMP_LIST" | awk 'NF {print $2}' | cut -d/ -f1 | sort -u | tr '\n' ' ')
say " top-level names written under HOME: $TOP; under /tmp: ${TOPTMP:-(none)}"
if [ "$cli" = claude ]; then
check "claude writes only .claude and .claude.json under HOME ($TOP)" "$([ "$TOP" = '.claude .claude.json ' ]; echo $?)"
check "claude writes only cc-socks and claude-<uid> under /tmp ($TOPTMP)" "$([ "$TOPTMP" = 'cc-socks claude-1000 ' ]; echo $?)"
else
check "pi writes only .pi under HOME ($TOP)" "$([ "$TOP" = '.pi ' ]; echo $?)"
check "pi writes nothing under /tmp" "$([ -z "$TOPTMP" ]; echo $?)"
fi
done
say "## B. the same with noexec,nosuid on both tmpfs mounts"
for cli in claude pi; do
ro_case "b-$cli" "$cli" --tmpfs /home/node:uid=1000,gid=1000,noexec,nosuid --tmpfs /tmp:noexec,nosuid
check "$cli starts with noexec,nosuid tmpfs" "$([ "$VER_RC" -eq 0 ]; echo $?)"
check "$cli with noexec,nosuid tmpfs: request fails on authentication" "$(authfail "$REQ_OUT"; echo $?)"
done
say "## C. read-only root, tmpfs /tmp only, HOME not a tmpfs (what breaks)"
for cli in claude pi; do
ro_case "c-$cli" "$cli" --tmpfs /tmp
say " HOME not writable: version exit $VER_RC, request exit $REQ_RC"
check "$cli --version still works with a read-only HOME" "$([ "$VER_RC" -eq 0 ]; echo $?)"
if [ "$cli" = claude ]; then
# Observed 2026-10-12: Claude Code's request still reaches the provider; this proves
# the request path only. A real session writes ~/.claude/projects/*.jsonl (case A).
check "claude's request still reaches the provider with a read-only HOME (request path only)" "$(authfail "$REQ_OUT"; echo $?)"
else
check "pi fails without a writable HOME: 'No API key found' even though the key is in the environment" "$(printf '%s' "$REQ_OUT" | grep -q 'No API key found'; echo $?)"
fi
done
say "## D. read-only root, no tmpfs at all"
for cli in claude pi; do
ro_case "d-$cli" "$cli"
say " no tmpfs: version exit $VER_RC, request exit $REQ_RC"
check "$cli --version works on a bare read-only root" "$([ "$VER_RC" -eq 0 ]; echo $?)"
if [ "$cli" = claude ]; then
check "claude fails to make its /tmp/claude-<uid> directory: EROFS" "$(printf '%s' "$REQ_OUT" | grep -q "EROFS.*claude-1000"; echo $?)"
else
check "pi fails on a bare read-only root" "$(printf '%s' "$REQ_OUT" | grep -q 'No API key found'; echo $?)"
fi
check "$cli: with no tmpfs /tmp holds only what the image holds (nothing: the Dockerfile empties it)" "$([ "$(printf '%s\n' "$TMP_LIST" | grep -c .)" -eq 0 ]; echo $?)"
done
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
# S8, a capability delivered on stdin, and how a container stops. Part 1: a node
# entrypoint reads one line from `docker run -i`, prints only its length and
# hash; the script checks the made-up capability string reached it and appears in
# neither `docker inspect`, `docker top`, `docker logs`, /proc/1/environ nor
# /proc/1/cmdline. Part 2: which signal pid 1 receives from `docker stop` and
# `docker kill`, with and without `--init`, and the exit status. The capability
# is a random made-up string, not a credential. The launcher's stop path
# (packages/cli/src/launcher.mjs:304-331, recover()) is rewritten from part 2.
. "$(dirname "$0")/lib.sh"
spike_begin s8-stdin-capability
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools"
cp tools/stdin-cap.mjs "$SCRATCH/tools/"
CAPSTR=$(node -e 'process.stdout.write("fake-cap-" + require("crypto").randomBytes(24).toString("hex"))')
WANTLEN=$(printf '%s' "$CAPSTR" | wc -c)
WANTSHA=$(printf '%s' "$CAPSTR" | sha256sum | cut -d' ' -f1)
say "made-up capability: $WANTLEN bytes, sha256 $WANTSHA (the string itself is never printed)"
wait_log() { # container pattern
for _ in $(seq 100); do D logs "$1" 2>&1 | grep -q "$2" && return 0; sleep 0.1; done
return 1
}
now_ms() { echo $(( $(date +%s%N) / 1000000 )); }
say "## 1. one line on stdin"
C="i1s8-stdin-$$"
printf '%s\n' "$CAPSTR" | D run -i --name "$C" --label "$LABEL" --user 1000:1000 -e READ=1 -e HANDLE=1 \
-v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/stdin-cap.mjs > "$SCRATCH/stdin.out" 2>&1 &
RUNPID=$!
wait_log "$C" ready; check "entrypoint started and printed ready" $?
sed 's/^/ entrypoint says: /' "$SCRATCH/stdin.out"
grep -q "got len=$WANTLEN sha256=$WANTSHA" "$SCRATCH/stdin.out"; check "the entrypoint received the exact string (length and sha256 match)" $?
absent() { # label, text
if printf '%s' "$2" | grep -qF -- "$CAPSTR"; then check "capability absent from $1" 1; else check "capability absent from $1" 0; fi
}
INSPECT=$(D inspect "$C")
say "Config.Env: $(D inspect "$C" --format '{{json .Config.Env}}' | sed 's/"PATH=[^"]*"/"PATH=..."/')"
say "Config.Cmd: $(D inspect "$C" --format '{{json .Config.Cmd}}') Path: $(D inspect "$C" --format '{{.Path}}') Args: $(D inspect "$C" --format '{{json .Args}}')"
absent "docker inspect (whole document, incl. Config.Env, Args, Cmd)" "$INSPECT"
absent "docker top" "$(D top "$C" -eo pid,args 2>&1)"
absent "docker logs" "$(D logs "$C" 2>&1)"
absent "/proc/1/environ" "$(D exec "$C" sh -c 'tr "\0" "\n" < /proc/1/environ' 2>&1)"
absent "/proc/1/cmdline" "$(D exec "$C" sh -c 'tr "\0" "\n" < /proc/1/cmdline' 2>&1)"
absent "the host process list (ps -eo args)" "$(ps -eo args 2>/dev/null)"
say "stdin of pid 1 after the line: $(D exec "$C" sh -c 'ls -l /proc/1/fd/0' 2>&1 | sed 's/.* -> //')"
D stop -t 3 "$C" >/dev/null 2>&1; wait "$RUNPID" 2>/dev/null
D rm -f "$C" >/dev/null
say "## 2. signals: what pid 1 receives, and the exit status"
sigrow() { # name, init-flag (--init or ""), handle (0/1), action...
local name=$1 initf=$2 handle=$3; shift 3
local c="i1s8-$name-$$"
D run -d --name "$c" --label "$LABEL" --user 1000:1000 $initf -e HANDLE="$handle" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" node /t/stdin-cap.mjs >/dev/null
wait_log "$c" ready
local t0 t1; t0=$(now_ms)
D "$@" "$c" >/dev/null 2>&1
# a signal sent with `kill -s TERM` may be ignored: give the container a moment, then look
sleep 1
t1=$(now_ms)
ROW_RUNNING=$(D inspect "$c" --format '{{.State.Running}}')
ROW_CODE=$(D inspect "$c" --format '{{.State.ExitCode}}')
ROW_LOG=$(D logs "$c" 2>&1 | tr '\n' ';' | sed 's/;$//')
# elapsed until the container was seen stopped: finished-at minus the action time
ROW_MS=$(( $(date -d "$(D inspect "$c" --format '{{.State.FinishedAt}}')" +%s%3N 2>/dev/null || echo 0) - t0 ))
[ "$ROW_RUNNING" = true ] && ROW_MS=n/a
say " $name: [$initf] handle=$handle action='$*' -> running=$ROW_RUNNING exit=$ROW_CODE stopped-after-ms=$ROW_MS log=$ROW_LOG"
D rm -f "$c" >/dev/null
}
sigrow stop-plain "" 0 stop -t 3
check "docker stop, no init, no handler: pid 1 ignores SIGTERM, killed by SIGKILL after the grace period (exit 137, >= 3 s)" "$([ "$ROW_CODE" = 137 ] && [ "$ROW_MS" -ge 2900 ]; echo $?)"
sigrow stop-handler "" 1 stop -t 3
check "docker stop, no init, handler: pid 1 receives SIGTERM as pid=1, exits 0 promptly" "$([ "$ROW_CODE" = 0 ] && case $ROW_LOG in *'signal SIGTERM pid=1') true ;; *) false ;; esac && [ "$ROW_MS" -lt 2500 ]; echo $?)"
sigrow stop-init "--init" 0 stop -t 3
check "docker stop, --init, no handler: tini forwards SIGTERM, the default action ends node (exit 143, prompt)" "$([ "$ROW_CODE" = 143 ] && [ "$ROW_MS" -lt 2500 ]; echo $?)"
sigrow stop-init-hand "--init" 1 stop -t 3
check "docker stop, --init, handler: node receives SIGTERM as a child, not pid 1" "$([ "$ROW_CODE" = 0 ] && case $ROW_LOG in *'signal SIGTERM pid=1') false ;; *'signal SIGTERM pid='*) true ;; *) false ;; esac; echo $?)"
sigrow kill-plain "" 0 kill
check "docker kill (SIGKILL), no init: exit 137" "$([ "$ROW_CODE" = 137 ]; echo $?)"
sigrow kill-handler "" 1 kill
check "docker kill (SIGKILL), handler: handler never runs, exit 137" "$([ "$ROW_CODE" = 137 ] && case $ROW_LOG in *signal*) false ;; *) true ;; esac; echo $?)"
sigrow kill-init "--init" 0 kill
check "docker kill (SIGKILL), --init: exit 137" "$([ "$ROW_CODE" = 137 ]; echo $?)"
sigrow killterm-plain "" 0 kill -s TERM
check "docker kill -s TERM, no init, no handler: ignored, container still running" "$([ "$ROW_RUNNING" = true ]; echo $?)"
sigrow killterm-init "--init" 0 kill -s TERM
check "docker kill -s TERM, --init, no handler: ends the container (exit 143)" "$([ "$ROW_CODE" = 143 ]; echo $?)"
sigrow killterm-hand "" 1 kill -s TERM
check "docker kill -s TERM, no init, handler: pid 1 receives it, exit 0" "$([ "$ROW_CODE" = 0 ] && case $ROW_LOG in *'signal SIGTERM pid=1') true ;; *) false ;; esac; echo $?)"
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# S9, hardening under rootless Docker and cgroup v2. Runs a container with
# cap_drop ALL, no-new-privileges, read_only (+ tmpfs /tmp), pids_limit,
# mem_limit and cpus. Each flag is read back from `docker inspect` HostConfig
# and from inside the container (/proc/self/status, /sys/fs/cgroup), and the
# limits are exercised: a write to the root filesystem, a fork burst, an
# allocation past the memory limit. Any flag the daemon rejects, warns about,
# or accepts without effect is recorded. A control container with no flags
# shows what the defaults are.
. "$(dirname "$0")/lib.sh"
spike_begin s9-hardening
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools"
cp tools/hardening-probe.mjs "$SCRATCH/tools/"
HARD=(--user 1000:1000 --cap-drop ALL --security-opt no-new-privileges --read-only
--tmpfs /tmp:rw,noexec,nosuid,size=16m --pids-limit 32 --memory 128m --memory-swap 128m --cpus 0.5)
MNT=(-v "$SCRATCH/tools:/t:ro")
jget() { node -e 'const o=JSON.parse(process.argv[1]);let v=o;for(const k of process.argv[2].split("."))v=v?.[k];console.log(typeof v==="object"?JSON.stringify(v):v)' "$PROBE" "$1"; }
is() { [ "$(jget "$1")" = "$2" ]; echo $?; }
say "## the daemon"
say "$(D info --format 'cgroup version {{.CgroupVersion}}, driver {{.CgroupDriver}}, warnings {{json .Warnings}}')"
say "controllers delegated to the user manager: $(cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllers 2>&1)"
say "## control: no hardening flags (the defaults), as container root and as uid 1000"
PROBE=$(D run --rm --label "$LABEL" --user 0:0 "${MNT[@]}" "$NODE_IMG" node /t/hardening-probe.mjs 2>&1); say " user 0:0 $PROBE"
CTRL_CAP=$(jget CapEff)
check "control (0:0): default CapEff is non-zero ($CTRL_CAP)" "$([ "$CTRL_CAP" != 0000000000000000 ] && [ "$CTRL_CAP" != undefined ]; echo $?)"
check "control (0:0): NoNewPrivs 0 without the flag" "$(is NoNewPrivs 0)"
check "control (0:0): default seccomp filter active (Seccomp: 2)" "$(is Seccomp 2)"
check "control (0:0): pids.max is the cgroup default, not 32 ($(jget pidsMax))" "$([ "$(jget pidsMax)" != 32 ]; echo $?)"
check "control (0:0): no memory limit (max)" "$(is memoryMax max)"
check "control (0:0): root filesystem is writable" "$(is writeRoot ok)"
PROBE=$(D run --rm --label "$LABEL" --user 1000:1000 "${MNT[@]}" "$NODE_IMG" node /t/hardening-probe.mjs 2>&1); say " user 1000 $PROBE"
check "control (1000): a non-root uid already has CapEff 0 and cannot write / (EACCES, a permission, not a mount)" "$([ "$(jget CapEff)" = 0000000000000000 ] && [ "$(jget writeRoot)" = EACCES ]; echo $?)"
say "## each flag alone: does the daemon accept it quietly?"
flagrun() { # label, flags...
local label=$1; shift
local o; o=$(D run --rm --label "$LABEL" "${MNT[@]}" "$@" "$NODE_IMG" node /t/hardening-probe.mjs 2>&1); local rc=$?
say " $label -> exit $rc; $(printf '%s' "$o" | grep -ci 'warning') warning line(s)"
printf '%s' "$o" | grep -i 'warning' | sed 's/^/ /'
PROBE=$(printf '%s' "$o" | grep '^{' | head -1)
}
flagrun "--cap-drop ALL (as 0:0)" --user 0:0 --cap-drop ALL; check "cap-drop ALL: CapEff 0 even for container root" "$(is CapEff 0000000000000000)"
flagrun "--security-opt no-new-privileges" --user 1000:1000 --security-opt no-new-privileges; check "no-new-privileges: NoNewPrivs 1" "$(is NoNewPrivs 1)"
flagrun "--read-only" --user 1000:1000 --read-only; check "read-only: writes to / fail with EROFS, /tmp too without tmpfs" "$([ "$(jget writeRoot)" = EROFS ] && [ "$(jget writeTmp)" = EROFS ]; echo $?)"
flagrun "--read-only --tmpfs /tmp" --user 1000:1000 --read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m; check "read-only + tmpfs: /tmp writable, / not" "$([ "$(jget writeRoot)" = EROFS ] && [ "$(jget writeTmp)" = ok ]; echo $?)"
flagrun "--pids-limit 32" --user 1000:1000 --pids-limit 32; check "pids-limit: pids.max 32" "$(is pidsMax 32)"
flagrun "--memory 128m" --user 1000:1000 --memory 128m; check "memory: memory.max 134217728" "$(is memoryMax 134217728)"
flagrun "--memory 128m --memory-swap 128m" --user 1000:1000 --memory 128m --memory-swap 128m; check "memory-swap equal to memory: memory.swap.max 0" "$(is swapMax 0)"
flagrun "--cpus 0.5" --user 1000:1000 --cpus 0.5; check "cpus: cpu.max '50000 100000'" "$(is cpuMax '50000 100000')"
say "## all together, read back from docker inspect and from inside"
C="i1s9-all-$$"
D run -d --name "$C" --label "$LABEL" "${HARD[@]}" "${MNT[@]}" "$NODE_IMG" sleep 600 >/dev/null
H() { D inspect "$C" --format "{{json .HostConfig.$1}}"; }
say " HostConfig.CapDrop $(H CapDrop)"
say " HostConfig.CapAdd $(H CapAdd)"
say " HostConfig.SecurityOpt $(H SecurityOpt)"
say " HostConfig.ReadonlyRootfs $(H ReadonlyRootfs)"
say " HostConfig.Tmpfs $(H Tmpfs)"
say " HostConfig.PidsLimit $(H PidsLimit)"
say " HostConfig.Memory $(H Memory)"
say " HostConfig.MemorySwap $(H MemorySwap)"
say " HostConfig.NanoCpus $(H NanoCpus)"
say " HostConfig.Privileged $(H Privileged)"
say " HostConfig.CgroupnsMode $(H CgroupnsMode)"
check "inspect: CapDrop is [ALL]" "$([ "$(H CapDrop)" = '["ALL"]' ]; echo $?)"
check "inspect: SecurityOpt carries no-new-privileges" "$(case "$(H SecurityOpt)" in *no-new-privileges*) echo 0 ;; *) echo 1 ;; esac)"
check "inspect: ReadonlyRootfs true" "$([ "$(H ReadonlyRootfs)" = true ]; echo $?)"
check "inspect: PidsLimit 32" "$([ "$(H PidsLimit)" = 32 ]; echo $?)"
check "inspect: Memory 134217728" "$([ "$(H Memory)" = 134217728 ]; echo $?)"
check "inspect: NanoCpus 500000000" "$([ "$(H NanoCpus)" = 500000000 ]; echo $?)"
check "inspect: not privileged" "$([ "$(H Privileged)" = false ]; echo $?)"
PROBE=$(D exec -e FORK=1 "$C" node /t/hardening-probe.mjs 2>&1); say " inside: $PROBE"
check "inside: CapEff 0, CapBnd 0" "$([ "$(jget CapEff)" = 0000000000000000 ] && [ "$(jget CapBnd)" = 0000000000000000 ]; echo $?)"
check "inside: NoNewPrivs 1" "$(is NoNewPrivs 1)"
check "inside: Seccomp 2 (default profile still on)" "$(is Seccomp 2)"
check "inside: pids.max 32" "$(is pidsMax 32)"
check "inside: memory.max 134217728, swap 0" "$([ "$(jget memoryMax)" = 134217728 ] && [ "$(jget swapMax)" = 0 ]; echo $?)"
check "inside: cpu.max 50000 100000" "$(is cpuMax '50000 100000')"
check "inside: / read-only, /tmp tmpfs writable" "$([ "$(jget writeRoot)" = EROFS ] && [ "$(jget writeTmp)" = ok ]; echo $?)"
STARTED=$(jget fork.started); REFUSED=$(jget fork.refused)
say " fork burst: 200 attempted, $STARTED started, $REFUSED refused"
check "the pids limit holds: some spawns refused, started <= 32" "$([ "$REFUSED" -gt 0 ] && [ "$STARTED" -le 32 ]; echo $?)"
say " /tmp mount inside: $(D exec "$C" sh -c "grep ' /tmp ' /proc/mounts")"
D rm -f "$C" >/dev/null
say "## memory limit enforced"
MC="i1s9-mem-$$"
MOUT=$(D run --name "$MC" --label "$LABEL" "${HARD[@]}" -e ALLOC=400 "${MNT[@]}" "$NODE_IMG" node /t/hardening-probe.mjs 2>&1); MRC=$?
say " exit $MRC; OOMKilled=$(D inspect "$MC" --format '{{.State.OOMKilled}}'); output tail: $(printf '%s' "$MOUT" | tail -n1 | cut -c1-120)"
check "allocating 400 MiB under a 128 MiB limit is OOM-killed (exit 137)" "$([ "$MRC" = 137 ] && [ "$(D inspect "$MC" --format '{{.State.OOMKilled}}')" = true ]; echo $?)"
D rm -f "$MC" >/dev/null
say "## things the flags do not stop (recorded, not asserted as hardening)"
say " container root with the same flags (user 0:0) maps to host uid $(id -u); CapEff:"
ROOTCAP=$(D run --rm --label "$LABEL" --user 0:0 --cap-drop ALL --security-opt no-new-privileges "${MNT[@]}" "$NODE_IMG" node -e 'console.log(require("fs").readFileSync("/proc/self/status","utf8").match(/CapEff:\s*(\S+)/)[1])')
say " $ROOTCAP"
check "container root with cap-drop ALL has CapEff 0" "$([ "$ROOTCAP" = 0000000000000000 ]; echo $?)"
@@ -0,0 +1,10 @@
# The S6/S7 test image: the pinned worker base plus the two provider CLIs at
# exact versions. Installed here, inside the image, never on the host. No
# credential is copied or passed at build time.
ARG NODE_IMG
FROM ${NODE_IMG}
RUN npm install -g --no-audit --no-fund --loglevel=error \
@anthropic-ai/[email protected] \
@earendil-works/[email protected] \
&& npm cache clean --force \
&& rm -rf /tmp/* /root/.npm
@@ -0,0 +1,43 @@
// A minimal allowlisting HTTP CONNECT proxy for the I1 spikes (S5, S6). No
// dependency beyond node, so the proxy container is the same pinned node image
// as everything else. It only tunnels CONNECT host:port for names in ALLOW on
// ports in PORTS, and logs one line per decision: the method, host and port,
// never a URL, header or body (a CONNECT tunnel carries TLS the proxy cannot read).
// ALLOW=api.anthropic.com,api.openai.com PORTS=443 node connect-proxy.mjs
import { createServer, connect } from "node:net";
const allow = new Set((process.env.ALLOW ?? "").split(",").filter(Boolean).map((h) => h.toLowerCase()));
const ports = new Set((process.env.PORTS ?? "443").split(","));
const log = (...a) => console.log(new Date().toISOString(), ...a);
const server = createServer((client) => {
client.on("error", () => {});
client.once("data", (head) => {
const first = head.toString("latin1").split("\r\n")[0];
const m = /^CONNECT ([A-Za-z0-9.-]+):(\d{1,5}) HTTP\/1\.[01]$/.exec(first);
if (!m) {
log("REFUSE malformed-request", JSON.stringify(first.slice(0, 40)));
client.end("HTTP/1.1 400 Bad Request\r\n\r\n");
return;
}
const host = m[1].toLowerCase(), port = m[2];
if (!allow.has(host) || !ports.has(port)) {
log("REFUSE", host, port);
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
return;
}
const up = connect(Number(port), host, () => {
log("CONNECT", host, port);
client.write("HTTP/1.1 200 Connection established\r\n\r\n");
up.pipe(client);
client.pipe(up);
});
up.on("error", (e) => {
log("UPSTREAM-ERROR", host, port, e.code);
client.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
});
client.on("close", () => up.destroy());
up.on("close", () => client.destroy());
});
});
server.listen(3128, () => log("listening 3128 allow=" + [...allow].join(",") + " ports=" + [...ports].join(",")));
@@ -0,0 +1,36 @@
// Runs inside the container (S9). Prints one JSON line: what the process sees of
// its own hardening. FORK=1 adds a process-limit test (spawn 200 sleeps, count
// how many started and how many were refused). ALLOC=<MiB> adds a memory test.
import { readFileSync, writeFileSync } from "node:fs";
import { spawn } from "node:child_process";
const rd = (p) => { try { return readFileSync(p, "utf8").trim(); } catch (e) { return "ERR:" + e.code; } };
const status = rd("/proc/self/status");
const field = (k) => (status.match(new RegExp("^" + k + ":\\s*(.*)$", "m")) ?? [])[1];
const wr = (p) => { try { writeFileSync(p, "x"); return "ok"; } catch (e) { return e.code; } };
const out = {
uid: process.getuid(),
CapEff: field("CapEff"), CapBnd: field("CapBnd"), NoNewPrivs: field("NoNewPrivs"), Seccomp: field("Seccomp"),
pidsMax: rd("/sys/fs/cgroup/pids.max"), memoryMax: rd("/sys/fs/cgroup/memory.max"),
swapMax: rd("/sys/fs/cgroup/memory.swap.max"), cpuMax: rd("/sys/fs/cgroup/cpu.max"),
writeRoot: wr("/rootfs-write-test"), writeTmp: wr("/tmp/x"),
};
if (process.env.FORK === "1") {
const kids = [];
let started = 0, refused = 0;
await Promise.all(
Array.from({ length: 200 }, () => new Promise((resolve) => {
const c = spawn("sleep", ["20"], { stdio: "ignore" });
c.once("spawn", () => { started++; kids.push(c); resolve(); });
c.once("error", () => { refused++; resolve(); });
})),
);
for (const c of kids) c.kill("SIGKILL");
out.fork = { attempted: 200, started, refused };
}
if (process.env.ALLOC) {
console.log(JSON.stringify(out));
const b = Buffer.alloc(Number(process.env.ALLOC) * 1024 * 1024, 1);
console.log("survived allocating " + b.length);
} else console.log(JSON.stringify(out));
@@ -0,0 +1,52 @@
// usage: node hostile.mjs <socket> <capFile>
// Runs inside the container. Sends raw envelopes to the broker socket and prints
// one JSON line per case: {case, answer}. The "made-up" values are random or
// fixed junk; the one real capability is the throwaway the temp broker issued.
import { connect } from "node:net";
import { readFileSync } from "node:fs";
import { randomBytes } from "node:crypto";
const [path, capFile] = process.argv.slice(2);
const real = readFileSync(capFile, "utf8");
function raw(data, { wait = 4000 } = {}) {
return new Promise((resolve) => {
const s = connect(path);
let out = "";
const done = (note) => {
s.destroy();
resolve(out.trim() || note);
};
const t = setTimeout(() => done("(no answer in " + wait + " ms)"), wait);
s.on("connect", () => data !== null && s.end(data));
s.on("data", (b) => (out += b));
s.on("end", () => (clearTimeout(t), done("(closed, empty)")));
s.on("error", (e) => (clearTimeout(t), done("(socket error " + e.code + ")")));
});
}
const line = (o) => JSON.stringify(o) + "\n";
const out = (name, answer) => console.log(JSON.stringify({ case: name, answer }));
out("valid capability, verb role.claim", await raw(line({ cap: real, verb: "role.claim", args: {} })));
out("valid capability, verb agents", await raw(line({ cap: real, verb: "agents", args: {} })));
out("made-up capability", await raw(line({ cap: randomBytes(24).toString("hex"), verb: "agents", args: {} })));
const proof = (extra) => ({ pid: 4242, startTime: "1", nonce: randomBytes(32).toString("hex"), business: "demo", ...extra });
out("human envelope, made-up proof", await raw(line({ human: proof(), verb: "agents", args: {} })));
const start = readFileSync("/proc/self/stat", "utf8").split(") ")[1].split(" ")[19];
out("human envelope, this container's own pid and start time", await raw(line({ human: proof({ pid: process.pid, startTime: start }), verb: "agents", args: {} })));
out("human envelope, pid 1", await raw(line({ human: proof({ pid: 1, startTime: "1" }), verb: "agents", args: {} })));
out("both cap and human", await raw(line({ cap: real, human: proof(), verb: "agents", args: {} })));
out("unknown key (role: human)", await raw(line({ cap: real, verb: "agents", role: "human" })));
out("valid capability, unknown verb sql", await raw(line({ cap: real, verb: "sql", args: { query: "select 1" } })));
out("not json", await raw("not-json\n"));
const pad = (n) => {
const base = line({ cap: "x".repeat(8), verb: "agents", args: { p: "" } });
return line({ cap: "x".repeat(8), verb: "agents", args: { p: "a".repeat(n - Buffer.byteLength(base)) } });
};
const atLimit = pad(65536), over = pad(65537);
out("request of exactly 65536 bytes (forged cap)", await raw(atLimit));
out("request of 65537 bytes (forged cap)", await raw(over));
out("70000 bytes, no newline", await raw("x".repeat(70000)));
out("1 MiB of junk, newline-terminated", await raw("y".repeat(1 << 20) + "\n"));
out("connect and send nothing", await raw(null, { wait: 7000 }));
out("partial line then close", await raw('{"cap":"x"'));
@@ -0,0 +1,62 @@
// usage: node netprobe.mjs (runs in the agent container on the internal network)
// Prints one JSON line per case: {case, ...result}. No credential is sent
// anywhere; the TLS handshake and the unauthenticated GET carry none.
import { connect } from "node:net";
import { connect as tlsConnect } from "node:tls";
import { lookup } from "node:dns/promises";
const PROXY = process.env.PROXY_HOST ?? "proxy";
const ALLOWED = process.env.ALLOWED_HOST ?? "api.anthropic.com";
const DENIED = process.env.DENIED_HOST ?? "example.com";
const out = (name, o) => console.log(JSON.stringify({ case: name, ...o }));
const dns = async (name) => {
try { return { ok: true, address: (await lookup(name)).address }; }
catch (e) { return { ok: false, err: e.code }; }
};
const direct = (host, port, tls = false) =>
new Promise((resolve) => {
const s = tls ? tlsConnect({ host, port, servername: host }) : connect({ host, port });
const t = setTimeout(() => { s.destroy(); resolve({ ok: false, err: "TIMEOUT" }); }, 5000);
s.once(tls ? "secureConnect" : "connect", () => { clearTimeout(t); s.destroy(); resolve({ ok: true }); });
s.once("error", (e) => { clearTimeout(t); resolve({ ok: false, err: e.code }); });
});
// CONNECT through the proxy; for a 200, also complete a TLS handshake through the tunnel.
const viaProxy = (host, port, handshake = false) =>
new Promise((resolve) => {
const s = connect({ host: PROXY, port: 3128 });
let buf = "";
const t = setTimeout(() => { s.destroy(); resolve({ status: null, err: "TIMEOUT" }); }, 8000);
s.once("error", (e) => { clearTimeout(t); resolve({ status: null, err: e.code }); });
s.once("connect", () => s.write(`CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`));
s.on("data", function onData(b) {
buf += b.toString("latin1");
if (!buf.includes("\r\n\r\n")) return;
s.off("data", onData);
const status = Number(buf.split(" ")[1]);
if (status !== 200 || !handshake) { clearTimeout(t); s.destroy(); return resolve({ status }); }
const tls = tlsConnect({ socket: s, servername: host });
tls.once("secureConnect", () => {
clearTimeout(t);
const r = { status, tls: tls.getProtocol(), authorized: tls.authorized };
tls.destroy(); resolve(r);
});
tls.once("error", (e) => { clearTimeout(t); resolve({ status, err: e.code }); });
});
});
out("dns: the proxy by name", await dns(PROXY));
out("dns: the allowed host", await dns(ALLOWED));
out("direct tcp to 1.1.1.1:443", await direct("1.1.1.1", 443));
out("direct tls to the allowed host", await direct(ALLOWED, 443, true));
out("proxy: CONNECT allowed host:443 + TLS handshake", await viaProxy(ALLOWED, 443, true));
out("proxy: CONNECT denied host:443", await viaProxy(DENIED, 443));
out("proxy: CONNECT allowed host:22", await viaProxy(ALLOWED, 22));
out("proxy: CONNECT raw IP 1.1.1.1:443", await viaProxy("1.1.1.1", 443));
out("proxy: CONNECT the proxy's own network gateway", await viaProxy(process.env.GATEWAY ?? "172.17.0.1", 443));
if (process.env.NODE_USE_ENV_PROXY) {
try {
const r = await fetch(`https://${ALLOWED}/v1/models`, { signal: AbortSignal.timeout(10000) });
out("node fetch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY", { status: r.status });
} catch (e) { out("node fetch with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY", { err: e.cause?.code ?? e.name }); }
}
@@ -0,0 +1,20 @@
// Spike helper: report who this process is and whether it can read a file and
// connect to a unix socket. node tools/probe.mjs [--file F] [--sock S]
// Prints one JSON line. Reads the file's first 16 bytes only (spike fixtures).
import { readFileSync, readFileSync as rf } from "node:fs";
import { connect } from "node:net";
const a = process.argv.slice(2);
const opt = (k) => (a.includes(k) ? a[a.indexOf(k) + 1] : null);
const out = { uid: process.getuid(), gid: process.getgid(), groups: process.getgroups() };
const f = opt("--file"), s = opt("--sock");
if (f) { try { out.file = { ok: true, head: rf(f, "utf8").slice(0, 16) }; } catch (e) { out.file = { ok: false, err: e.code }; } }
const done = () => { console.log(JSON.stringify(out)); };
if (!s) done();
else {
const c = connect(s);
const t = setTimeout(() => { out.sock = { ok: false, err: "TIMEOUT" }; c.destroy(); done(); }, 3000);
let buf = "";
c.on("data", (d) => { buf += d; });
c.on("error", (e) => { clearTimeout(t); out.sock = { ok: false, err: e.code }; done(); });
c.on("close", (hadErr) => { if (!hadErr && !out.sock) { clearTimeout(t); out.sock = { ok: true, reply: buf.trim() }; done(); } });
}
@@ -0,0 +1,34 @@
// usage: node tools/s4-broker.mjs <repo> <dataRoot> <capFile>
// Starts the repository's broker (startBroker, the way the bus tests do) on a
// temp dataRoot, binds one throwaway capability for a made-up business, writes
// it to <capFile>, and waits. On SIGTERM it closes the broker and prints the
// events table, which is what the broker "logs" (it writes nothing to stdout).
import { pathToFileURL } from "node:url";
import { join } from "node:path";
import { writeFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
const [repo, dataRoot, capFile] = process.argv.slice(2);
const { startBroker } = await import(pathToFileURL(join(repo, "packages/bus/src/index.mjs")).href);
const businesses = {
demo: {
id: "demo",
human: "jason",
arbiters: { technical: "cto", delivery: "cto" },
roles: { cto: { authority: { withinRole: ["message.send"], crossRole: [] } } },
},
};
const bus = await startBroker({ dataRoot, businesses });
const cap = bus.broker.bindLaunch({ business: "demo", role: "cto", run: "spike", harness: "pi" });
writeFileSync(capFile, cap, { mode: 0o600 });
console.log("ready", bus.path);
process.on("SIGTERM", async () => {
await bus.close();
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
for (const r of db.prepare("SELECT seq, kind, business, actor_role, subject, body FROM events ORDER BY seq").all())
console.log("event", JSON.stringify(r));
console.log("events-total", db.prepare("SELECT count(*) n FROM events").get().n);
db.close();
process.exit(0);
});
setInterval(() => {}, 1 << 30);
@@ -0,0 +1,11 @@
// Spike helper: a unix socket server that answers each connection with one line,
// "pong <tag> <n>", then closes. node tools/sockserver.mjs <path> <mode-octal> [tag]
// Runs on the host (S2, S3) or in a container (S2 reverse case). No credentials.
import { createServer } from "node:net";
import { chmodSync, rmSync } from "node:fs";
const [path, mode = "600", tag = "srv"] = process.argv.slice(2);
rmSync(path, { force: true });
let n = 0;
const server = createServer((c) => { c.end(`pong ${tag} ${++n}\n`); });
server.listen(path, () => { chmodSync(path, parseInt(mode, 8)); console.log("listening", path, mode); });
for (const s of ["SIGTERM", "SIGINT"]) process.on(s, () => { server.close(); process.exit(0); });
@@ -0,0 +1,25 @@
// A stand-in for a container entrypoint (S8). Environment switches:
// READ=1 read one line from stdin, print its byte length and sha256, never the line
// HANDLE=1 catch SIGTERM, SIGINT and SIGHUP, print which one arrived, exit 0
// Then print its pid and parent pid and idle until a signal ends it.
import { createInterface } from "node:readline";
import { createHash } from "node:crypto";
if (process.env.READ === "1") {
const rl = createInterface({ input: process.stdin });
const line = await new Promise((resolve) => {
rl.once("line", resolve);
rl.once("close", () => resolve(null));
});
rl.close();
if (line === null) console.log("got nothing (stdin closed before a line)");
else console.log(`got len=${Buffer.byteLength(line)} sha256=${createHash("sha256").update(line).digest("hex")}`);
}
if (process.env.HANDLE === "1")
for (const s of ["SIGTERM", "SIGINT", "SIGHUP"])
process.on(s, () => {
console.log(`signal ${s} pid=${process.pid}`);
process.exit(0);
});
console.log(`ready pid=${process.pid} ppid=${process.ppid}`);
setInterval(() => {}, 1 << 30);