researcher: row 86 (#1580) I1 spike packet, manifest 0381c233
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
|
```
|
||||||
Executable
+126
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
Reference in New Issue
Block a user