researcher: row 87 human-proof research packet, manifest 1ae3020d
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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`.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user