diff --git a/agents/researcher/work/i1-spikes/MANIFEST.sha256 b/agents/researcher/work/i1-spikes/MANIFEST.sha256 new file mode 100644 index 000000000..acea99e59 --- /dev/null +++ b/agents/researcher/work/i1-spikes/MANIFEST.sha256 @@ -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 diff --git a/agents/researcher/work/i1-spikes/REPORT.md b/agents/researcher/work/i1-spikes/REPORT.md new file mode 100644 index 000000000..242020645 --- /dev/null +++ b/agents/researcher/work/i1-spikes/REPORT.md @@ -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/.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=`. 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=: still the rootless list +missing socket: exit 1, stdout "null", + "failed to connect to the docker API at unix:///none.sock; check if the path is correct and if the daemon is running: dial unix /none.sock: connect: no such file or directory" +regular file as endpoint: exit 1, "Cannot connect to the Docker daemon at unix:///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 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 ` 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 :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. + .claude/projects/-/.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-` +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= +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 `; 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 `; 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 +``` diff --git a/agents/researcher/work/i1-spikes/lib.sh b/agents/researcher/work/i1-spikes/lib.sh new file mode 100755 index 000000000..b773f4cf8 --- /dev/null +++ b/agents/researcher/work/i1-spikes/lib.sh @@ -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=; +# - 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/.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')" +} diff --git a/agents/researcher/work/i1-spikes/out/s0-clean-images.out b/agents/researcher/work/i1-spikes/out/s0-clean-images.out new file mode 100644 index 000000000..da7ea31fa --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s0-clean-images.out @@ -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: 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 diff --git a/agents/researcher/work/i1-spikes/out/s1-endpoint-pin.out b/agents/researcher/work/i1-spikes/out/s1-endpoint-pin.out new file mode 100644 index 000000000..040a4878e --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s1-endpoint-pin.out @@ -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:///none.sock; check if the path is correct and if the daemon is running: dial unix /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:///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 diff --git a/agents/researcher/work/i1-spikes/out/s2-uid-mapping.out b/agents/researcher/work/i1-spikes/out/s2-uid-mapping.out new file mode 100644 index 000000000..1b152fc3a --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s2-uid-mapping.out @@ -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 /d/file;srw------- jwoltje:jwoltje /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 diff --git a/agents/researcher/work/i1-spikes/out/s3-socket-restart.out b/agents/researcher/work/i1-spikes/out/s3-socket-restart.out new file mode 100644 index 000000000..9bdfadca8 --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s3-socket-restart.out @@ -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: /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 diff --git a/agents/researcher/work/i1-spikes/out/s4-hostile-client.out b/agents/researcher/work/i1-spikes/out/s4-hostile-client.out new file mode 100644 index 000000000..330566c6b --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s4-hostile-client.out @@ -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 /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 /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 diff --git a/agents/researcher/work/i1-spikes/out/s5-internal-network-proxy.out b/agents/researcher/work/i1-spikes/out/s5-internal-network-proxy.out new file mode 100644 index 000000000..948c311c4 --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s5-internal-network-proxy.out @@ -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 diff --git a/agents/researcher/work/i1-spikes/out/s6-provider-clis-proxy.out b/agents/researcher/work/i1-spikes/out/s6-provider-clis-proxy.out new file mode 100644 index 000000000..1e2e49675 --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s6-provider-clis-proxy.out @@ -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 diff --git a/agents/researcher/work/i1-spikes/out/s7-read-only-root.out b/agents/researcher/work/i1-spikes/out/s7-read-only-root.out new file mode 100644 index 000000000..4d39a43ea --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s7-read-only-root.out @@ -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- 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- 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 diff --git a/agents/researcher/work/i1-spikes/out/s8-stdin-capability.out b/agents/researcher/work/i1-spikes/out/s8-stdin-capability.out new file mode 100644 index 000000000..0b0499db6 --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s8-stdin-capability.out @@ -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 diff --git a/agents/researcher/work/i1-spikes/out/s9-hardening.out b/agents/researcher/work/i1-spikes/out/s9-hardening.out new file mode 100644 index 000000000..8376fbf95 --- /dev/null +++ b/agents/researcher/work/i1-spikes/out/s9-hardening.out @@ -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 diff --git a/agents/researcher/work/i1-spikes/s0-clean-images.sh b/agents/researcher/work/i1-spikes/s0-clean-images.sh new file mode 100755 index 000000000..f761858cf --- /dev/null +++ b/agents/researcher/work/i1-spikes/s0-clean-images.sh @@ -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 $?)" diff --git a/agents/researcher/work/i1-spikes/s1-endpoint-pin.sh b/agents/researcher/work/i1-spikes/s1-endpoint-pin.sh new file mode 100755 index 000000000..b8f75796b --- /dev/null +++ b/agents/researcher/work/i1-spikes/s1-endpoint-pin.sh @@ -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##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##g" +[ "$NRC" -ne 0 ]; check 'regular file as endpoint: nonzero exit' $? diff --git a/agents/researcher/work/i1-spikes/s2-uid-mapping.sh b/agents/researcher/work/i1-spikes/s2-uid-mapping.sh new file mode 100755 index 000000000..aebf711c2 --- /dev/null +++ b/agents/researcher/work/i1-spikes/s2-uid-mapping.sh @@ -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##" | 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 diff --git a/agents/researcher/work/i1-spikes/s3-socket-restart.sh b/agents/researcher/work/i1-spikes/s3-socket-restart.sh new file mode 100755 index 000000000..46ad4416b --- /dev/null +++ b/agents/researcher/work/i1-spikes/s3-socket-restart.sh @@ -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##")" +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##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)" diff --git a/agents/researcher/work/i1-spikes/s4-hostile-client.sh b/agents/researcher/work/i1-spikes/s4-hostile-client.sh new file mode 100755 index 000000000..0206b333c --- /dev/null +++ b/agents/researcher/work/i1-spikes/s4-hostile-client.sh @@ -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##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##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)" $? diff --git a/agents/researcher/work/i1-spikes/s5-internal-network-proxy.sh b/agents/researcher/work/i1-spikes/s5-internal-network-proxy.sh new file mode 100755 index 000000000..220caad4d --- /dev/null +++ b/agents/researcher/work/i1-spikes/s5-internal-network-proxy.sh @@ -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)" $? \ No newline at end of file diff --git a/agents/researcher/work/i1-spikes/s6-provider-clis-proxy.sh b/agents/researcher/work/i1-spikes/s6-provider-clis-proxy.sh new file mode 100755 index 000000000..17a10184b --- /dev/null +++ b/agents/researcher/work/i1-spikes/s6-provider-clis-proxy.sh @@ -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//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/^/ /' diff --git a/agents/researcher/work/i1-spikes/s7-read-only-root.sh b/agents/researcher/work/i1-spikes/s7-read-only-root.sh new file mode 100755 index 000000000..cb98ad140 --- /dev/null +++ b/agents/researcher/work/i1-spikes/s7-read-only-root.sh @@ -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//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- 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- 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 diff --git a/agents/researcher/work/i1-spikes/s8-stdin-capability.sh b/agents/researcher/work/i1-spikes/s8-stdin-capability.sh new file mode 100755 index 000000000..bedf8eaa2 --- /dev/null +++ b/agents/researcher/work/i1-spikes/s8-stdin-capability.sh @@ -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 $?)" diff --git a/agents/researcher/work/i1-spikes/s9-hardening.sh b/agents/researcher/work/i1-spikes/s9-hardening.sh new file mode 100755 index 000000000..3d1bf3719 --- /dev/null +++ b/agents/researcher/work/i1-spikes/s9-hardening.sh @@ -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 $?)" diff --git a/agents/researcher/work/i1-spikes/tools/Dockerfile.clis b/agents/researcher/work/i1-spikes/tools/Dockerfile.clis new file mode 100644 index 000000000..6f48657d9 --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/Dockerfile.clis @@ -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/claude-code@2.1.296 \ + @earendil-works/pi-coding-agent@0.85.1 \ + && npm cache clean --force \ + && rm -rf /tmp/* /root/.npm diff --git a/agents/researcher/work/i1-spikes/tools/connect-proxy.mjs b/agents/researcher/work/i1-spikes/tools/connect-proxy.mjs new file mode 100644 index 000000000..8514355ca --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/connect-proxy.mjs @@ -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(","))); diff --git a/agents/researcher/work/i1-spikes/tools/hardening-probe.mjs b/agents/researcher/work/i1-spikes/tools/hardening-probe.mjs new file mode 100644 index 000000000..20c3599ab --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/hardening-probe.mjs @@ -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= 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)); diff --git a/agents/researcher/work/i1-spikes/tools/hostile.mjs b/agents/researcher/work/i1-spikes/tools/hostile.mjs new file mode 100644 index 000000000..9380aa15d --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/hostile.mjs @@ -0,0 +1,52 @@ +// usage: node hostile.mjs +// 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"')); diff --git a/agents/researcher/work/i1-spikes/tools/netprobe.mjs b/agents/researcher/work/i1-spikes/tools/netprobe.mjs new file mode 100644 index 000000000..07f64bf35 --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/netprobe.mjs @@ -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 }); } +} diff --git a/agents/researcher/work/i1-spikes/tools/probe.mjs b/agents/researcher/work/i1-spikes/tools/probe.mjs new file mode 100644 index 000000000..cc1efaafc --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/probe.mjs @@ -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(); } }); +} diff --git a/agents/researcher/work/i1-spikes/tools/s4-broker.mjs b/agents/researcher/work/i1-spikes/tools/s4-broker.mjs new file mode 100644 index 000000000..88d827d4b --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/s4-broker.mjs @@ -0,0 +1,34 @@ +// usage: node tools/s4-broker.mjs +// 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 , 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); diff --git a/agents/researcher/work/i1-spikes/tools/sockserver.mjs b/agents/researcher/work/i1-spikes/tools/sockserver.mjs new file mode 100644 index 000000000..86696795a --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/sockserver.mjs @@ -0,0 +1,11 @@ +// Spike helper: a unix socket server that answers each connection with one line, +// "pong ", then closes. node tools/sockserver.mjs [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); }); diff --git a/agents/researcher/work/i1-spikes/tools/stdin-cap.mjs b/agents/researcher/work/i1-spikes/tools/stdin-cap.mjs new file mode 100644 index 000000000..0601d49c2 --- /dev/null +++ b/agents/researcher/work/i1-spikes/tools/stdin-cap.mjs @@ -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);