From 459c4fc6814981662b2fd1ab0f161e00575489e5 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Mon, 12 Oct 2026 17:32:48 -0500 Subject: [PATCH] darkwing: row 86 round 1 review packet, approve (#1580) Review notes and the rerun of S1, S2, S4, S8, S9 and S0 in a detached worktree of ad0bdc20. Verdict comment 27986, rev 803 (swept in 85fd3286). Co-Authored-By: Claude Opus 5.5 --- .../darkwing/work/queue-86-review/r1/run.sh | 13 ++++ .../darkwing/work/queue-86-review/r1/run.txt | 7 ++ .../queue-86-review/r1/s0-clean-images.out | 26 +++++++ .../queue-86-review/r1/s1-endpoint-pin.out | 39 ++++++++++ .../queue-86-review/r1/s2-uid-mapping.out | 65 +++++++++++++++++ .../queue-86-review/r1/s4-hostile-client.out | 68 ++++++++++++++++++ .../r1/s8-stdin-capability.out | 41 +++++++++++ .../work/queue-86-review/r1/s9-hardening.out | 72 +++++++++++++++++++ .../work/queue-86-review/review-r1.md | 51 +++++++++++++ 9 files changed, 382 insertions(+) create mode 100755 agents/darkwing/work/queue-86-review/r1/run.sh create mode 100644 agents/darkwing/work/queue-86-review/r1/run.txt create mode 100644 agents/darkwing/work/queue-86-review/r1/s0-clean-images.out create mode 100644 agents/darkwing/work/queue-86-review/r1/s1-endpoint-pin.out create mode 100644 agents/darkwing/work/queue-86-review/r1/s2-uid-mapping.out create mode 100644 agents/darkwing/work/queue-86-review/r1/s4-hostile-client.out create mode 100644 agents/darkwing/work/queue-86-review/r1/s8-stdin-capability.out create mode 100644 agents/darkwing/work/queue-86-review/r1/s9-hardening.out create mode 100644 agents/darkwing/work/queue-86-review/review-r1.md diff --git a/agents/darkwing/work/queue-86-review/r1/run.sh b/agents/darkwing/work/queue-86-review/r1/run.sh new file mode 100755 index 000000000..09a6e5e57 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Darkwing's row 86 rerun: S1 S2 S4 S8 S9, then S0. Aborts if another seat is busy. +busy=$(for p in /proc/[0-9]*; do c=$(readlink "$p/cwd" 2>/dev/null) || continue; case $c in */sage-g*|*/rocko-r*|*dewey-scratch*|*filbert-scratch*) echo "BUSY ${p#/proc/} $c";; esac; done) +[ -z "$busy" ] || { echo "$busy"; exit 9; } +pgrep -u "$(id -u)" -fa filbert-scratch && { echo "BUSY filbert-scratch"; exit 9; } +export TMPDIR=$HOME/darkwing-scratch/rv86/tmp +cd "$HOME/darkwing-scratch/rv86/wt/agents/researcher/work/i1-spikes" || exit 9 +for s in s1-endpoint-pin s2-uid-mapping s4-hostile-client s8-stdin-capability s9-hardening s0-clean-images; do + t0=$(date -u +%H:%M:%SZ) + timeout 600 ./$s.sh >/dev/null 2>&1; rc=$? + echo "$s rc=$rc $t0-$(date -u +%H:%M:%SZ) $(grep -c '^ok' out/$s.out) ok, $(grep -c '^FAIL' out/$s.out) FAIL" +done +echo done diff --git a/agents/darkwing/work/queue-86-review/r1/run.txt b/agents/darkwing/work/queue-86-review/r1/run.txt new file mode 100644 index 000000000..d9bc39c00 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/run.txt @@ -0,0 +1,7 @@ +s1-endpoint-pin rc=0 22:29:05Z-22:29:06Z 6 ok, 0 FAIL +s2-uid-mapping rc=0 22:29:06Z-22:29:17Z 23 ok, 0 FAIL +s4-hostile-client rc=0 22:29:17Z-22:29:24Z 21 ok, 0 FAIL +s8-stdin-capability rc=0 22:29:24Z-22:29:42Z 18 ok, 0 FAIL +s9-hardening rc=0 22:29:42Z-22:29:47Z 32 ok, 0 FAIL +s0-clean-images rc=0 22:29:47Z-22:29:47Z 7 ok, 0 FAIL +done diff --git a/agents/darkwing/work/queue-86-review/r1/s0-clean-images.out b/agents/darkwing/work/queue-86-review/r1/s0-clean-images.out new file mode 100644 index 000000000..c2b967a64 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s0-clean-images.out @@ -0,0 +1,26 @@ +# s0-clean-images 2026-10-12T22:29:47Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s0-clean-images.rEayPo +## before +node: d6aa754f16b3 +alpine:3 294b683cb724 + build cache: Build Cache 0B +## remove +ok no container (of anyone) is using the pinned node image + Untagged: node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 + Deleted: sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20 + Total: 0B +## 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/darkwing/work/queue-86-review/r1/s1-endpoint-pin.out b/agents/darkwing/work/queue-86-review/r1/s1-endpoint-pin.out new file mode 100644 index 000000000..4dbda9733 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s1-endpoint-pin.out @@ -0,0 +1,39 @@ +# s1-endpoint-pin 2026-10-12T22:29:05Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s1-endpoint-pin.YNI9xJ +## 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/darkwing/work/queue-86-review/r1/s2-uid-mapping.out b/agents/darkwing/work/queue-86-review/r1/s2-uid-mapping.out new file mode 100644 index 000000000..d40f91750 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s2-uid-mapping.out @@ -0,0 +1,65 @@ +# s2-uid-mapping 2026-10-12T22:29:06Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s2-uid-mapping.UtDZ7H +## 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/darkwing/work/queue-86-review/r1/s4-hostile-client.out b/agents/darkwing/work/queue-86-review/r1/s4-hostile-client.out new file mode 100644 index 000000000..d7f07baf8 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s4-hostile-client.out @@ -0,0 +1,68 @@ +# s4-hostile-client 2026-10-12T22:29:17Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s4-hostile-client.s3JQIf +## the broker, on a temp dataRoot (/home/jwoltje/darkwing-scratch/rv86/wt/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-12T22:29:17.745Z\",\"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 22:29 . + drwxr-xr-x 1 root root 4096 Oct 12 22:29 .. + srw------- 1 root root 0 Oct 12 22:29 broker.sock + -rw------- 1 root root 4096 Oct 12 22:29 bus.sqlite + -rw------- 1 root root 32768 Oct 12 22:29 bus.sqlite-shm + -rw------- 1 root root 115392 Oct 12 22:29 bus.sqlite-wal + -rw------- 1 root root 46 Oct 12 22:29 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/darkwing/work/queue-86-review/r1/s8-stdin-capability.out b/agents/darkwing/work/queue-86-review/r1/s8-stdin-capability.out new file mode 100644 index 000000000..6672239d1 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s8-stdin-capability.out @@ -0,0 +1,41 @@ +# s8-stdin-capability 2026-10-12T22:29:24Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s8-stdin-capability.3Izcvy +made-up capability: 57 bytes, sha256 a3d659831fdb756840377edc343a25ce8a1764d6562979fdc9ea8397ee044d9e (the string itself is never printed) +## 1. one line on stdin +ok entrypoint started and printed ready + entrypoint says: got len=57 sha256=a3d659831fdb756840377edc343a25ce8a1764d6562979fdc9ea8397ee044d9e + 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:[1955323506] +## 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=3128 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=17 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=28 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=22 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=21 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=21 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=20 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=23 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/darkwing/work/queue-86-review/r1/s9-hardening.out b/agents/darkwing/work/queue-86-review/r1/s9-hardening.out new file mode 100644 index 000000000..7d2bd2b30 --- /dev/null +++ b/agents/darkwing/work/queue-86-review/r1/s9-hardening.out @@ -0,0 +1,72 @@ +# s9-hardening 2026-10-12T22:29:42Z +endpoint unix:///run/user/1000/docker.sock: name=rootless +scratch /home/jwoltje/darkwing-scratch/rv86/tmp/i1spike-s9-hardening.x4qdx4 +## 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/darkwing/work/queue-86-review/review-r1.md b/agents/darkwing/work/queue-86-review/review-r1.md new file mode 100644 index 000000000..2dcd1b87b --- /dev/null +++ b/agents/darkwing/work/queue-86-review/review-r1.md @@ -0,0 +1,51 @@ +## Row 86 round 1 review (Darkwing): approve + +**Verdict: approve**, commit `ad0bdc20` of `agents/researcher/work/i1-spikes/`, candidate manifest `0381c233794f61aa98765b8b7c69bc9dd25b2e31702ab7beca556da73c4a586e`. The packet does what the brief's "I1 spikes" section asks, inside its rules, and I found nothing that contradicts decisions 105 to 108. Nothing blocks. The notes below are for Sage's gate rerun and the design row. + +### What I checked + +- **Tree.** `sha256sum -c MANIFEST.sha256` from the packet directory gives 32 of 32 OK, and the manifest's own sha256 is the candidate. `git log ad0bdc20..4835a8e2` on the packet and on the brief is empty. +- **Read.** All of `REPORT.md`, `lib.sh`, `s0` to `s9` and the nine files under `tools/`, against the brief's rules: + - every `docker` call goes through `D()`, which names `unix:///run/user/1000/docker.sock`, and `spike_begin` refuses with exit 2 unless the info lists `name=rootless`. The one call to the system socket is S1's `info`, which the brief asks for; + - the node image is pinned by digest. `Dockerfile.clis` installs `@anthropic-ai/claude-code@2.1.296` and `@earendil-works/pi-coding-agent@0.85.1` inside the image. `package.json` pins pi at 0.85.1, and `~/.local/bin/claude --version` prints 2.1.296; + - every bind source is under the spike's `mktemp -d` scratch directory. No named volume, no `--privileged`, no engine socket in a container, no daemon change; + - no credential. S4's one capability is a throwaway the temp broker issues for a made-up business, on a temp dataRoot under the scratch directory; + - every container, network and image carries `mosaic.i1spike=`, and `spike_end` removes them and the scratch directory, then turns any leftover into exit 2. +- **Citations.** `packages/bus/src/server.mjs:8-11` and `43-56`, `packages/bus/src/runtime.mjs:66-70`, `packages/seat/src/session.mjs:63` (`ANTHROPIC_BASE_URL` is in `ENV_ALLOW`) and the `close()` path in `packages/cli/src/launcher.mjs:393-411` all say what the report quotes. +- **Decisions.** I read 105 to 108 in `docs/plans/2026-09-26_lead-decisions.md`. I agree there's no contradiction. The report's four open items are the right ones for the design row, and the first one, how a containerized broker authenticates a human once the pid proof can't run, is the hard one. +- **Committed outputs.** All ten `out/*.out` files end in `result: pass` with no `FAIL` line. + +### My rerun + +In a detached worktree of `ad0bdc20` under `~/darkwing-scratch/rv86/`, with `TMPDIR` there, after a busy check that aborts. I ran the spikes that need no build and no provider traffic, then S0 to clean up: + +| Spike | Exit | Assertions | UTC | +|---|---|---|---| +| S1 endpoint pin | 0 | 6 ok | 22:29:05Z to 22:29:06Z | +| S2 uid mapping | 0 | 23 ok | 22:29:06Z to 22:29:17Z | +| S4 hostile client | 0 | 21 ok | 22:29:17Z to 22:29:24Z | +| S8 stdin capability | 0 | 18 ok | 22:29:24Z to 22:29:42Z | +| S9 hardening | 0 | 32 ok | 22:29:42Z to 22:29:47Z | +| S0 clean images | 0 | 7 ok | 22:29:47Z | + +Each count matches the committed output, with no `FAIL` line. S2 pulled the node image by digest first; S1 needs no image. The script and its outputs are in `r1/` here. + +I left S3, S5, S6 and S7 to Sage's gate. S6 and S7 build the CLI image and reach the providers, and S5 reaches the network. Afterwards `ps -a` showed no rows, `network ls` showed `bridge host none`, and `image ls -a` showed `alpine:3`, the same end state the report gives. + +### Notes (non-blocking) + +N1. **A rerun in place rewrites tracked files.** `spike_begin` tees into `$HERE/out/$SPIKE.out`, and "How to run" says to `cd` into the packet and run there. In the canonical checkout that leaves the ten committed outputs modified. For the gate, run in a detached worktree of `ad0bdc20` (`REPO` is four levels up from the packet, so a worktree keeps S4's broker import working), then compare `result:` lines. That's what I did, and the worktree came out with those six files modified, as expected. + +N2. **S0 checks the whole daemon.** It asserts no container of anyone, only `bridge host none`, no volume, and it runs `builder prune -f`, which empties the whole build cache. That's true and harmless today, since the daemon held only `alpine:3` before the spikes. Once I2 or anything else uses the rootless daemon, S0 fails, or deletes someone else's build cache. Scope its checks to the label, or retire S0, before that. + +N3. **One stale line.** `REPORT.md:38` says "Nothing was committed or posted". The packet is committed at `ad0bdc20`. + +N4. **S6 sends key-shaped strings to the real providers.** The brief says "with no credential". The `FAKE` and `OAT` values aren't credentials, and the report explains why a key-shaped value is needed: with none, neither CLI makes a model request. I accept it. I'm naming it because it's traffic to api.anthropic.com and api.openai.com with a key-shaped header, which the brief didn't spell out. + +N5. **The spike tools aren't production patterns.** `connect-proxy.mjs` reads only the first chunk (`client.once("data")`), so a CONNECT line split across two reads is refused as malformed, and any bytes after the header in that chunk are dropped. `sockserver.mjs` sets the socket's mode after `listen`, so for a moment it has the umask's mode. Neither matters to what the spikes measure. The design row shouldn't copy either. For the broker, choice 3's own directory per socket is the real gate, not the socket's mode. + +N6. **Tooling gap.** `scripts/mosaic queue review verify-commit 86 HEAD` reports every file "missing", because the manifest's paths are relative to the packet directory and the tool resolves them from the repository root. Check this packet with `sha256sum -c` from the packet directory. The tool could learn to try the manifest's own directory. + +N7. **Dead code.** `RUNFLAGS()` in `lib.sh:51` has no caller, and its comment mentions `--rm`, which it doesn't print. + +Approve for commit `ad0bdc20` and the candidate above.