researcher: row 87 human-proof research packet, manifest 1ae3020d

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-12 15:30:42 -05:00
co-authored by Claude Opus 5.5
parent d4e8e05690
commit ec60937650
19 changed files with 1469 additions and 0 deletions
@@ -0,0 +1,18 @@
7b4263250048554807c682491984f15086fa923f99ac25c83d21c2f3017f6968 h0-clean-images.sh
8d60ce8b745881aaf2339a49943d3f8429ab257cbefe4e7988af1b793a1fe02a h1-peercred.sh
2285b82fff53d696f6703342b5dcf7ef64d384ab7165882f8139c896a5a2bc48 h2-gate.sh
a5f0610006baac520c1fccc094deb51bfc33d17047aadb018d6ecfc001465d1d h2-probe.sh
f59227c2e9d87b5046491c8b03044afbfb182e24dbab7c919caf49b91f7db4bc lib.sh
357b6f33b83d85ad7d6d3f653624de4ba5c4cb2d2ccff3dd13f6030edff5b3ca out/h0-clean-images.out
3760dcfe9b1e3215eca830a7a71bd596572eae76fcdbbf715f536c38e97f28bf out/h1-peercred.out
b01b1b38fbbb7b2746ea6692bd5109b0d1d0db4b190da021f3a61c7196ac1cf9 out/h2-gate.out
b8391e7b602f215f4819b5645d235dfc186895cfab7b870dd50aba7bcb8a72f2 out/h2-probe.out
395683d05041b01923ec2eb79adc38f590d2c194e102c760b70f97eae4f1ede5 REPORT.md
bc13087c24614fc2f1692decbdb6eef0e0587d30e94d918cadadd33a97f0e36f tools/detach.sh
357c649eb5fba5ac79256013403f6b32b3204cd54746b8610e112ae5ee05abaa tools/forge.mjs
7b58ced624edb25991287f98b73c1b19d502ef083618151d9e3e184446efdf76 tools/gate.mjs
b63b4c5778565ee0c5bb42c43f39a291b948f6e5ea7ebdcc181095367d41d51d tools/h2-baseline-broker.mjs
b51566b829ff31694f83fec372d4e5089e4678f2c4909b5b5a58a0d031572f3f tools/h2-broker.mjs
acc1bfecf6a261791126a5ff6d59ff0e6d8e7bb781a9a46e1a2620507332152d tools/peer.pl
dbf4a760fe009ad653b323160a3ae89b603d652fdb54988dcdd51f0041da4e6a tools/probe.mjs
f03dae03fcc3b92c21ba40923c2d4818fb24653577cbdb09b475929bf819c933 tools/under-claude.sh
@@ -0,0 +1,224 @@
# I1 human proof from a containerized broker — spike report (row 87, open point 1)
Researcher, 2026-10-12. Spikes ran 20:26–20:27Z (final pass) on the rootless engine
(`unix:///run/user/1000/docker.sock`, `name=rootless`), docker 29.7.2, kernel 7.2.2-1-cachyos,
host node 26.8.1, container node 24.21.0 pinned by digest
`sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20`. No credentials,
no live bus, no `~/.mosaic*`, stand-in broker on a temp dataRoot. Every script proved its own
cleanup; `h0-clean-images.sh` removed the pinned image last and the daemon is back to
`alpine:3` only (not ours), no containers, default networks, no volumes.
## The short version
1. **A containerized broker cannot identify a host client from peer credentials.** The host user's
process arrives as pid 0, uid 0, gid 0 — identical to container root. With `--pid=host` the real
host pid arrives, but see 2.
2. **No container can read a host process's `environ`.** Not root, not with `CAP_SYS_PTRACE`, not
with `--pid=host`. The current proof depends on `environ` twice: the `MOSAIC_BUS_CLI_NONCE`
match and the agent-marker refusals. So the current proof's full strength can only be evaluated
by a process in the host user namespace.
3. **Recommendation: a thin host relay** (a process of the human user, no credentials, no dataRoot)
that runs today's `verifyHuman` unchanged, adds a check that the claimed pid is the pid the kernel
reports for the connecting socket, and forwards `{business}` to the broker's human socket. In my
spike it matches the current proof on every scenario and additionally refuses the
snooped-nonce forger that the current proof accepts.
4. **What it costs, stated plainly:** the broker's human socket has to be connectable by the host
user (mode 0666 inside a host-private directory). A same-UID host process that skips the relay
and speaks the protocol to that socket passes with no proof. That is the same class of hole as
the accepted one (decisions 62, 95: a same-UID host agent that detaches passes), and the engine
route (`docker run --user … -v <dir>`) reaches it regardless, but the direct connect is cheaper.
REQ-DEC-3 holds against agent containers (structural: no mount) and against accidental or
inherited-environment launches. It does not hold against a deliberate same-UID host agent, as
today.
## (a) What SO_PEERCRED returns across the rootless user namespace (h1-peercred.sh)
Rootless docker uses one user namespace for all containers: container uid 0 is host 1000,
container uid N≥1 is host 100000+N−1 (`0 1000 1` / `1 100000 65536`). One pid namespace per container.
| Server | Client | pid | uid | gid | /proc/pid in server |
|---|---|---|---|---|---|
| container 1001:2000, own pidns | host user (perl) | **0** | **0** | **0** | none |
| same | container root | 0 | 0 | 0 | none |
| same | container uid 1002:2002 | 0 | 1002 | 2002 | none |
| container 1001:2000, `--pid=host` | host user | real host pid | 0 | 0 | stat, cmdline readable; **environ not** |
| same | container uid 1002 | host-side pid (client's NSpid "N 1") | 1002 | 2002 | same |
| container root (host uid 1000), socket 0600 | host user | 0 | 0 | 0 | none |
| host perl server | container root | real host pid | 1000 | 1000 | environ readable |
| host perl server | container uid 1002 | real host pid | 101001 | 102001 | environ not readable |
- uid, gid and pid **cannot tell the host user from container root** (0/0/0 for both).
- The host user's supplementary groups arrive as overflow 65534 or container gid 0
(SO_PEERGROUPS), so groups carry no identity either.
- pidfd (SO_PEERPIDFD) works: `fdinfo` shows `Pid` 0 in a separate pid namespace, the real pid
under `--pid=host`.
- A host user cannot connect to a socket owned by a subuid at 0600 or 0660, only at 0666
(also row 86 S2). A container non-root client is refused by the mode bits.
- A second host uid was not available; I did not run that case (it would show as 65534).
Does the pid map to anything the container can read? Only under `--pid=host`, and only
`stat` and `cmdline`.
## (b) What a container can and cannot read of a host process (h2-probe.sh)
Container root, `--cap-drop ALL`, `--pid=host`, reading a live host `human-cli` and its
ancestors (`out/h2-probe.out`): `stat` ok, `cmdline` ok, `lstat` uid 0, **`environ`: EACCES**, at every
level up to systemd. Repeated with `--cap-add SYS_PTRACE`: same. Pid 1 shows as uid 65534.
My reading of why (kernel source, not spiked): the capability LSM's `ptrace_access_check` lets a
process in a child user namespace read another namespace's `environ` only with `CAP_SYS_PTRACE` in
the target's namespace, here the initial one, which no rootless container can hold. Effect for
`verifyHuman` inside a container: `readProcess` returns `env: null`, the nonce comparison
(`p.env?.MOSAIC_BUS_CLI_NONCE !== proof.nonce`) fails, and every genuine request is refused.
Run as an unmodified relay in a container, the proof **fails closed on the genuine CLI**
(`gate-claimed`, `gate-peer` columns below). That is not a usable result; it is the finding.
## Candidate proofs
All were run against the same hostile and genuine clients (`h2-gate.sh`). "Detached, clean env"
stands for two things the verifier cannot tell apart: the human at their terminal, and a same-UID
agent that deliberately detached (`setsid -f env -i`). That is the accepted limit and appears as
accepted in every column.
### A. Gate container (`--user 0:0 --pid=host --cap-drop ALL --read-only`, `--group-add <human socket gid>`)
Runs `verifyHuman` against the host `/proc` inside a container and forwards `{business}` to the
broker's group-gated 0660 human socket. Keeps the broker non-root with no host-pid access.
- **Trusts:** the kernel-reported peer pid (via SO_PEERCRED), host `stat`/`cmdline`, the gate being
unreachable from agent containers.
- **Cannot do:** read `environ`. To work at all it needs a `peer-noenv` mode where the claimed
nonce replaces the unreadable one. That drops the agent-marker checks.
- **Forgeable by a same-UID host process:** everything the current proof is forgeable by, **plus**
a CLI or ancestor carrying `CLAUDECODE` / `MOSAIC_RUN_ID` passes (measured: accepted in both
marker scenarios). A live ancestor named `claude` is still refused (argv is readable).
- **Against an agent container:** refused by structure (container root with the gate directory
mounted and a made-up proof: `human-required`; container uid 1002: EACCES).
- **Conflicts with:** decision 105 (a second consumer of the human socket directory), and the
hardening set (root, `--pid=host`).
- **Verdict: weaker than now.** Does not meet "no weaker proof".
### B. Host relay (host process of the human user, no container)
Same code as the gate (`tools/gate.mjs`, mode `peer`) run on the host: `verifyHuman` unchanged
(environ readable), plus claimed pid must equal the SO_PEERCRED pid of the connecting socket;
forwards `{business}` to the broker's human socket opened to 0666 in a host-private directory.
- **Trusts:** the host `/proc` as the human user sees it, the kernel peer pid, the relay being the
only thing that tells humans from agents on that socket.
- **Forgeable by a same-UID host process:** the accepted limit (detach + clean env); **and** a direct
connect to the 0666 human socket, which needs no proof at all (measured: accepted). A
snooped-nonce forger on its own connection is refused (peer pid ≠ claimed pid; measured, and the
same forger is accepted by the current proof).
- **Against an agent container:** no mount, no route; the 0666 socket sits in a directory they do
not have.
- **Verdict: no weaker for the cases the current proof covers**; adds a direct-connect path that
the current design lacks (but the engine route already provides).
### C. Reachability only (survey option D): the socket is the proof, no verification
Measured as the direct-connect row of B: accepted with no proof. Drops every current check. A
`human-cli` run by an agent in the host shell passes. **Weaker than now.** Fine for the Console
(it is the human by mount, decision 105); not for `human-cli` on the host.
### D. Nonce through a file
A file the host user writes and a file container root writes both show as owner 0:0 in any
container (host 1000:1000 on the host). A container cannot tell them apart, and any same-UID host
process can read or write the file. It proves "host uid", nothing more. **Does not discriminate
agent from human.** Not pursued.
### E. pidfd (SO_PEERPIDFD)
Not a proof on its own; a hardening for B. It closes the window between reading the peer pid and
reading `/proc/<pid>`, where a short-lived connecting process could be replaced by pid reuse.
Works (H1 reads the pidfd's `Pid`). Needs native code: Node cannot read SO_PEERCRED or
SO_PEERPIDFD.
### F. A second host uid for the relay (not spiked)
The only way I can see to close the direct-connect path under the one-uid host: the relay runs as
a different host user that agents' uid cannot read. Needs host root to create, and the rootless
engine cannot provide it. Out of the rootless-only rules, so not tried.
## Matrix (out/h2-gate.out; accepted = reached a human session, refused = human-required)
| scenario | current proof (host) | container gate, claimed | container gate, peer | container gate, peer-noenv | host relay |
|---|---|---|---|---|---|
| genuine CLI, detached, clean env | accepted | **refused** | **refused** | accepted | accepted |
| agent marker in an ancestor only | refused | refused | refused | **accepted** | refused |
| agent marker in the CLI itself | refused | refused | refused | **accepted** | refused |
| live ancestor named `claude`, no marker | refused | refused | refused | refused | refused |
| snooped-nonce forger, own connection | **accepted** | refused | refused | refused | refused |
| held genuine CLI, afterwards | accepted | refused | refused | accepted | accepted |
Other measured results:
- Container root with the gate directory mounted and a made-up proof: `human-required`. Container uid 1002: EACCES.
- A non-root gate container (uid 1001, `--pid=host`) refuses the genuine CLI (environ unreadable).
- Host user to the agent socket (0600, uid 1001): EACCES. Agent socket with a human envelope: `human-required`. An agent capability on a human-only verb: `human-required`.
- Engine access: `docker run --user 1001:2000 -v <human dir>` reaches the human socket with no proof: **accepted** (the floor, below).
- The stand-in broker's own temp store recorded 2 events (session.launched, action.refused). Nothing from the live bus store or any PM turn was read.
The snooped-nonce finding is about the **current** proof, host mode included: it verifies the
claimed process, not the connecting one, so a same-UID process can copy a live `human-cli`'s nonce
from `/proc/<pid>/environ` and send it on its own connection. Measured: accepted. The peer-pid
check fixes it. That is worth doing in host mode regardless of I1.
## Recommendation
**B, the host relay,** with the peer-pid check. Reasons: it is the only candidate that keeps every
check the current proof makes, because those checks need the host `/proc` including `environ`, and
only a host-namespace process can read that. A fixes the placement and loses the checks; C and D
drop them.
The floor to state in the I1 design, since the question asked what an agent process on the host
running as the same UID could forge: **REQ-DEC-3 holds against agent containers and against
accidental or environment-inherited launches. It does not hold against a deliberate same-UID host
process**, which can detach with a clean environment (today), connect straight to the human
socket (B and C), or use the engine to mount it (any design). If the project wants more, it needs a
second uid (F) or a user-held secret that is not a file or environment variable (a passphrase typed
at the CLI would qualify; WebAuthn is already ruled out among the settled decisions; I did not test a passphrase).
Your call whether the accepted-limit decisions cover the direct connect.
## What I3 would have to build
1. **`serve()` socket mode option** (`server.mjs:93` hard-codes `chmod 0600`). The human listener
needs 0666 (relay) and the agent listener stays 0600.
2. **A trust-the-transport `authenticateHuman`** for the human listener (the stand-in's version is
`tools/h2-broker.mjs`: look up the business, `bindHuman`). The listener must not exist on the
agent socket (spiked: agent socket answers a human envelope `human-required`).
3. **A new `via` value** in `broker.mjs:236` (`bindHuman` accepts only `'cli'`). The relay and the
Console should be distinguishable in the record. The spikes used `'cli'`.
4. **The relay** (`packages/bus`, host process, human uid): `gate.mjs` is the spike; I3 owns the
real module. It runs `verifyHuman` with `cliPath` set to the host path of `human-cli.mjs` in the
canonical checkout (the path-bound proof: bus, Console, wrapper and relay share a tree).
5. **Launch records.** `verifyHuman` also refuses ancestors matching `launches`
(`runtime.mjs:41-52`, held in the bus process's memory). The relay is outside that process, so
the host launcher has to hand it the records (shared file or the relay living with the launcher).
Without them the `launches` refusal is lost for host-launched agents. I did not model this.
6. **A peer-credential reader.** Node cannot read SO_PEERCRED. The spike hands the accepted
socket's fd (`socket._handle.fd`, undocumented) to `perl` per connection. I3 should pick: a
small native helper, a Node addon, or perl as a stated host dependency. SO_PEERPIDFD (E)
if pid reuse matters.
7. **`human-cli` points at the relay socket** instead of the broker (its argv[2] path; no protocol
change, the proof object stays `pid, startTime, nonce, business`).
8. **The relay's lifecycle and path**: started by the human launch path (decisions 94, 102), socket
0600 in a host-private directory, no credentials, no dataRoot, runs from the same tree.
9. **Directory layout for the human socket:** mode 0666 socket in a directory that is
0777 on the inside and under a 0700 host parent (the spike's layout, because a non-root
container uid cannot create files in a directory the host user owns at 0700).
10. **Decision 105** needs a sentence: the human socket's directory is mounted into the Console, and
the host relay reaches it through the host path. No second container mount.
## Flags for you
- B makes the human socket connectable by host processes without the engine (0666). That is new
relative to 0660 plus a mount. I think it is the price of any host-side human proof, but it is
your decision, not mine.
- A would need the human socket directory in a second container, `--pid=host` and root. Outside
decision 105 and the hardening set. I do not recommend it.
- Not proved: a second host uid; argv rewriting by a hostile same-UID process (both proofs are
cooperative, as the code comment says); pid-reuse race (E); relay concurrency; the Console
path; perl availability on hosts other than this one.
## Files (all in this directory)
- `lib.sh` — shared helpers (rootless refusal, scratch, cleanup proof).
- `h1-peercred.sh` + `tools/peer.pl` — section (a).
- `h2-probe.sh` + `tools/probe.mjs` — section (b).
- `h2-gate.sh` + `tools/{gate.mjs,h2-broker.mjs,h2-baseline-broker.mjs,forge.mjs,detach.sh,under-claude.sh}` — candidates and matrix.
- `h0-clean-images.sh` — run last; removes the pinned node image.
- `out/*.out` — final outputs. `MANIFEST.sha256` — hashes.
- Rerun order: `h1-peercred.sh`, `h2-probe.sh`, `h2-gate.sh`, `h0-clean-images.sh`.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# H0, run last: removes what h1 and h2 share and neither removes alone. The pinned node
# image is pulled once and used by both (and by row 86's spikes; this script removes it by
# digest, so run it after whichever packet is last). No image is built here, so there is
# no build cache to prune. Prints `ps -a`, `network ls`, `image ls -a` and `volume ls` so
# the end state is visible. It leaves alpine:3, which was on the daemon before the spikes
# began and is not ours.
. "$(dirname "$0")/lib.sh"
spike_begin h0-clean-images
cd "$HERE" || exit 2
say "## before"
D image ls -a --format ' {{.Repository}}:{{.Tag}} {{.ID}}'
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.i1human"); [ -n "$IDS" ] && D image rm -f $IDS 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}}'
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.i1human label" "$([ -z "$(D image ls -a -q --filter label=mosaic.i1human)" ]; 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 $?)"
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env bash
# H1, what SO_PEERCRED returns across the rootless user namespace. A unix-socket
# server (tools/peer.pl) reports, for each connection, the peer's pid, uid and gid
# (SO_PEERCRED), its supplementary groups (SO_PEERGROUPS) and its pidfd
# (SO_PEERPIDFD), and whether that pid means anything in the server's own /proc.
# Cases: a host client and two container clients against a container server (own
# pid namespace, then --pid=host), and the same clients' containers against a
# host server. The question under test: can a container-side broker tell the
# host user's process from a container process, and can it find the host process
# in /proc? All containers run with the hardening set; no credential is involved.
. "$(dirname "$0")/lib.sh"
spike_begin h1-peercred
need_node_image
cd "$HERE" || exit 2
mkdir -p "$SCRATCH/tools"
cp tools/peer.pl "$SCRATCH/tools/"
for d in a b c d e; do mkdir "$SCRATCH/$d"; chmod 777 "$SCRATCH/$d"; done
SRV=
trap '_rc=$?; [ -n "$SRV" ] && kill -9 $SRV 2>/dev/null; (exit $_rc); spike_end' EXIT
HARD=(--cap-drop ALL --security-opt no-new-privileges --read-only)
jg() { node -e 'let v=JSON.parse(process.argv[1]);for(const k of process.argv[2].split("."))v=v?.[k];console.log(v!==null&&typeof v==="object"?JSON.stringify(v):v)' "$J" "$1"; }
is() { [ "$(jg "$1")" = "$2" ]; echo $?; }
# cserver NAME DIR MODE docker-flags...: a perl server in a container
cserver() {
local name=$1 dir=$2 mode=$3; shift 3
D run -d --name "$name" --label "$LABEL" "${HARD[@]}" "$@" -v "$SCRATCH/$dir:/h" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" perl /t/peer.pl serve /h/sock "$mode" >/dev/null || return 1
for _ in $(seq 50); do [ -S "$SCRATCH/$dir/sock" ] && return 0; sleep 0.1; done
return 1
}
# ccall DIR docker-flags...: a perl client in a container; sets J
ccall() {
local dir=$1; shift
J=$(D run --rm --label "$LABEL" "${HARD[@]}" "$@" -v "$SCRATCH/$dir:/h" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" perl /t/peer.pl call /h/sock 2>&1)
}
hcall() { J=$(perl tools/peer.pl call "$SCRATCH/$1/sock" 2>&1); }
show() { say " $1"; say " server: $(jg reply.server.uid) uid, pidns $(jg reply.server.pidns), uid_map '$(jg reply.server.uid_map)'"; \
say " server saw peer: pid=$(jg reply.peer_as_server_sees_it.pid) uid=$(jg reply.peer_as_server_sees_it.uid) gid=$(jg reply.peer_as_server_sees_it.gid) groups=$(jg reply.peer_as_server_sees_it.groups)"; \
say " pidfd=$(jg reply.peer_as_server_sees_it.pidfd) /proc/<pid>=$(jg reply.peer_as_server_sees_it.proc)"; \
say " client's own view: pid=$(jg client.pid) uid=$(jg client.uid) gid=$(jg client.gid) pidns=$(jg client.pidns)"; }
showh() { say " $1"; say " server saw peer: pid=$(jg peer_as_server_sees_it.pid) uid=$(jg peer_as_server_sees_it.uid) gid=$(jg peer_as_server_sees_it.gid) groups=$(jg peer_as_server_sees_it.groups)"; \
say " pidfd=$(jg peer_as_server_sees_it.pidfd) /proc/<pid>=$(jg peer_as_server_sees_it.proc)"; }
say "## the mapping (host user $(id -u):$(id -g), pid namespace $(readlink /proc/self/ns/pid), user namespace $(readlink /proc/self/ns/user))"
D run --rm --label "$LABEL" "${HARD[@]}" "$NODE_IMG" sh -c 'echo "container user namespace $(readlink /proc/self/ns/user), pid namespace $(readlink /proc/self/ns/pid)"; cat /proc/self/uid_map' | sed 's/^/ /'
D run --rm --label "$LABEL" "${HARD[@]}" --pid=host "$NODE_IMG" sh -c 'echo "with --pid=host: pid namespace $(readlink /proc/self/ns/pid), pid 1 is $(cat /proc/1/comm)"' 2>&1 | sed 's/^/ /'
say "## A. server in a container (uid 1001:2000, own pid namespace), socket 0666"
cserver "i1h1-a-$$" a 666 --user 1001:2000; check "the container server starts" $?
say "socket as the host sees it: $(stat -c '%A %u:%g' "$SCRATCH/a/sock")"
hcall a; J_A_HOST=$J; show "client 1: the host user's process (perl on the host)"
check "the host client connects at 0666" "$(is connect ok)"
check "the server sees the host client as uid 0 (host uid 1000 maps to container root)" "$(is reply.peer_as_server_sees_it.uid 0)"
check "the server sees the host client's gid as 0" "$(is reply.peer_as_server_sees_it.gid 0)"
check "the server gets pid 0 for the host client (not in its pid namespace)" "$(is reply.peer_as_server_sees_it.pid 0)"
check "there is no /proc/<pid> for it in the server" "$(is reply.peer_as_server_sees_it.proc.dir_exists 0)"
ccall a --user 0:0; show "client 2: container root, another container"
check "container root connects" "$(is connect ok)"
check "the server sees a container root client as uid 0 too" "$(is reply.peer_as_server_sees_it.uid 0)"
check "and pid 0 (a different pid namespace)" "$(is reply.peer_as_server_sees_it.pid 0)"
ccall a --user 1002:2002; show "client 3: container uid 1002, another container"
check "the server sees container uid 1002 as uid 1002" "$(is reply.peer_as_server_sees_it.uid 1002)"
check "and as gid 2002" "$(is reply.peer_as_server_sees_it.gid 2002)"
J=$J_A_HOST
H_UID=$(jg reply.peer_as_server_sees_it.uid); H_GID=$(jg reply.peer_as_server_sees_it.gid); H_PID=$(jg reply.peer_as_server_sees_it.pid)
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 0:0 -v "$SCRATCH/a:/h" -v "$SCRATCH/tools:/t:ro" "$NODE_IMG" perl /t/peer.pl call /h/sock 2>&1)
[ "$H_UID/$H_GID/$H_PID" = "$(jg reply.peer_as_server_sees_it.uid)/$(jg reply.peer_as_server_sees_it.gid)/$(jg reply.peer_as_server_sees_it.pid)" ]
check "uid, gid and pid cannot tell the host user's process from a container root process ($H_UID/$H_GID/$H_PID for both)" $?
D rm -f "i1h1-a-$$" >/dev/null
say "## B. server in a container with --pid=host (uid 1001:2000), socket 0666"
if cserver "i1h1-b-$$" b 666 --user 1001:2000 --pid=host; then
check "the --pid=host container server starts under the rootless daemon" 0
hcall b; show "client 1: the host user's process"
HP=$(jg client.pid)
check "the server sees the host client's real host pid ($HP)" "$(is reply.peer_as_server_sees_it.pid "$HP")"
check "the server sees the host client as uid 0 and gid 0" "$([ "$(jg reply.peer_as_server_sees_it.uid)/$(jg reply.peer_as_server_sees_it.gid)" = 0/0 ]; echo $?)"
check "/proc/<pid> exists for it" "$(is reply.peer_as_server_sees_it.proc.dir_exists 1)"
check "stat and cmdline are readable (world-readable files)" "$([ "$(jg reply.peer_as_server_sees_it.proc.stat_readable)$(jg reply.peer_as_server_sees_it.proc.cmdline_readable)" = 11 ]; echo $?)"
check "environ is NOT readable by the non-root server uid (host uid 1000 owns it, mode 0400)" "$(is reply.peer_as_server_sees_it.proc.environ_readable 0)"
say " cmdline the server read: $(jg reply.peer_as_server_sees_it.proc.cmdline | sed "s#$SCRATCH#<scratch>#")"
ccall b --user 1002:2002; show "client 3: container uid 1002 (own pid namespace)"
CP=$(jg client.pid)
check "the server sees a container client's host-side pid, not its in-container pid ($(jg reply.peer_as_server_sees_it.pid), the client calls itself $CP)" "$([ "$(jg reply.peer_as_server_sees_it.pid)" != "$CP" ]; echo $?)"
else
check "the --pid=host container server starts under the rootless daemon" 1
say " docker run --pid=host refused or the server never came up:"; D logs "i1h1-b-$$" 2>&1 | sed 's/^/ /'
fi
D rm -f "i1h1-b-$$" >/dev/null 2>&1
say "## C. server in a container as container root (host uid 1000), socket 0600"
cserver "i1h1-c-$$" c 600 --user 0:0; check "the root server starts" $?
say "socket as the host sees it: $(stat -c '%A %u:%g' "$SCRATCH/c/sock") (host user is $(id -u):$(id -g))"
hcall c; show "client 1: the host user's process"
check "the host client connects to a 0600 socket owned by the host uid" "$(is connect ok)"
check "the server sees uid 0 and pid 0 again" "$([ "$(jg reply.peer_as_server_sees_it.uid)/$(jg reply.peer_as_server_sees_it.pid)" = 0/0 ]; echo $?)"
ccall c --user 1002:2002; say " client 3: container uid 1002 -> $(jg connect) ($(jg errno))"
check "a container non-root client is refused (mode bits, cap-drop ALL)" "$(is connect refused)"
D rm -f "i1h1-c-$$" >/dev/null
say "## D. server on the host (perl, socket 0600 and 0666), clients in containers"
perl tools/peer.pl serve "$SCRATCH/d/sock" 666 > "$SCRATCH/d.log" 2>&1 &
SRV=$!
for _ in $(seq 50); do [ -S "$SCRATCH/d/sock" ] && break; sleep 0.1; done
ccall d --user 0:0; show "client 2: container root"
check "the host server sees container root as the host uid ($(id -u))" "$(is reply.peer_as_server_sees_it.uid "$(id -u)")"
check "and as the real host pid of the container process (visible in /proc: $(jg reply.peer_as_server_sees_it.proc.dir_exists))" "$(is reply.peer_as_server_sees_it.proc.dir_exists 1)"
CRP=$(jg reply.peer_as_server_sees_it.pid); CRC=$(jg reply.peer_as_server_sees_it.proc.cmdline)
check "that pid is not the pid the container sees for itself ($(jg client.pid))" "$([ "$CRP" != "$(jg client.pid)" ]; echo $?)"
ccall d --user 1002:2002; show "client 3: container uid 1002"
check "the host server sees container uid 1002 as uid $((100000 + 1002 - 1)) (subuid range)" "$(is reply.peer_as_server_sees_it.uid $((100000 + 1002 - 1)))"
check "and gid $((100000 + 2002 - 1))" "$(is reply.peer_as_server_sees_it.gid $((100000 + 2002 - 1)))"
check "environ of that container process is not readable by the host user (owner is a subuid)" "$(is reply.peer_as_server_sees_it.proc.environ_readable 0)"
kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=
say "## E. what a host client looks like to a server whose uid is not the host user"
say "see A and B: uid 0 / gid 0 / pid 0 (own pid namespace) or the host pid (--pid=host). A host process owned by"
say "another host uid (root, for example) would show as the overflow uid 65534; this spike has no second host uid to run."
J=$J_A_HOST
say "the host user's supplementary groups ($(id -G)) reach the server as: $(jg reply.peer_as_server_sees_it.groups)"
check "SO_PEERGROUPS maps the host user's supplementary host groups to overflow gid 65534 or container gid 0" "$(node -e 'const g=JSON.parse(process.argv[1]);process.exit(Array.isArray(g)&&g.every((x)=>x===0||x===65534)?0:1)' "$(jg reply.peer_as_server_sees_it.groups)"; echo $?)"
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env bash
# H2, can a container-side broker keep the human proof it has today? Two stand-in
# brokers on temp dataRoots, never the live bus:
# BASELINE the repository's own startBroker on the host: the proof as it is now.
# GATE a stand-in broker in a non-root container (1001:2000) with an agent socket
# and a group-gated human socket (0660, gid 2000), and, in front of the human
# socket, a gate container: container root (= host uid 1000), --cap-drop ALL,
# no-new-privileges, --read-only, --pid=host, --group-add 2000. The gate runs
# the repository's verifyHuman against the HOST /proc and forwards to the
# broker. Run twice: MODE=claimed (the claimed pid is trusted, as today) and
# MODE=peer (it must also equal the pid SO_PEERCRED reports for the connection).
# The same scenarios run against each. The genuine client is the repository's real
# human-cli.mjs, launched detached from a clean environment. No credential is involved:
# one made-up business, one throwaway capability.
. "$(dirname "$0")/lib.sh"
spike_begin h2-gate
need_node_image
cd "$HERE" || exit 2
S=$SCRATCH
CLI=$REPO/packages/bus/src/human-cli.mjs
mkdir -p "$S/pkg" "$S/tools" "$S/dr" "$S/hs" "$S/as" "$S/share" "$S/g" "$S/g2" "$S/base" "$S/f"
chmod 777 "$S/hs" "$S/as" "$S/share" "$S/g2"
# The Store insists its dataRoot is owned by the broker's uid; the host user is uid 0 in
# a container, so hand the directory to 1001:2000 first (container root does it, once).
D run --rm --label "$LABEL" --user 0:0 -v "$S/dr:/dr" "$NODE_IMG" sh -c 'chown 1001:2000 /dr && chmod 700 /dr' || exit 2
cp -r "$REPO/packages/bus/src" "$S/pkg/src"; cp "$REPO/packages/bus/schema.sql" "$S/pkg/"
cp tools/gate.mjs tools/h2-broker.mjs tools/forge.mjs tools/peer.pl "$S/tools/"
printf '{"business":"demo","verb":"decisions","args":{"list":"roles"}}' > "$S/req.json"
BASE=
cleanup_procs() {
[ -n "$BASE" ] && kill -9 $BASE 2>/dev/null
exec 9>&-
pkill -9 -f -- "$SCRATCH" 2>/dev/null
}
trap '_rc=$?; cleanup_procs; (exit $_rc); spike_end' EXIT
HARD=(--cap-drop ALL --security-opt no-new-privileges --read-only)
jg() { node -e 'let v;try{v=JSON.parse(process.argv[1])}catch{console.log("unparsable");process.exit(0)}for(const k of process.argv[2].split("."))v=v?.[k];console.log(v!==null&&typeof v==="object"?JSON.stringify(v):v)' "$J" "$1"; }
waitdone() { for _ in $(seq 100); do [ -e "$1.done" ] && return 0; sleep 0.1; done; return 1; }
# outcome of a human-cli run: accepted (the verb answered), refused (human-required), other
cli_outcome() {
if grep -q '^\[' "$1" 2>/dev/null; then echo accepted
elif grep -q human-required "$1" 2>/dev/null; then echo refused
else echo "other($(tr '\n' ' ' < "$1" 2>/dev/null | cut -c1-80))"; fi
}
forge_outcome() { # reads J
case $(jg connect) in
ok) if [ "$(jg reply.ok)" = true ]; then echo accepted; else echo "refused($(jg reply.error))"; fi ;;
not-attempted) echo "no-target" ;;
*) echo "no-connect($(jg connect))" ;;
esac
}
declare -A R # R["scenario|column"]=outcome
# scenarios COLUMN SOCK: the host-side clients, the same against every target
scenarios() {
local col=$1 sock=$2 out
say "### scenarios against $col ($sock)" | sed "s#$S#<scratch>#g"
# 1. the genuine human-cli, detached, clean environment
out=$S/o-genuine-$col; tools/detach.sh "$out" "$S/req.json" "$CLI" "$sock" -; waitdone "$out"
R["genuine human-cli, detached, clean env|$col"]=$(cli_outcome "$out")
# 2. the CLI run under a shell that carries an agent marker (CLAUDECODE), the marker removed from the CLI itself
out=$S/o-anc-$col; tools/detach.sh "$out" "$S/req.json" "$CLI" "$sock" CLAUDECODE CLAUDECODE=1; waitdone "$out"
R["agent marker in an ancestor only|$col"]=$(cli_outcome "$out")
# 3. the marker in the CLI's own environment
out=$S/o-self-$col; tools/detach.sh "$out" "$S/req.json" "$CLI" "$sock" - MOSAIC_RUN_ID=r1; waitdone "$out"
R["agent marker in the CLI itself|$col"]=$(cli_outcome "$out")
# 3b. a live ancestor whose command name is claude, no marker anywhere: the signal that lives in argv
out=$S/o-argv-$col; tools/under-claude.sh "$out" "$S/req.json" "$CLI" "$sock"; waitdone "$out"
R["a live ancestor named claude, no marker|$col"]=$(cli_outcome "$out")
# 4. a same-UID process snoops a LIVE human-cli's nonce and speaks for it on its own connection.
# The CLI is held open on a FIFO (it reads stdin before it connects), so the nonce is in its environ.
out=$S/o-hold-$col; rm -f "$S/hold"; mkfifo "$S/hold"
tools/detach.sh "$out" "$S/hold" "$CLI" "$sock" -
exec 9>"$S/hold"
J=
for _ in $(seq 50); do J=$(node tools/forge.mjs "$sock" find "$CLI" 2>&1); [ "$(jg connect)" != not-attempted ] && break; sleep 0.1; done
R["snooped nonce, forger on its own connection|$col"]=$(forge_outcome)
say " forger claimed pid $(jg claimed): $(forge_outcome)"
cat "$S/req.json" >&9; exec 9>&-
waitdone "$out"
R["the held genuine CLI, afterwards|$col"]=$(cli_outcome "$out")
}
say "## BASELINE: the repository's startBroker on the host, temp dataRoot, node $(node -v)"
mkdir -m 700 "$S/base/dr"
node tools/h2-baseline-broker.mjs "$REPO" "$S/base/dr" "$S/base/cap" > "$S/base/out" 2>&1 &
BASE=$!
for _ in $(seq 100); do grep -q '^ready' "$S/base/out" 2>/dev/null && break; sleep 0.1; done
check "the baseline broker is listening" "$([ -S "$S/base/dr/bus/broker.sock" ]; echo $?)"
scenarios baseline "$S/base/dr/bus/broker.sock"
kill -TERM "$BASE" 2>/dev/null; wait "$BASE" 2>/dev/null; BASE=
say "## GATE: broker container (1001:2000), two sockets"
D run -d --name "i1h2-broker-$$" --label "$LABEL" "${HARD[@]}" --user 1001:2000 \
-v "$S/dr:/dr" -v "$S/hs:/hs" -v "$S/as:/as" -v "$S/share:/share" -v "$S/pkg:/pkg:ro" -v "$S/tools:/t:ro" \
"$NODE_IMG" node /t/h2-broker.mjs /pkg/src /dr /hs/human.sock /as/agent.sock /share/cap >/dev/null
for _ in $(seq 100); do D logs "i1h2-broker-$$" 2>&1 | grep -q '^ready' && break; sleep 0.1; done
check "the broker container is up" "$(D logs "i1h2-broker-$$" 2>&1 | grep -q '^ready'; echo $?)"
say "human socket as the host sees it: $(stat -c '%A %u:%g' "$S/hs/human.sock") (host user $(id -u):$(id -g)); agent socket: $(stat -c '%A %u:%g' "$S/as/agent.sock")"
D logs "i1h2-broker-$$" 2>&1 | grep -v '^ready' | head -3 | sed 's/^/ broker log: /'
# gate MODE: start (or restart) the gate container
GATE=i1h2-gate-$$
gate() {
D rm -f "$GATE" >/dev/null 2>&1; rm -f "$S/g/sock"
D run -d --name "$GATE" --label "$LABEL" "${HARD[@]}" --user 0:0 --pid=host --group-add 2000 \
-e CLI_PATH="$CLI" -e MODE="$1" \
-v "$S/g:/g" -v "$S/hs:/hs" -v "$S/pkg:/pkg:ro" -v "$S/tools:/t:ro" \
"$NODE_IMG" node /t/gate.mjs /pkg/src /g/sock /hs/human.sock >/dev/null || return 1
for _ in $(seq 100); do [ -S "$S/g/sock" ] && D logs "$GATE" 2>&1 | grep -q 'gate ready' && return 0; sleep 0.1; done
return 1
}
for MODE in claimed peer peer-noenv; do
say "## GATE, MODE=$MODE"
gate $MODE; check "the gate container starts ($MODE; root, cap-drop ALL, read-only, --pid=host, --group-add 2000)" $?
D logs "$GATE" 2>&1 | sed "s#$S#<scratch>#g;s/^/ gate log: /"
say "gate socket as the host sees it: $(stat -c '%A %u:%g' "$S/g/sock") gate inspect: user=$(D inspect "$GATE" --format '{{.Config.User}}') pid=$(D inspect "$GATE" --format '{{.HostConfig.PidMode}}') caps-dropped=$(D inspect "$GATE" --format '{{.HostConfig.CapDrop}}') groups=$(D inspect "$GATE" --format '{{.HostConfig.GroupAdd}}') readonly=$(D inspect "$GATE" --format '{{.HostConfig.ReadonlyRootfs}}')"
scenarios "gate-$MODE" "$S/g/sock"
say " gate decisions:"; D logs "$GATE" 2>&1 | grep -E '^gate [a-z-]+ (accept|refuse)' | sed 's/^/ /'
if [ "$MODE" = peer-noenv ]; then
say "### the same gate against a client that cannot see the host"
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 0:0 -v "$S/g:/g" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/forge.mjs /g/sock random 2>&1)
R["container root with the gate dir mounted (misconfiguration), made-up proof|gate-peer-noenv"]=$(forge_outcome)
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 1002:2002 -v "$S/g:/g" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/forge.mjs /g/sock random 2>&1)
R["container uid 1002 with the gate dir mounted, made-up proof|gate-peer-noenv"]=$(forge_outcome)
say " container root: ${R["container root with the gate dir mounted (misconfiguration), made-up proof|gate-peer-noenv"]}; container uid 1002: ${R["container uid 1002 with the gate dir mounted, made-up proof|gate-peer-noenv"]}"
fi
done
say "## bypass attempts on the broker's own sockets"
J=$(node tools/forge.mjs "$S/hs/human.sock" direct 2>&1); say " host user, human socket directly: $(forge_outcome)"
check "a host process cannot connect to the broker's human socket (0660, group 2000)" "$([ "$(jg connect)" = EACCES ]; echo $?)"
J=$(node tools/forge.mjs "$S/as/agent.sock" random 2>&1); say " host user, agent socket: $(forge_outcome)"
check "a host process cannot connect to the agent socket either (0600, uid 1001)" "$([ "$(jg connect)" = EACCES ]; echo $?)"
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 1001:2000 -v "$S/as:/as" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/forge.mjs /as/agent.sock random 2>&1)
say " agent socket, a human envelope with a made-up proof: $(forge_outcome)"
check "the agent socket answers a human envelope with human-required" "$([ "$(forge_outcome)" = 'refused(human-required)' ]; echo $?)"
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 1001:2000 -v "$S/as:/as" -v "$S/share:/share:ro" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/forge.mjs /as/agent.sock cap /share/cap 2>&1)
say " agent socket, the agent's capability on a human-only verb: $(forge_outcome)"
check "an agent capability gets human-required on a human-only verb" "$([ "$(forge_outcome)" = 'refused(human-required)' ]; echo $?)"
say "## HOST RELAY: the same gate as a host process (can read environ), broker human socket opened to 0666"
# A host user cannot connect to a socket owned by a subuid at 0660 (row 86 S2), so a relay
# on the host needs the broker's human socket at 0666, kept private by the scratch directory (0700).
D exec "i1h2-broker-$$" node -e "require('fs').chmodSync('/hs/human.sock',0o666)" || exit 2
mkdir -p "$S/hr"
CLI_PATH="$CLI" MODE=peer PEER_PL="$S/tools/peer.pl" node tools/gate.mjs "$REPO/packages/bus/src" "$S/hr/sock" "$S/hs/human.sock" > "$S/hr/log" 2>&1 &
for _ in $(seq 100); do [ -S "$S/hr/sock" ] && grep -q 'gate ready' "$S/hr/log" && break; sleep 0.1; done
check "the host relay starts (host user $(id -u), node $(node -v))" "$([ -S "$S/hr/sock" ]; echo $?)"
scenarios host-relay "$S/hr/sock"
say " relay decisions:"; grep -E '^gate [a-z-]+ (accept|refuse)' "$S/hr/log" | sed 's/^/ /'
J=$(node tools/forge.mjs "$S/hs/human.sock" direct 2>&1)
R["host process connects to the broker's human socket directly, no proof|host-relay"]=$(forge_outcome)
say " a host process straight to the broker's human socket (0666): $(forge_outcome)"
D exec "i1h2-broker-$$" node -e "require('fs').chmodSync('/hs/human.sock',0o660)"
say "## the floor: a same-UID process with rootless-engine access"
J=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 1001:2000 -v "$S/hs:/hs" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/forge.mjs /hs/human.sock direct 2>&1)
R["docker run --user 1001:2000 -v hs, no proof at all|engine"]=$(forge_outcome)
say " a container started by the host user with the human socket directory mounted and --user 1001:2000: $(forge_outcome)"
say "## negative control: the same gate as a NON-root container (1001:2000, --pid=host), socket 0666"
D rm -f "$GATE" >/dev/null 2>&1
D run -d --name "$GATE" --label "$LABEL" "${HARD[@]}" --user 1001:2000 --pid=host --group-add 2000 \
-e CLI_PATH="$CLI" -e MODE=claimed -e SOCK_MODE=666 \
-v "$S/g2:/g" -v "$S/hs:/hs" -v "$S/pkg:/pkg:ro" -v "$S/tools:/t:ro" \
"$NODE_IMG" node /t/gate.mjs /pkg/src /g/sock /hs/human.sock >/dev/null
for _ in $(seq 100); do [ -S "$S/g2/sock" ] && break; sleep 0.1; done
out=$S/o-genuine-nonroot; tools/detach.sh "$out" "$S/req.json" "$CLI" "$S/g2/sock" -; waitdone "$out"
R["genuine human-cli, detached, clean env|gate-nonroot"]=$(cli_outcome "$out")
say " genuine human-cli through the non-root gate: ${R["genuine human-cli, detached, clean env|gate-nonroot"]}"
say "## candidate C, a nonce in a file: who owns a file the host user wrote, as a container sees it"
echo host > "$S/f/by-host"
D run --rm --label "$LABEL" "${HARD[@]}" --user 0:0 -v "$S/f:/f" "$NODE_IMG" sh -c 'echo root > /f/by-container-root'
for u in 0:0 1002:2002; do
say " seen by container $u: $(D run --rm --label "$LABEL" "${HARD[@]}" --user $u -v "$S/f:/f:ro" "$NODE_IMG" sh -c 'stat -c "%n owner %u:%g mode %a" /f/by-host /f/by-container-root' | tr '\n' ';')"
done
say " seen by the host: $(stat -c '%n owner %u:%g' "$S/f/by-host" "$S/f/by-container-root" | sed "s#$S/f/##" | tr '\n' ';')"
O=$(D run --rm --label "$LABEL" "${HARD[@]}" --user 1002:2002 -v "$S/f:/f:ro" "$NODE_IMG" stat -c '%u' /f/by-host /f/by-container-root | sort -u | tr '\n' ' ')
check "the file the host user wrote and the file container root wrote have the same owner in a container ($O)" "$([ "$O" = '0 ' ]; echo $?)"
say "## stop the brokers; what each recorded"
D stop -t 5 "i1h2-broker-$$" >/dev/null 2>&1
D logs "i1h2-broker-$$" 2>&1 | grep -E '^(event|events-total)' | sed 's/^/ gate broker: /'
say "## the matrix (accepted = the request reached a human session; refused = human-required)"
COLS=(baseline gate-claimed gate-peer gate-peer-noenv host-relay)
printf '%-52s' scenario; for c in "${COLS[@]}"; do printf ' | %-14s' "$c"; done; echo
for sc in "genuine human-cli, detached, clean env" "agent marker in an ancestor only" "agent marker in the CLI itself" "a live ancestor named claude, no marker" "snooped nonce, forger on its own connection" "the held genuine CLI, afterwards"; do
printf '%-52s' "$sc"; for c in "${COLS[@]}"; do printf ' | %-14s' "${R["$sc|$c"]}"; done; echo
done
say "container root, gate dir mounted, made-up proof: ${R["container root with the gate dir mounted (misconfiguration), made-up proof|gate-peer-noenv"]}"
say "container uid 1002, gate dir mounted, made-up proof: ${R["container uid 1002 with the gate dir mounted, made-up proof|gate-peer-noenv"]}"
say "non-root gate, genuine CLI: ${R["genuine human-cli, detached, clean env|gate-nonroot"]}"
say "host process straight to the broker's human socket at 0666: ${R["host process connects to the broker's human socket directly, no proof|host-relay"]}"
say "engine access (docker run --user 1001:2000 -v hs): ${R["docker run --user 1001:2000 -v hs, no proof at all|engine"]}"
say "## hypotheses, each stated before this run"
is() { [ "${R["$1|$2"]}" = "$3" ]; echo $?; }
for sc in "genuine human-cli, detached, clean env|accepted" "agent marker in an ancestor only|refused" "agent marker in the CLI itself|refused" "the held genuine CLI, afterwards|accepted"; do
check "baseline: ${sc%%|*} -> ${sc##*|}" "$(is "${sc%%|*}" baseline "${sc##*|}")"
check "host-relay: ${sc%%|*} -> ${sc##*|}" "$(is "${sc%%|*}" host-relay "${sc##*|}")"
done
check "baseline: a snooped-nonce forger IS accepted (the proof checks the claimed process, not the connecting one)" "$(is 'snooped nonce, forger on its own connection' baseline accepted)"
check "host-relay: the same forger is refused (claimed pid != SO_PEERCRED pid)" "$(is 'snooped nonce, forger on its own connection' host-relay 'refused(human-required)')"
for col in gate-claimed gate-peer; do
check "$col: the genuine CLI is refused (a container cannot read environ, so the nonce check fails closed)" "$(is 'genuine human-cli, detached, clean env' $col refused)"
done
check "gate-peer-noenv: the genuine CLI is accepted" "$(is 'genuine human-cli, detached, clean env' gate-peer-noenv accepted)"
check "gate-peer-noenv: an agent marker in an ancestor is ACCEPTED (environ unreadable: the weakening)" "$(is 'agent marker in an ancestor only' gate-peer-noenv accepted)"
check "gate-peer-noenv: an agent marker in the CLI itself is ACCEPTED (the weakening)" "$(is 'agent marker in the CLI itself' gate-peer-noenv accepted)"
check "baseline: a live ancestor named claude is refused" "$(is 'a live ancestor named claude, no marker' baseline refused)"
check "host-relay: a live ancestor named claude is refused" "$(is 'a live ancestor named claude, no marker' host-relay refused)"
check "gate-peer-noenv: a live ancestor named claude is still refused (argv is readable)" "$(is 'a live ancestor named claude, no marker' gate-peer-noenv refused)"
check "gate-peer-noenv: the snooped-nonce forger is refused" "$(is 'snooped nonce, forger on its own connection' gate-peer-noenv 'refused(human-required)')"
check "a container root client with the gate dir mounted is refused" "$(is 'container root with the gate dir mounted (misconfiguration), made-up proof' gate-peer-noenv 'refused(human-required)')"
check "a container uid 1002 client cannot connect to the gate socket" "$(case ${R["container uid 1002 with the gate dir mounted, made-up proof|gate-peer-noenv"]} in no-connect*) echo 0 ;; *) echo 1 ;; esac)"
check "a non-root gate cannot read environ, so it refuses the genuine CLI (fails closed)" "$(is 'genuine human-cli, detached, clean env' gate-nonroot refused)"
check "host-relay: a host process reaches the broker's 0666 human socket with no proof (the cost of the relay)" "$(is "host process connects to the broker's human socket directly, no proof" host-relay accepted)"
check "engine access reaches the human socket with no proof (the floor)" "$(is 'docker run --user 1001:2000 -v hs, no proof at all' engine accepted)"
cleanup_procs
check "no process with this scratch directory in its command line is left" "$(pgrep -f -- "$S" >/dev/null; [ $? -ne 0 ]; echo $?)"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# H2 diagnostic: why does verifyHuman, run by container root with --pid=host, refuse the
# genuine human-cli? Holds a detached human-cli open on a FIFO, then runs tools/probe.mjs
# in a gate-shaped container (cap-drop ALL, no-new-privileges, read-only, --pid=host) and
# prints what readProcess sees at each step of the ancestry. No broker, no credentials.
set -u
. "$(dirname "$0")/lib.sh"
spike_begin h2-probe
need_node_image
cd "$HERE" || exit 2
S=$SCRATCH
CLI=$REPO/packages/bus/src/human-cli.mjs
mkdir -p "$S/pkg" "$S/tools"
cp -r "$REPO/packages/bus/src" "$S/pkg/src"; cp "$REPO/packages/bus/schema.sql" "$S/pkg/"
cp tools/probe.mjs "$S/tools/"
trap '_rc=$?; exec 9>&-; pkill -9 -f -- "$SCRATCH" 2>/dev/null; (exit $_rc); spike_end' EXIT
mkfifo "$S/hold"
tools/detach.sh "$S/out" "$S/hold" "$CLI" "$S/nosock" -
exec 9>"$S/hold"
sleep 1
say "host view of the held CLI and its ancestors:"
for p in $(pgrep -f -- "$CLI"); do say " host pid $p ppid $(ps -o ppid= -p "$p" | tr -d ' ') uid $(stat -c %u /proc/$p) cmd: $(tr '\0' ' ' < /proc/$p/cmdline | cut -c1-90)"; done
say "## probe as container root (uid 0:0), --pid=host, hardened"
D run --rm --label "$LABEL" --cap-drop ALL --security-opt no-new-privileges --read-only --user 0:0 --pid=host \
-v "$S/pkg:/pkg:ro" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/probe.mjs /pkg/src "$CLI" 2>&1 | sed 's/^/ /'
say "## the same with --cap-add SYS_PTRACE (to see whether a capability is what is missing)"
D run --rm --label "$LABEL" --cap-drop ALL --cap-add SYS_PTRACE --security-opt no-new-privileges --read-only --user 0:0 --pid=host \
-v "$S/pkg:/pkg:ro" -v "$S/tools:/t:ro" "$NODE_IMG" node /t/probe.mjs /pkg/src "$CLI" 2>&1 | sed 's/^/ /'
exec 9>&-
@@ -0,0 +1,92 @@
# Shared by the I1 human-proof spike scripts (row 87 research note). Sourced, never run.
# Same rules and helpers as ../i1-spikes/lib.sh (row 86), minus the proxy and CLI-image helpers.
#
# 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.i1human=<spike>;
# - on exit, pass or fail, the spike's containers, networks, built images and
# scratch directory are removed, and the script checks that nothing is left.
#
# Exit codes: 0 the observed behavior matched the assertions, 1 it did not,
# 2 a rule refused the run (wrong daemon, leftovers). Output is also saved to
# out/<spike>.out.
EP=unix:///run/user/1000/docker.sock
# node:24-bookworm-slim, linux/amd64, pulled 2026-10-12T19:09Z, re-pulled 20:08Z after s0 removed it. 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.i1human=$SPIKE"
# The scratch parent is mktemp's 0700 directory; spikes mount only subdirectories.
SCRATCH=$(mktemp -d "${TMPDIR:-/tmp}/i1human-$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
}
@@ -0,0 +1,23 @@
# h0-clean-images 2026-10-12T20:27:35Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1human-h0-clean-images.pfebfE
## before
node:<none> d6aa754f16b3
alpine:3 294b683cb724
## remove
ok no container (of anyone) is using the pinned node image
Untagged: node@sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20
Deleted: sha256:d6aa754f16b3197301076f047b5def2f02ea1dbbc2ca920407d46d7ec7f87b20
## after
network: bridge bridge
network: host host
network: none null
image: alpine:3 294b683cb724
ok the pinned node image is gone
ok alpine:3 (not ours) is still there
ok no image carries a mosaic.i1human 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 h0-clean-images is left
result: pass
@@ -0,0 +1,92 @@
# h1-peercred 2026-10-12T20:26:59Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1human-h1-peercred.9jGctr
## the mapping (host user 1000:1000, pid namespace pid:[4026531836], user namespace user:[4026531837])
container user namespace user:[4026534061], pid namespace pid:[4026537291]
0 1000 1
1 100000 65536
with --pid=host: pid namespace pid:[4026531836], pid 1 is systemd
## A. server in a container (uid 1001:2000, own pid namespace), socket 0666
ok the container server starts
socket as the host sees it: srw-rw-rw- 101000:101999
client 1: the host user's process (perl on the host)
server: 1001 uid, pidns pid:[4026537312], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=0 uid=0 gid=0 groups=[65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,0,65534]
pidfd={"fd":5,"fdinfo_pid":0,"nspid":0} /proc/<pid>={"cmdline":null,"cmdline_readable":0,"dir_exists":0,"environ_readable":0,"stat_readable":0}
client's own view: pid=830335 uid=1000 gid=1000 pidns=pid:[4026531836]
ok the host client connects at 0666
ok the server sees the host client as uid 0 (host uid 1000 maps to container root)
ok the server sees the host client's gid as 0
ok the server gets pid 0 for the host client (not in its pid namespace)
ok there is no /proc/<pid> for it in the server
client 2: container root, another container
server: 1001 uid, pidns pid:[4026537312], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=0 uid=0 gid=0 groups=[0]
pidfd={"fd":5,"fdinfo_pid":0,"nspid":0} /proc/<pid>={"cmdline":null,"cmdline_readable":0,"dir_exists":0,"environ_readable":0,"stat_readable":0}
client's own view: pid=1 uid=0 gid=0 pidns=pid:[4026537736]
ok container root connects
ok the server sees a container root client as uid 0 too
ok and pid 0 (a different pid namespace)
client 3: container uid 1002, another container
server: 1001 uid, pidns pid:[4026537312], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=0 uid=1002 gid=2002 groups=[2002]
pidfd={"fd":5,"fdinfo_pid":0,"nspid":0} /proc/<pid>={"cmdline":null,"cmdline_readable":0,"dir_exists":0,"environ_readable":0,"stat_readable":0}
client's own view: pid=1 uid=1002 gid=2002 pidns=pid:[4026537735]
ok the server sees container uid 1002 as uid 1002
ok and as gid 2002
ok uid, gid and pid cannot tell the host user's process from a container root process (0/0/0 for both)
## B. server in a container with --pid=host (uid 1001:2000), socket 0666
ok the --pid=host container server starts under the rootless daemon
client 1: the host user's process
server: 1001 uid, pidns pid:[4026531836], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=836690 uid=0 gid=0 groups=[65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,0,65534]
pidfd={"fd":5,"fdinfo_pid":836690,"nspid":836690} /proc/<pid>={"cmdline":"perl tools/peer.pl call /mnt/storage/scratch/tmp/i1human-h1-peercred.9jGctr/b/sock ","cmdline_readable":1,"dir_exists":1,"environ_readable":0,"stat_readable":1}
client's own view: pid=836690 uid=1000 gid=1000 pidns=pid:[4026531836]
ok the server sees the host client's real host pid (836690)
ok the server sees the host client as uid 0 and gid 0
ok /proc/<pid> exists for it
ok stat and cmdline are readable (world-readable files)
ok environ is NOT readable by the non-root server uid (host uid 1000 owns it, mode 0400)
cmdline the server read: perl tools/peer.pl call <scratch>/b/sock
client 3: container uid 1002 (own pid namespace)
server: 1001 uid, pidns pid:[4026531836], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=838615 uid=1002 gid=2002 groups=[2002]
pidfd={"fd":5,"fdinfo_pid":838615,"nspid":"838615 1"} /proc/<pid>={"cmdline":"perl /t/peer.pl call /h/sock ","cmdline_readable":1,"dir_exists":1,"environ_readable":0,"stat_readable":1}
client's own view: pid=1 uid=1002 gid=2002 pidns=pid:[4026534245]
ok the server sees a container client's host-side pid, not its in-container pid (838615, the client calls itself 1)
## C. server in a container as container root (host uid 1000), socket 0600
ok the root server starts
socket as the host sees it: srw------- 1000:1000 (host user is 1000:1000)
client 1: the host user's process
server: 0 uid, pidns pid:[4026536782], uid_map ' 0 1000 1 1 100000 65536 '
server saw peer: pid=0 uid=0 gid=0 groups=[65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,0,65534]
pidfd={"fd":5,"fdinfo_pid":0,"nspid":0} /proc/<pid>={"cmdline":null,"cmdline_readable":0,"dir_exists":0,"environ_readable":0,"stat_readable":0}
client's own view: pid=841099 uid=1000 gid=1000 pidns=pid:[4026531836]
ok the host client connects to a 0600 socket owned by the host uid
ok the server sees uid 0 and pid 0 again
client 3: container uid 1002 -> refused (Permission denied)
ok a container non-root client is refused (mode bits, cap-drop ALL)
## D. server on the host (perl, socket 0600 and 0666), clients in containers
client 2: container root
server: 1000 uid, pidns pid:[4026531836], uid_map ' 0 0 4294967295 '
server saw peer: pid=844122 uid=1000 gid=1000 groups=[1000]
pidfd={"fd":5,"fdinfo_pid":844122,"nspid":"844122 1"} /proc/<pid>={"cmdline":"perl /t/peer.pl call /h/sock ","cmdline_readable":1,"dir_exists":1,"environ_readable":1,"stat_readable":1}
client's own view: pid=1 uid=0 gid=0 pidns=pid:[4026536782]
ok the host server sees container root as the host uid (1000)
ok and as the real host pid of the container process (visible in /proc: 1)
ok that pid is not the pid the container sees for itself (1)
client 3: container uid 1002
server: 1000 uid, pidns pid:[4026531836], uid_map ' 0 0 4294967295 '
server saw peer: pid=846901 uid=101001 gid=102001 groups=[102001]
pidfd={"fd":5,"fdinfo_pid":846901,"nspid":"846901 1"} /proc/<pid>={"cmdline":"perl /t/peer.pl call /h/sock ","cmdline_readable":1,"dir_exists":1,"environ_readable":0,"stat_readable":1}
client's own view: pid=1 uid=1002 gid=2002 pidns=pid:[4026535831]
ok the host server sees container uid 1002 as uid 101001 (subuid range)
ok and gid 102001
ok environ of that container process is not readable by the host user (owner is a subuid)
## E. what a host client looks like to a server whose uid is not the host user
see A and B: uid 0 / gid 0 / pid 0 (own pid namespace) or the host pid (--pid=host). A host process owned by
another host uid (root, for example) would show as the overflow uid 65534; this spike has no second host uid to run.
the host user's supplementary groups (1000 3 90 108 955 956 979 982 983 985 989 995 998 3009) reach the server as: [65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,65534,0,65534]
ok SO_PEERGROUPS maps the host user's supplementary host groups to overflow gid 65534 or container gid 0
cleanup: no container, network, image or scratch directory labelled h1-peercred is left
result: pass
@@ -0,0 +1,127 @@
# h2-gate 2026-10-12T20:27:17Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1human-h2-gate.dGfU3S
## BASELINE: the repository's startBroker on the host, temp dataRoot, node v26.8.1
ok the baseline broker is listening
### scenarios against baseline (<scratch>/base/dr/bus/broker.sock)
forger claimed pid 851027: accepted
## GATE: broker container (1001:2000), two sockets
ok the broker container is up
human socket as the host sees it: srw-rw---- 101000:101999 (host user 1000:1000); agent socket: srw------- 101000:101999
## GATE, MODE=claimed
ok the gate container starts (claimed; root, cap-drop ALL, read-only, --pid=host, --group-add 2000)
gate log: gate ready mode=claimed uid=0 listen=/g/sock sock=600 cli=/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs
gate socket as the host sees it: srw------- 1000:1000 gate inspect: user=0:0 pid=host caps-dropped=[ALL] groups=[2000] readonly=true
### scenarios against gate-claimed (<scratch>/g/sock)
forger claimed pid 852424: refused(human-required)
gate decisions:
gate claimed refuse human-required claimed=852091 peer=unchecked
gate claimed refuse human-required claimed=852193 peer=unchecked
gate claimed refuse human-required claimed=852261 peer=unchecked
gate claimed refuse human-required claimed=852348 peer=unchecked
gate claimed refuse human-required claimed=852424 peer=unchecked
gate claimed refuse human-required claimed=852424 peer=unchecked
## GATE, MODE=peer
ok the gate container starts (peer; root, cap-drop ALL, read-only, --pid=host, --group-add 2000)
gate log: gate ready mode=peer uid=0 listen=/g/sock sock=600 cli=/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs
gate socket as the host sees it: srw------- 1000:1000 gate inspect: user=0:0 pid=host caps-dropped=[ALL] groups=[2000] readonly=true
### scenarios against gate-peer (<scratch>/g/sock)
forger claimed pid 854176: refused(human-required)
gate decisions:
gate peer refuse human-required claimed=853790 peer=853790
gate peer refuse human-required claimed=853904 peer=853904
gate peer refuse human-required claimed=853985 peer=853985
gate peer refuse human-required claimed=854062 peer=854062
gate peer refuse human-required claimed=854176 peer=854309
gate peer refuse human-required claimed=854176 peer=854176
## GATE, MODE=peer-noenv
ok the gate container starts (peer-noenv; root, cap-drop ALL, read-only, --pid=host, --group-add 2000)
gate log: gate ready mode=peer-noenv uid=0 listen=/g/sock sock=600 cli=/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs
gate socket as the host sees it: srw------- 1000:1000 gate inspect: user=0:0 pid=host caps-dropped=[ALL] groups=[2000] readonly=true
### scenarios against gate-peer-noenv (<scratch>/g/sock)
forger claimed pid 855562: refused(human-required)
gate decisions:
gate peer-noenv accept claimed=855155 peer=855155
gate peer-noenv accept claimed=855241 peer=855241
gate peer-noenv accept claimed=855332 peer=855332
gate peer-noenv refuse human-required claimed=855420 peer=855420
gate peer-noenv refuse human-required claimed=855562 peer=855685
gate peer-noenv accept claimed=855562 peer=855562
### the same gate against a client that cannot see the host
container root: refused(human-required); container uid 1002: no-connect(EACCES)
## bypass attempts on the broker's own sockets
host user, human socket directly: no-connect(EACCES)
ok a host process cannot connect to the broker's human socket (0660, group 2000)
host user, agent socket: no-connect(EACCES)
ok a host process cannot connect to the agent socket either (0600, uid 1001)
agent socket, a human envelope with a made-up proof: refused(human-required)
ok the agent socket answers a human envelope with human-required
agent socket, the agent's capability on a human-only verb: refused(human-required)
ok an agent capability gets human-required on a human-only verb
## HOST RELAY: the same gate as a host process (can read environ), broker human socket opened to 0666
ok the host relay starts (host user 1000, node v26.8.1)
### scenarios against host-relay (<scratch>/hr/sock)
forger claimed pid 858710: refused(human-required)
relay decisions:
gate peer accept claimed=857595 peer=857595
gate peer refuse human-required claimed=857878 peer=857878
gate peer refuse human-required claimed=858168 peer=858168
gate peer refuse human-required claimed=858395 peer=858395
gate peer refuse human-required claimed=858710 peer=858636
gate peer accept claimed=858710 peer=858710
a host process straight to the broker's human socket (0666): accepted
## the floor: a same-UID process with rootless-engine access
a container started by the host user with the human socket directory mounted and --user 1001:2000: accepted
## negative control: the same gate as a NON-root container (1001:2000, --pid=host), socket 0666
genuine human-cli through the non-root gate: refused
## candidate C, a nonce in a file: who owns a file the host user wrote, as a container sees it
seen by container 0:0: /f/by-host owner 0:0 mode 644;/f/by-container-root owner 0:0 mode 644;
seen by container 1002:2002: /f/by-host owner 0:0 mode 644;/f/by-container-root owner 0:0 mode 644;
seen by the host: by-host owner 1000:1000;by-container-root owner 1000:1000;
ok the file the host user wrote and the file container root wrote have the same owner in a container (0 )
## stop the brokers; what each recorded
gate broker: event {"seq":1,"kind":"session.launched","business":"demo","actor_role":"cto","subject":"spike"}
gate broker: event {"seq":2,"kind":"action.refused","business":"demo","actor_role":"cto","subject":null}
gate broker: events-total 2
## the matrix (accepted = the request reached a human session; refused = human-required)
scenario | baseline | gate-claimed | gate-peer | gate-peer-noenv | host-relay
genuine human-cli, detached, clean env | accepted | refused | refused | accepted | accepted
agent marker in an ancestor only | refused | refused | refused | accepted | refused
agent marker in the CLI itself | refused | refused | refused | accepted | refused
a live ancestor named claude, no marker | refused | refused | refused | refused | refused
snooped nonce, forger on its own connection | accepted | refused(human-required) | refused(human-required) | refused(human-required) | refused(human-required)
the held genuine CLI, afterwards | accepted | refused | refused | accepted | accepted
container root, gate dir mounted, made-up proof: refused(human-required)
container uid 1002, gate dir mounted, made-up proof: no-connect(EACCES)
non-root gate, genuine CLI: refused
host process straight to the broker's human socket at 0666: accepted
engine access (docker run --user 1001:2000 -v hs): accepted
## hypotheses, each stated before this run
ok baseline: genuine human-cli, detached, clean env -> accepted
ok host-relay: genuine human-cli, detached, clean env -> accepted
ok baseline: agent marker in an ancestor only -> refused
ok host-relay: agent marker in an ancestor only -> refused
ok baseline: agent marker in the CLI itself -> refused
ok host-relay: agent marker in the CLI itself -> refused
ok baseline: the held genuine CLI, afterwards -> accepted
ok host-relay: the held genuine CLI, afterwards -> accepted
ok baseline: a snooped-nonce forger IS accepted (the proof checks the claimed process, not the connecting one)
ok host-relay: the same forger is refused (claimed pid != SO_PEERCRED pid)
ok gate-claimed: the genuine CLI is refused (a container cannot read environ, so the nonce check fails closed)
ok gate-peer: the genuine CLI is refused (a container cannot read environ, so the nonce check fails closed)
ok gate-peer-noenv: the genuine CLI is accepted
ok gate-peer-noenv: an agent marker in an ancestor is ACCEPTED (environ unreadable: the weakening)
ok gate-peer-noenv: an agent marker in the CLI itself is ACCEPTED (the weakening)
ok baseline: a live ancestor named claude is refused
ok host-relay: a live ancestor named claude is refused
ok gate-peer-noenv: a live ancestor named claude is still refused (argv is readable)
ok gate-peer-noenv: the snooped-nonce forger is refused
ok a container root client with the gate dir mounted is refused
ok a container uid 1002 client cannot connect to the gate socket
ok a non-root gate cannot read environ, so it refuses the genuine CLI (fails closed)
ok host-relay: a host process reaches the broker's 0666 human socket with no proof (the cost of the relay)
ok engine access reaches the human socket with no proof (the floor)
ok no process with this scratch directory in its command line is left
./h2-gate.sh: line 228: 857298 Killed CLI_PATH="$CLI" MODE=peer PEER_PL="$S/tools/peer.pl" node tools/gate.mjs "$REPO/packages/bus/src" "$S/hr/sock" "$S/hs/human.sock" > "$S/hr/log" 2>&1
cleanup: no container, network, image or scratch directory labelled h2-gate is left
result: pass
@@ -0,0 +1,29 @@
# h2-probe 2026-10-12T20:27:14Z
endpoint unix:///run/user/1000/docker.sock: name=rootless
scratch /mnt/storage/scratch/tmp/i1human-h2-probe.IuMBtf
host view of the held CLI and its ancestors:
host pid 848955 ppid 1274 uid 1000 cmd: sh -c
if [ "$5" = - ]; then node "$1" "$2" < "$3" > "$4" 2>&1; else env -u "$5" node "$1" "$2"
echo "exit $?" >> "$4"
touch "$4.done"
sh /mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs /mnt/storage/scratch/tmp/
host pid 848956 ppid 848955 uid 1000 cmd: node /mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs /mnt/storage/scratch/tmp
host pid 848976 ppid 848956 uid 1000 cmd: /usr/bin/node /mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs /mnt/storage/sc
## probe as container root (uid 0:0), --pid=host, hardened
probe uid=0 pid=849436
target 848976
pid=848976 ppid=848956 uid=0 env=null argv=["/usr/bin/node","/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs","/mnt/storage/scratch/tmp/i1hu raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=848956 ppid=848955 uid=0 env=null argv=["node","/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs","/mnt/storage/scratch/tmp/i1human-h2-pr raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=848955 ppid=1274 uid=0 env=null argv=["sh","-c","\n if [ \"$5\" = - ]; then node \"$1\" \"$2\" < \"$3\" > \"$4\" 2>&1; else env -u \"$5\" node \"$ raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=1274 ppid=1 uid=0 env=null argv=["/usr/lib/systemd/systemd","--user","--deserialize=38"] raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=1 ppid=0 uid=65534 env=null argv=["/usr/lib/systemd/systemd","--switched-root","--system","--deserialize=60","splash"] raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":65534}
## the same with --cap-add SYS_PTRACE (to see whether a capability is what is missing)
probe uid=0 pid=849780
target 848976
pid=848976 ppid=848956 uid=0 env=null argv=["/usr/bin/node","/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs","/mnt/storage/scratch/tmp/i1hu raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=848956 ppid=848955 uid=0 env=null argv=["node","/mnt/storage/src/mosaic-stack/packages/bus/src/human-cli.mjs","/mnt/storage/scratch/tmp/i1human-h2-pr raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=848955 ppid=1274 uid=0 env=null argv=["sh","-c","\n if [ \"$5\" = - ]; then node \"$1\" \"$2\" < \"$3\" > \"$4\" 2>&1; else env -u \"$5\" node \"$ raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=1274 ppid=1 uid=0 env=null argv=["/usr/lib/systemd/systemd","--user","--deserialize=38"] raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":0}
pid=1 ppid=0 uid=65534 env=null argv=["/usr/lib/systemd/systemd","--switched-root","--system","--deserialize=60","splash"] raw={"stat":"ok","environ":"EACCES","cmdline":"ok","lstatUid":65534}
cleanup: no container, network, image or scratch directory labelled h2-probe is left
result: pass
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# usage: detach.sh OUT STDIN CLI SOCK DROP [NAME=VALUE...]
# Runs the real human-cli.mjs detached: `setsid -f` (new session, parent exits, so the
# kernel reparents it) under `env -i` (clean environment, PATH and HOME only, plus the
# NAME=VALUE pairs given). STDIN is the request file, or a FIFO to hold the CLI open. The CLI's output
# and "exit N" go to OUT; OUT.done appears when it has finished.
# DROP names a variable to remove from the node process only, so that the NAME=VALUE pair
# stays in the environment of the shell above it: a marker in an ancestor but not in the CLI.
# Here "detached, clean environment" stands for two things the verifier cannot tell
# apart: the human at their own terminal and a same-UID agent that detached on purpose.
out=$1 in=$2 cli=$3 sock=$4 drop=$5
shift 5
rm -f "$out" "$out.done"
setsid -f env -i PATH="$PATH" HOME="$HOME" "$@" sh -c '
if [ "$5" = - ]; then node "$1" "$2" < "$3" > "$4" 2>&1; else env -u "$5" node "$1" "$2" < "$3" > "$4" 2>&1; fi
echo "exit $?" >> "$4"
touch "$4.done"
' sh "$cli" "$sock" "$in" "$out" "$drop"
@@ -0,0 +1,81 @@
// usage: node forge.mjs <sock> find <cliPath> | random | cap <capFile> | direct
// Spike H2's hostile client. It sends one request, `decisions {list:'roles'}` (a verb that
// only a human or reader session may call), over a raw unix socket and prints one JSON
// line: the connect result (errno on refusal) and the reply. It does not use Client, so
// a refused connect is distinguishable from a refused proof.
// find <cliPath> claim a LIVE human-cli process on this host: scan /proc for argv[1] ==
// cliPath, copy its pid, start time and MOSAIC_BUS_CLI_NONCE (a same-UID
// process may read them). The forger then speaks for it on its own connection.
// random claim an arbitrary pid with a made-up nonce (what a process with no
// access to the host /proc can do)
// cap <file> send a capability, as an agent would on the agent socket
// direct send {human:{business}} with no proof, as a process that reaches the
// broker's human socket directly would
import { connect } from 'node:net';
import { readFileSync, readdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
const [sock, mode, arg] = process.argv.slice(2);
const out = (o) => console.log(JSON.stringify(o));
function find(cliPath) {
for (const d of readdirSync('/proc')) {
if (!/^\d+$/.test(d)) continue;
try {
const argv = readFileSync(`/proc/${d}/cmdline`, 'utf8').split('\0');
if (argv[1] !== cliPath) continue;
const env = readFileSync(`/proc/${d}/environ`, 'utf8').split('\0');
const nonce = env.find((e) => e.startsWith('MOSAIC_BUS_CLI_NONCE='))?.slice(21);
if (!nonce) continue;
const raw = readFileSync(`/proc/${d}/stat`, 'utf8');
return { pid: Number(d), startTime: raw.slice(raw.lastIndexOf(')') + 2).split(' ')[19], nonce };
} catch {}
}
return null;
}
let envelope;
if (mode === 'find') {
const p = find(arg);
if (!p) {
out({ connect: 'not-attempted', note: 'no live human-cli process with a readable nonce' });
process.exit(0);
}
envelope = { human: { business: 'demo', ...p }, claimed: p.pid };
} else if (mode === 'random') {
envelope = { human: { business: 'demo', pid: 4242, startTime: '1', nonce: randomBytes(32).toString('hex') } };
} else if (mode === 'cap') {
envelope = { cap: readFileSync(arg, 'utf8').trim() };
} else if (mode === 'direct') {
envelope = { human: { business: 'demo' } };
} else {
throw Error('usage');
}
const claimed = envelope.claimed;
delete envelope.claimed;
const request = JSON.stringify({ ...envelope, verb: 'decisions', args: { list: 'roles' } }) + '\n';
const s = connect(sock);
let buf = '';
s.setTimeout(8000, () => {
out({ connect: 'ok', reply: 'timeout', claimed });
s.destroy();
});
s.on('connect', () => s.write(request));
s.on('error', (e) => {
out({ connect: e.code ?? 'error', claimed });
process.exit(0);
});
s.on('data', (b) => {
buf += b;
if (!buf.includes('\n')) return;
let reply;
try {
reply = JSON.parse(buf);
} catch {
reply = 'unparsable';
}
out({ connect: 'ok', reply, claimed });
s.destroy();
});
s.on('close', () => process.exit(0));
@@ -0,0 +1,105 @@
// usage: node gate.mjs <bus-src> <listenSock> <forwardSock> (env: CLI_PATH, MODE, SOCK_MODE, PEER_PL)
// Spike H2's human-proof gate: a small relay that runs the repository's own verifyHuman
// against the HOST /proc and, if it passes, forwards the request to the broker's
// group-gated human socket. It holds no credential and no dataRoot, and it is not
// reachable by an agent container (its socket is not mounted anywhere else).
// CLI_PATH host path of human-cli.mjs; verifyHuman compares it with the claimed
// process's argv[1], so the gate must be told the host path, not its own.
// MODE claimed the claimed pid is trusted, exactly as today (parity)
// peer the claimed pid must also equal the pid SO_PEERCRED reports for
// the connecting socket (tools/peer.pl creds on the accepted fd)
// peer-noenv peer, and an unreadable environ (env=null) is replaced by the
// claimed nonce: the only way the check can run inside a container
// SOCK_MODE octal mode for the listening socket (default 600)
// The forwarded envelope carries only {business}: pid, start time and nonce stop here.
import { createServer } from 'node:net';
import { chmodSync, lstatSync, unlinkSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
const [src, listen, forward] = process.argv.slice(2);
const { CLI_PATH, MODE = 'claimed', SOCK_MODE = '600', PEER_PL = '/t/peer.pl' } = process.env;
const mod = (f) => import(pathToFileURL(join(src, f)).href);
const { verifyHuman, readProcess } = await mod('human.mjs');
const { BusError } = await mod('broker.mjs');
const { Client } = await mod('client.mjs');
const LIMIT = 65536;
// The kernel's view of the connecting process. Node cannot ask for SO_PEERCRED, so the
// accepted socket's fd goes to perl as stdin. socket._handle.fd is undocumented.
function peerPid(socket) {
const fd = socket._handle?.fd;
if (!Number.isInteger(fd) || fd < 0) throw new BusError('human-required');
const r = spawnSync('perl', [PEER_PL, 'creds'], { stdio: [fd, 'pipe', 'pipe'], timeout: 2000 });
if (r.status !== 0) throw new BusError('human-required');
const pid = JSON.parse(r.stdout.toString()).pid;
if (!Number.isSafeInteger(pid) || pid < 2) throw new BusError('human-required');
return pid;
}
process.umask(0o077);
const server = createServer({ allowHalfOpen: true }, (socket) => {
socket.on('error', () => {});
socket.setTimeout(5000, () => socket.destroy());
let input = Buffer.alloc(0);
let done = false;
const finish = (result) => {
if (done) return;
done = true;
socket.end(JSON.stringify(result) + '\n');
};
socket.on('data', async (chunk) => {
if (done) return;
if (input.length + chunk.length > LIMIT) return finish({ ok: false, error: 'request-too-large' });
input = Buffer.concat([input, chunk]);
const end = input.indexOf(10);
if (end < 0) return;
let log = 'claimed=? peer=?';
try {
if (end !== input.length - 1) throw new BusError('invalid-envelope');
const r = JSON.parse(input.subarray(0, end).toString('utf8'));
if (
!r ||
typeof r !== 'object' ||
Array.isArray(r) ||
Object.keys(r).some((k) => !['human', 'verb', 'args'].includes(k)) ||
typeof r.verb !== 'string' ||
!r.human
)
throw new BusError('invalid-envelope');
const peer = MODE.startsWith('peer') ? peerPid(socket) : null;
log = `claimed=${r.human.pid} peer=${peer ?? 'unchecked'}`;
if (peer !== null && peer !== r.human.pid) throw new BusError('human-required');
// peer-noenv: a container cannot read /proc/<pid>/environ of a host process (EACCES,
// even for container root with CAP_SYS_PTRACE), so readProcess returns env=null. In
// this mode the kernel-attested peer pid replaces the nonce, and the environment
// markers on the CLI and its ancestors are NOT checked (that is the weakening).
const read =
MODE === 'peer-noenv'
? (pid) => {
const p = readProcess(pid);
return p.env === null ? { ...p, env: { MOSAIC_BUS_CLI_NONCE: r.human.nonce } } : p;
}
: readProcess;
const { business } = verifyHuman(r.human, { cliPath: CLI_PATH, readProcess: read });
const result = await new Client({ path: forward, human: { business } }).call(r.verb, r.args ?? {});
console.log(`gate ${MODE} accept ${log}`);
finish({ ok: true, result });
} catch (e) {
const code = e instanceof BusError ? e.code : 'invalid-envelope';
console.log(`gate ${MODE} refuse ${code} ${log}`);
finish({ ok: false, error: code });
}
});
});
try {
lstatSync(listen);
unlinkSync(listen);
} catch {}
await new Promise((res, rej) => {
server.once('error', rej);
server.listen(listen, res);
});
chmodSync(listen, parseInt(SOCK_MODE, 8));
console.log(`gate ready mode=${MODE} uid=${process.getuid()} listen=${listen} sock=${SOCK_MODE} cli=${CLI_PATH}`);
@@ -0,0 +1,34 @@
// usage: node tools/s4-broker.mjs <repo> <dataRoot> <capFile>
// Starts the repository's broker (startBroker, the way the bus tests do) on a
// temp dataRoot, binds one throwaway capability for a made-up business, writes
// it to <capFile>, and waits. On SIGTERM it closes the broker and prints the
// events table, which is what the broker "logs" (it writes nothing to stdout).
import { pathToFileURL } from "node:url";
import { join } from "node:path";
import { writeFileSync } from "node:fs";
import { DatabaseSync } from "node:sqlite";
const [repo, dataRoot, capFile] = process.argv.slice(2);
const { startBroker } = await import(pathToFileURL(join(repo, "packages/bus/src/index.mjs")).href);
const businesses = {
demo: {
id: "demo",
human: "jason",
arbiters: { technical: "cto", delivery: "cto" },
roles: { cto: { authority: { withinRole: ["message.send"], crossRole: [] } } },
},
};
const bus = await startBroker({ dataRoot, businesses });
const cap = bus.broker.bindLaunch({ business: "demo", role: "cto", run: "spike", harness: "pi" });
writeFileSync(capFile, cap, { mode: 0o600 });
console.log("ready", bus.path);
process.on("SIGTERM", async () => {
await bus.close();
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
for (const r of db.prepare("SELECT seq, kind, business, actor_role, subject, body FROM events ORDER BY seq").all())
console.log("event", JSON.stringify(r));
console.log("events-total", db.prepare("SELECT count(*) n FROM events").get().n);
db.close();
process.exit(0);
});
setInterval(() => {}, 1 << 30);
@@ -0,0 +1,66 @@
// usage: node h2-broker.mjs <bus-src> <dataRoot> <humanSock> <agentSock> <capFile>
// Stand-in for the containerized broker, spike H2. It is the repository's Broker, Store,
// Credentials and serve(), wired the way packages/bus/src/runtime.mjs wires them, except:
// - two listeners, as decision 105 says: an agent socket (authenticateHuman null, so a
// human envelope gets human-required) and a human socket whose authenticateHuman
// trusts the transport (the group-gated socket is the proof) and binds the business
// named in the envelope;
// - the human socket is chmod 0660 after serve() creates it (serve() hard-codes 0600; I3
// needs a mode option);
// - via stays 'cli' because Broker.bindHuman accepts nothing else today (I3 adds a value).
// The data is a made-up business and a throwaway capability on a temp dataRoot.
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
import { writeFileSync, chmodSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';
const [src, dataRoot, humanSock, agentSock, capFile] = process.argv.slice(2);
const mod = (f) => import(pathToFileURL(join(src, f)).href);
const { Broker, BusError } = await mod('broker.mjs');
const { Store } = await mod('store.mjs');
const { Credentials } = await mod('credentials.mjs');
const { serve } = await mod('server.mjs');
const businesses = {
demo: {
id: 'demo',
human: 'jason',
arbiters: { technical: 'cto', delivery: 'cto' },
roles: { cto: { authority: { withinRole: ['message.send'], crossRole: [] } } },
},
};
const credentials = new Credentials({ references: {}, repoRoots: [], dataRoot });
const store = new Store(dataRoot);
const broker = new Broker({
store,
businesses,
secretCheck: (v) => credentials.assertClean(v),
credentialStatus: () => credentials.status(),
});
const agent = await serve({ broker, path: agentSock });
const human = await serve({
broker,
path: humanSock,
authenticateHuman: (proof) => {
const b = businesses[proof?.business];
if (!b) throw new BusError('unknown-business');
return broker.bindHuman({ business: proof.business, human: b.human, via: 'cli', outsideAgent: true });
},
});
chmodSync(humanSock, 0o660);
const cap = broker.bindLaunch({ business: 'demo', role: 'cto', run: 'spike', harness: 'pi' });
writeFileSync(capFile, cap, { mode: 0o644 });
console.log('ready');
process.on('SIGTERM', async () => {
await human.close();
await agent.close();
store.close();
credentials.close();
const db = new DatabaseSync(join(store.directory, 'bus.sqlite'), { readOnly: true });
for (const r of db.prepare('SELECT seq, kind, business, actor_role, subject FROM events ORDER BY seq').all())
console.log('event', JSON.stringify(r));
console.log('events-total', db.prepare('SELECT count(*) n FROM events').get().n);
db.close();
process.exit(0);
});
setInterval(() => {}, 1 << 30);
@@ -0,0 +1,91 @@
#!/usr/bin/perl
# Spike helper: what the kernel tells a unix-socket server about each peer.
# peer.pl serve <path> <mode-octal> accept loop; per connection, reply with one JSON line
# peer.pl call <path> connect, print this process's own view and the reply
# peer.pl creds STDIN is an accepted socket: print its SO_PEERCRED as JSON
# Perl ships in the pinned node image (perl-base) and on the host, so no compiler or new
# dependency is needed. Node has no API for these options. No credentials are involved.
use strict; use warnings;
use Socket;
use constant { SO_PEERGROUPS => 59, SO_PEERPIDFD => 77 };
$| = 1;
sub readf { my ($f) = @_; open(my $h, '<', $f) or return undef; local $/; my $d = <$h>; close $h; return $d; }
sub jstr { my $s = shift; $s = '' unless defined $s; $s =~ s/([\\"])/\\$1/g; $s =~ s/\n/\\n/g; $s =~ s/[\0-\x1f]/ /g; return "\"$s\""; }
sub nsid { my $l = readlink("/proc/self/ns/$_[0]"); return defined $l ? $l : 'unreadable'; }
# SO_PEERCRED, SO_PEERGROUPS, SO_PEERPIDFD for an accepted socket, plus what the
# server can see of the peer's pid in its own /proc.
sub peer {
my ($c) = @_;
my %r;
my $cred = getsockopt($c, SOL_SOCKET, SO_PEERCRED);
if (defined $cred) { @r{qw(pid uid gid)} = unpack('iII', $cred); } else { $r{err} = "SO_PEERCRED: $!"; return \%r; }
my $g = getsockopt($c, SOL_SOCKET, SO_PEERGROUPS);
$r{groups} = defined $g ? [unpack('I*', $g)] : "err: $!";
my $pf = getsockopt($c, SOL_SOCKET, SO_PEERPIDFD);
if (defined $pf) {
my $fd = unpack('i', $pf);
my $info = readf("/proc/self/fdinfo/$fd") // '';
my ($p) = $info =~ /^Pid:\s*(-?\d+)/m; my ($ns) = $info =~ /^NSpid:\s*(.*)$/m;
$r{pidfd} = { fd => $fd, fdinfo_pid => $p, nspid => $ns };
if (open(my $h, '<&=', $fd)) { close $h; }
} else { $r{pidfd} = "err: $!"; }
my $pid = $r{pid};
$r{proc} = {
dir_exists => (-d "/proc/$pid" ? 1 : 0),
stat_readable => (defined readf("/proc/$pid/stat") ? 1 : 0),
cmdline_readable => (defined readf("/proc/$pid/cmdline") ? 1 : 0),
environ_readable => (defined readf("/proc/$pid/environ") ? 1 : 0),
};
my $cl = readf("/proc/$pid/cmdline"); $r{proc}{cmdline} = defined $cl ? $cl : undef;
return \%r;
}
sub jpeer {
my ($r) = @_;
return '{' . join(',', map { jstr($_) . ':' . jval($r->{$_}) } sort keys %$r) . '}';
}
sub jval {
my $v = shift;
return 'null' unless defined $v;
return '[' . join(',', map { jval($_) } @$v) . ']' if ref $v eq 'ARRAY';
return jpeer($v) if ref $v eq 'HASH';
return $v if $v =~ /^-?\d+$/;
return jstr($v);
}
sub me {
my @g = split ' ', $);
my $rg = $(; my ($gid) = split ' ', $rg;
my $um = readf('/proc/self/uid_map') // ''; $um =~ s/\s+/ /g;
return { pid => $$, uid => $<, euid => $>, gid => $gid, groups => [@g[1 .. $#g]],
pidns => nsid('pid'), userns => nsid('user'), uid_map => $um };
}
my $mode = shift // '';
if ($mode eq 'serve') {
my ($path, $perm) = @ARGV;
socket(my $s, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!";
unlink $path;
bind($s, sockaddr_un($path)) or die "bind: $!";
chmod oct($perm), $path or die "chmod: $!";
listen($s, 16) or die "listen: $!";
print "listening $path $perm\n";
$SIG{TERM} = $SIG{INT} = sub { unlink $path; exit 0 };
while (accept(my $c, $s)) {
my $r = peer($c);
print $c '{"server":' . jpeer(me()) . ',"peer_as_server_sees_it":' . jpeer($r) . "}\n";
close $c;
}
} elsif ($mode eq 'call') {
my ($path) = @ARGV;
socket(my $s, PF_UNIX, SOCK_STREAM, 0) or die "socket: $!";
if (!connect($s, sockaddr_un($path))) { print '{"connect":"refused","errno":' . jstr("$!") . ',"client":' . jpeer(me()) . "}\n"; exit 0; }
my $line = <$s>; $line = '' unless defined $line; chomp $line;
print '{"connect":"ok","client":' . jpeer(me()) . ',"reply":' . ($line eq '' ? 'null' : $line) . "}\n";
} elsif ($mode eq 'creds') {
open(my $c, '<&=', 0) or die "fd 0: $!";
print jpeer(peer($c)) . "\n";
} else { die "usage: peer.pl serve|call|creds\n"; }
@@ -0,0 +1,47 @@
// usage: node probe.mjs <bus-src> <cliPath> (run by a debug spike, not by the packet's results)
// Finds a live human-cli on the host /proc, then walks its ancestry the way verifyHuman
// does and prints each step's readProcess result (and the exact error if one fails), so a
// refusal can be traced to the field that caused it.
import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
import { readdirSync, readFileSync, lstatSync } from 'node:fs';
const [src, cli] = process.argv.slice(2);
const { readProcess } = await import(pathToFileURL(join(src, 'human.mjs')).href);
console.log(`probe uid=${process.getuid()} pid=${process.pid}`);
let target = null;
for (const d of readdirSync('/proc')) {
if (!/^\d+$/.test(d)) continue;
try {
const a = readFileSync(`/proc/${d}/cmdline`, 'utf8').split('\0');
if (a[1] === cli) target = Number(d);
} catch {}
}
console.log('target', target);
let pid = target;
for (let i = 0; pid && i < 12; i++) {
let raw = {};
for (const f of ['stat', 'environ', 'cmdline']) {
try {
readFileSync(`/proc/${pid}/${f}`, 'utf8');
raw[f] = 'ok';
} catch (e) {
raw[f] = e.code;
}
}
try {
raw.lstatUid = lstatSync(`/proc/${pid}`).uid;
} catch (e) {
raw.lstatUid = e.code;
}
let p;
try {
p = readProcess(pid);
console.log(
`pid=${pid} ppid=${p.ppid} uid=${p.uid} env=${p.env === null ? 'null' : Object.keys(p.env).join(',') || '{}'} argv=${JSON.stringify(p.argv).slice(0, 110)} raw=${JSON.stringify(raw)}`,
);
} catch (e) {
console.log(`pid=${pid} readProcess REFUSED raw=${JSON.stringify(raw)}`);
break;
}
pid = p.ppid;
}
@@ -0,0 +1,12 @@
#!/bin/sh
# usage: under-claude.sh OUT STDIN CLI SOCK
# Runs the real human-cli.mjs as a descendant of a live process whose argv[0] is "claude",
# in a clean environment (no agent marker anywhere). The whole chain is detached with
# `setsid -f` so the test shell is not an ancestor. This is the one agent signal that
# lives in argv, not in environ, so a verifier that cannot read environ can still see it.
# OUT gets the CLI's output and "exit N"; OUT.done appears when it has finished.
out=$1 in=$2 cli=$3 sock=$4
rm -f "$out" "$out.done"
setsid -f env -i PATH="$PATH" HOME="$HOME" bash -c '
exec -a claude bash -c "node \"\$1\" \"\$2\" < \"\$3\" > \"\$4\" 2>&1; echo \"exit \$?\" >> \"\$4\"; touch \"\$4.done\"" claude "$1" "$2" "$3" "$4"
' bash "$cli" "$sock" "$in" "$out"